From c52dee2c1b9cc00c2a558bf1a4f8a9104744e280 Mon Sep 17 00:00:00 2001 From: Bugs5382 Date: Sat, 25 Jul 2026 15:37:41 -0400 Subject: [PATCH 1/2] feat(adopt): select a parent CA and pin its anchor for subordinate nodes When the adopt role is intermediate or issuing, the wizard now lists the established fleet nodes as eligible parents; selecting one fetches that node's own CA certificate (GetNode identity chain, first block) and embeds it as pki.parent.ca_cert_pem so the node validator accepts the subordinate config. The subject label drops "Root", root validity is hidden, and the progress rail ends at awaiting-certificate with a pointer to complete the chain via a subordinate enrollment. The mock script mirrors the role so the offline demo matches the live flow. Also sorts vite.config.ts keys to satisfy the lint gate (overlaps the X1 PR; resolves identically). Closes #73 --- src/lib/adopt.ts | 48 ++++++++++- src/pages/adopt.tsx | 192 +++++++++++++++++++++++++++++++++++--------- 2 files changed, 200 insertions(+), 40 deletions(-) diff --git a/src/lib/adopt.ts b/src/lib/adopt.ts index 6ea1c74..2d0c878 100644 --- a/src/lib/adopt.ts +++ b/src/lib/adopt.ts @@ -64,6 +64,31 @@ export const previewAdoption = async (endpoint: string): Promise { + const match = chainPem.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/); + return match ? `${match[0]}\n` : ""; +}; + +// fetchParentAnchor loads the chosen parent node's own CA certificate (PEM) to +// embed as pki.parent.ca_cert_pem when adopting a subordinate: the child pins +// this anchor and later verifies the parent-signed chain against it. `mock` +// returns a canned block so the wizard is exercisable offline. +export const fetchParentAnchor = async (nodeName: string): Promise => { + if (fleetMode() === "mock") { + return `-----BEGIN CERTIFICATE-----\nMOCK-PARENT-ANCHOR (${nodeName})\n-----END CERTIFICATE-----\n`; + } + const response = await fleetClient().getNode({ name: nodeName }); + const anchor = firstPemBlock(response.node?.identity?.chainPem ?? ""); + if (!anchor) { + throw new Error(`Parent ${nodeName} has no certificate to anchor to yet.`); + } + return anchor; +}; + // adoptNode drives the orchestrated adoption and yields each streamed phase. // It is an async generator so the wizard can render progress as it arrives. // In `mock` mode it walks a scripted sequence of phases (no live stream) so the @@ -85,13 +110,30 @@ export async function* adoptNode( } if (fleetMode() === "mock") { - const scripted: AdoptPhase[] = [ + // A subordinate skips the ceremony and ends awaiting a parent-signed cert; + // a root self-signs via the ceremony and reaches established. Keep the mock + // script coherent with the role so the offline demo matches the live flow. + const kind = config.role?.kind ?? ""; + const subordinate = kind !== "" && kind !== "root"; + const common: AdoptPhase[] = [ { detail: "Applying the initial machine config.", done: false, phase: "applying-config" }, { detail: "Installing the CryptOS runtime.", done: false, phase: "installing" }, { detail: "Waiting for the node to reboot.", done: false, phase: "awaiting-reboot" }, - { detail: "Running the enrollment ceremony.", done: false, phase: "ceremony" }, - { detail: "Node established and linked to the fleet.", done: true, phase: "established" }, ]; + const scripted: AdoptPhase[] = subordinate + ? [ + ...common, + { + detail: "Subordinate provisioned; awaiting a parent-signed certificate.", + done: true, + phase: "awaiting-certificate", + }, + ] + : [ + ...common, + { detail: "Running the enrollment ceremony.", done: false, phase: "ceremony" }, + { detail: "Node established and linked to the fleet.", done: true, phase: "established" }, + ]; for (const step of scripted) { // A short delay makes the mock progress visibly step through phases. await new Promise((resolve) => setTimeout(resolve, 150)); diff --git a/src/pages/adopt.tsx b/src/pages/adopt.tsx index 5d3f486..ce5134b 100644 --- a/src/pages/adopt.tsx +++ b/src/pages/adopt.tsx @@ -22,7 +22,14 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; import { useAuth } from "@/context/auth"; import { MachineConfigSchema } from "@/gen/fleet/cryptos/v1/config_pb"; -import { type AdoptionPreview, adoptNode, type AdoptPhase, previewAdoption } from "@/lib/adopt"; +import { + type AdoptionPreview, + adoptNode, + type AdoptPhase, + fetchParentAnchor, + previewAdoption, +} from "@/lib/adopt"; +import { useNodes } from "@/lib/nodes"; const field = "w-full rounded-md border bg-card px-3 py-2 font-mono text-sm"; const label = "font-mono text-[11px] uppercase tracking-wider text-muted-foreground"; @@ -37,8 +44,16 @@ const tierToMode: Record = { }; // The manager's documented phases, in order, so the progress rail can show -// every step and mark those already passed. -const PHASES = ["applying-config", "installing", "awaiting-reboot", "ceremony", "established"]; +// every step and mark those already passed. A root self-signs via the ceremony +// and reaches "established"; a subordinate skips the ceremony and ends at +// "awaiting-certificate", completed later by a subordinate enrollment. +const ROOT_PHASES = ["applying-config", "installing", "awaiting-reboot", "ceremony", "established"]; +const SUBORDINATE_PHASES = [ + "applying-config", + "installing", + "awaiting-reboot", + "awaiting-certificate", +]; // PhaseRail renders the ordered adoption phases with the current one // highlighted and completed ones checked. It reads live from the streamed @@ -60,13 +75,22 @@ const phaseGlyph = (active: boolean, passed: boolean): string => { return "[ ]"; }; -const PhaseRail = ({ current, error }: { current: null | string; error: boolean }) => { - const currentIndex = current ? PHASES.indexOf(current) : -1; +const PhaseRail = ({ + current, + error, + phases, +}: { + current: null | string; + error: boolean; + phases: string[]; +}) => { + const terminal = phases.at(-1); + const currentIndex = current ? phases.indexOf(current) : -1; return (
    - {PHASES.map((p, i) => { - const passed = currentIndex > i || (currentIndex === i && current === "established"); - const active = currentIndex === i && current !== "established"; + {phases.map((p, i) => { + const passed = currentIndex > i || (currentIndex === i && current === terminal); + const active = currentIndex === i && current !== terminal; return (
  1. @@ -98,10 +122,34 @@ export const AdoptPage = () => { const [crl, setCrl] = useState(""); const [tier, setTier] = useState(tiers[0]); + // Subordinate (intermediate/issuing) adoption: the operator picks a parent + // node and we embed its CA certificate as the trust anchor. Established nodes + // are the eligible parents; the anchor is fetched on selection. + const isSubordinate = role !== "root"; + const parents = useNodes().filter((n) => n.identityState === "ESTABLISHED"); + const [parentName, setParentName] = useState(""); + const [parentAnchor, setParentAnchor] = useState(""); + const [parentBusy, setParentBusy] = useState(false); + const [phase, setPhase] = useState(null); const [pending, setPending] = useState(false); const [error, setError] = useState(""); + const selectParent = async (name: string) => { + setParentName(name); + setParentAnchor(""); + if (!name) return; + setError(""); + setParentBusy(true); + try { + setParentAnchor(await fetchParentAnchor(name)); + } catch (error_: unknown) { + setError(error_ instanceof Error ? error_.message : "Could not load the parent certificate"); + } finally { + setParentBusy(false); + } + }; + if (!isAdmin) { return (
    @@ -135,18 +183,29 @@ export const AdoptPage = () => { // The initial config the manager applies to the maintenance node. Only the // fields the operator set are populated; the node fills its build-time // defaults for the rest. + // A root self-signs, so it carries root_validity_years and no parent. A + // subordinate's validity comes from the parent's sub-CA profile at signing + // time, so it omits root_validity_years and instead pins the parent anchor. + const pki = isSubordinate + ? { + parent: { caCertPem: parentAnchor }, + revocationBaseUrl: crl, + rootKeyAlg: "ECDSA-P384", + rootSubject: { commonName: rootCn }, + } + : { + revocationBaseUrl: crl, + rootKeyAlg: "ECDSA-P384", + rootSubject: { commonName: rootCn }, + rootValidityYears: Number(validityYears) || 10, + }; const config = create(MachineConfigSchema, { apiVersion: "cryptos.dev/v1alpha1", install: { disk }, kind: "MachineConfig", metadata: { name: nodeName }, network: { address, gateway, interface: netInterface }, - pki: { - revocationBaseUrl: crl, - rootKeyAlg: "ECDSA-P384", - rootSubject: { commonName: rootCn }, - rootValidityYears: Number(validityYears) || 10, - }, + pki, role: { kind: role }, stateKey: { mode: tierToMode[tier] ?? "" }, }); @@ -162,6 +221,7 @@ export const AdoptPage = () => { }; const established = phase?.done && phase.phase === "established"; + const awaitingCert = phase?.done && phase.phase === "awaiting-certificate"; return (
    @@ -247,18 +307,58 @@ export const AdoptPage = () => { ))} + + {/* A subordinate is signed by a parent CA already in the fleet: pick it + and pin its certificate as the trust anchor. */} + {isSubordinate ? ( + + ) : null} + - + {isSubordinate ? null : ( + + )} - + {/* Revocation is a CA responsibility, so it is offered only for a root + at adopt time. A subordinate is not a CA until its enrollment is + signed; its revocation base URL is set afterward via apply-config. */} + {isSubordinate ? null : ( + + )}