Skip to content

Commit e074cca

Browse files
authored
feat(adopt): confirm the installed node's fingerprint in the adopt wizard (#143)
After the node installs and reboots, the manager pauses the adoption on the awaiting-fingerprint-confirmation phase with the certificate the installed node presents. The wizard shows that fingerprint in readable pairs to compare with the Mgmt SHA-256 line on the node's console, and offers a confirm action (ConfirmAdoptionFingerprint) or a cancel that ends the stream so nothing is trusted or recorded. Mock mode pauses the same way so the flow can be tried offline. Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> Co-authored-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
1 parent e8099d6 commit e074cca

5 files changed

Lines changed: 450 additions & 30 deletions

File tree

‎src/lib/adopt.test.ts‎

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,13 @@ import { afterEach, describe, expect, it, vi } from "vitest";
1818

1919
import type { MachineConfig } from "@/gen/fleet/cryptos/v1/config_pb";
2020

21-
import { adoptNode, previewAdoption } from "@/lib/adopt";
21+
import {
22+
adoptNode,
23+
confirmAdoptionFingerprint,
24+
fingerprintsMatch,
25+
formatFingerprint,
26+
previewAdoption,
27+
} from "@/lib/adopt";
2228
import * as clientMod from "@/lib/fleet/client";
2329
import * as modeMod from "@/lib/fleet/mode";
2430

@@ -48,17 +54,58 @@ describe("adopt (mock mode)", () => {
4854
for await (const step of adoptNode("host:9000", "AB:CD", config)) {
4955
phases.push(step.phase);
5056
final = step.done;
57+
if (step.phase === "awaiting-fingerprint-confirmation") {
58+
await confirmAdoptionFingerprint(step.adoptionId, step.presentedCertSha256);
59+
}
5160
}
5261
expect(phases).toEqual([
5362
"applying-config",
5463
"installing",
5564
"awaiting-reboot",
65+
"awaiting-fingerprint-confirmation",
5666
"ceremony",
5767
"established",
5868
]);
5969
expect(final).toBe(true);
6070
});
6171

72+
it("pauses on the installed node's fingerprint until it is confirmed", async () => {
73+
vi.spyOn(modeMod, "fleetMode").mockReturnValue("mock");
74+
const iter = adoptNode("host:9000", "AB:CD", config);
75+
let step = (await iter.next()).value;
76+
while (step && step.phase !== "awaiting-fingerprint-confirmation") {
77+
step = (await iter.next()).value;
78+
}
79+
expect(step?.adoptionId).not.toBe("");
80+
expect(step?.presentedCertSha256).toMatch(/^[0-9a-f]{64}$/);
81+
82+
let resumed = false;
83+
const next = iter.next().then((r) => {
84+
resumed = true;
85+
return r;
86+
});
87+
await new Promise((resolve) => setTimeout(resolve, 400));
88+
expect(resumed).toBe(false);
89+
90+
await confirmAdoptionFingerprint(step!.adoptionId, step!.presentedCertSha256.toUpperCase());
91+
expect((await next).value?.phase).toBe("ceremony");
92+
await iter.return(undefined);
93+
});
94+
95+
it("fails the mock adoption on a mismatched fingerprint", async () => {
96+
vi.spyOn(modeMod, "fleetMode").mockReturnValue("mock");
97+
const iter = adoptNode("host:9000", "AB:CD", config);
98+
let step = (await iter.next()).value;
99+
while (step && step.phase !== "awaiting-fingerprint-confirmation") {
100+
step = (await iter.next()).value;
101+
}
102+
const next = iter.next();
103+
await expect(confirmAdoptionFingerprint(step!.adoptionId, "00".repeat(32))).rejects.toThrow(
104+
/does not match/i,
105+
);
106+
await expect(next).rejects.toThrow(/does not match/i);
107+
});
108+
62109
it("refuses to adopt without a confirmed fingerprint", async () => {
63110
vi.spyOn(modeMod, "fleetMode").mockReturnValue("mock");
64111
const iter = adoptNode("host:9000", "", config);
@@ -90,6 +137,73 @@ describe("adopt (live mode)", () => {
90137
expect(seen).toEqual(["applying-config", "established"]);
91138
});
92139

140+
it("relays the adoption ID and the presented fingerprint", async () => {
141+
vi.spyOn(modeMod, "fleetMode").mockReturnValue("live");
142+
vi.spyOn(clientMod, "fleetClient").mockReturnValue({
143+
adoptNode: vi.fn().mockReturnValue(
144+
(async function* () {
145+
yield {
146+
adoptionId: "adopt-1",
147+
detail: "",
148+
done: false,
149+
phase: "awaiting-fingerprint-confirmation",
150+
presentedCertSha256: "ab".repeat(32),
151+
};
152+
})(),
153+
),
154+
} as unknown as ReturnType<typeof clientMod.fleetClient>);
155+
156+
const seen = [];
157+
for await (const step of adoptNode("host:9000", "AB:CD", config)) seen.push(step);
158+
159+
expect(seen).toEqual([
160+
{
161+
adoptionId: "adopt-1",
162+
detail: "",
163+
done: false,
164+
phase: "awaiting-fingerprint-confirmation",
165+
presentedCertSha256: "ab".repeat(32),
166+
},
167+
]);
168+
});
169+
170+
it("passes the stop signal to the adoption stream", async () => {
171+
vi.spyOn(modeMod, "fleetMode").mockReturnValue("live");
172+
const adoptNodeRpc = vi.fn().mockReturnValue((async function* () {})());
173+
vi.spyOn(clientMod, "fleetClient").mockReturnValue({
174+
adoptNode: adoptNodeRpc,
175+
} as unknown as ReturnType<typeof clientMod.fleetClient>);
176+
const controller = new AbortController();
177+
178+
for await (const _ of adoptNode("host:9000", "AB:CD", config, controller.signal)) {
179+
// drain
180+
}
181+
182+
expect(adoptNodeRpc).toHaveBeenCalledWith(expect.anything(), { signal: controller.signal });
183+
});
184+
185+
it("sends the confirmed fingerprint for the adoption", async () => {
186+
vi.spyOn(modeMod, "fleetMode").mockReturnValue("live");
187+
const confirm = vi.fn().mockResolvedValue({});
188+
vi.spyOn(clientMod, "fleetClient").mockReturnValue({
189+
confirmAdoptionFingerprint: confirm,
190+
} as unknown as ReturnType<typeof clientMod.fleetClient>);
191+
192+
await confirmAdoptionFingerprint("adopt-1", "ab".repeat(32));
193+
194+
expect(confirm).toHaveBeenCalledWith({ adoptionId: "adopt-1", certSha256: "ab".repeat(32) });
195+
});
196+
197+
it("surfaces a live confirm refusal", async () => {
198+
vi.spyOn(modeMod, "fleetMode").mockReturnValue("live");
199+
vi.spyOn(clientMod, "fleetClient").mockReturnValue({
200+
confirmAdoptionFingerprint: vi.fn().mockRejectedValue(new Error("fingerprint mismatch")),
201+
} as unknown as ReturnType<typeof clientMod.fleetClient>);
202+
await expect(confirmAdoptionFingerprint("adopt-1", "ab".repeat(32))).rejects.toThrow(
203+
/mismatch/,
204+
);
205+
});
206+
93207
it("surfaces a live preview error (no silent fallback)", async () => {
94208
vi.spyOn(modeMod, "fleetMode").mockReturnValue("live");
95209
vi.spyOn(clientMod, "fleetClient").mockReturnValue({
@@ -98,3 +212,17 @@ describe("adopt (live mode)", () => {
98212
await expect(previewAdoption("host:9000")).rejects.toThrow(/unreachable/);
99213
});
100214
});
215+
216+
describe("fingerprint helpers", () => {
217+
it("compares fingerprints ignoring case, colons and spaces", () => {
218+
expect(fingerprintsMatch("AB:CD:EF", "abcdef")).toBe(true);
219+
expect(fingerprintsMatch(" ab cd ef ", "AB:CD:EF")).toBe(true);
220+
expect(fingerprintsMatch("abcdef", "abcde0")).toBe(false);
221+
expect(fingerprintsMatch("", "")).toBe(false);
222+
});
223+
224+
it("groups a fingerprint in colon-separated uppercase pairs for reading", () => {
225+
expect(formatFingerprint("abcdef01")).toBe("AB:CD:EF:01");
226+
expect(formatFingerprint("AB:CD")).toBe("AB:CD");
227+
});
228+
});

‎src/lib/adopt.ts‎

Lines changed: 111 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,13 @@ import { type InstallDisk, InstallDiskSchema } from "@/gen/fleet/cryptos/v1/node
2121
import { fleetClient } from "@/lib/fleet/client";
2222
import { fleetMode } from "@/lib/fleet/mode";
2323

24-
// Adopt-a-new-node (S10). The web drives two manager RPCs: PreviewAdoption
25-
// (unary, trust-on-first-use fingerprint) and AdoptNode (server-streaming,
26-
// live phase progress). The web never contacts the maintenance node directly
27-
// and never sends any secret to an unpinned endpoint -- the manager holds the
28-
// pin and orchestrates the whole apply -> install -> reboot -> ceremony flow.
24+
// Adopt-a-new-node (S10). The web drives three manager RPCs: PreviewAdoption
25+
// (unary, trust-on-first-use fingerprint), AdoptNode (server-streaming, live
26+
// phase progress) and ConfirmAdoptionFingerprint (unary, confirms the
27+
// installed node's certificate while the stream waits). The web never contacts
28+
// the maintenance node directly and never sends any secret to an unpinned
29+
// endpoint -- the manager holds the pin and orchestrates the whole apply ->
30+
// install -> reboot -> confirm -> ceremony flow.
2931

3032
// AdoptionPreview is the maintenance node's presented identity the operator
3133
// confirms before adoption proceeds.
@@ -36,14 +38,71 @@ export interface AdoptionPreview {
3638

3739
// AdoptPhase is one streamed step of the orchestration. `done` marks the final
3840
// message; the phase string is one of the manager's documented phases
39-
// (applying-config, installing, awaiting-reboot, ceremony, established) or an
40-
// error phase when a step fails.
41+
// (applying-config, installing, awaiting-reboot,
42+
// awaiting-fingerprint-confirmation, ceremony, established,
43+
// awaiting-certificate) or an error phase when a step fails. adoptionId names
44+
// the run; presentedCertSha256 is set only while the manager waits for the
45+
// installed node's fingerprint to be confirmed.
4146
export interface AdoptPhase {
47+
adoptionId: string;
4248
detail: string;
4349
done: boolean;
4450
phase: string;
51+
presentedCertSha256: string;
4552
}
4653

54+
export const AWAITING_FINGERPRINT = "awaiting-fingerprint-confirmation";
55+
56+
const normalizeFingerprint = (fp: string): string => fp.replaceAll(/[\s:]/g, "").toLowerCase();
57+
58+
// fingerprintsMatch compares two SHA-256 fingerprints the way the manager
59+
// does: case, colons and whitespace are ignored.
60+
export const fingerprintsMatch = (a: string, b: string): boolean => {
61+
const na = normalizeFingerprint(a);
62+
return na !== "" && na === normalizeFingerprint(b);
63+
};
64+
65+
// formatFingerprint renders a fingerprint as colon-separated uppercase pairs,
66+
// easier to read against the node console than one long hex run.
67+
export const formatFingerprint = (fp: string): string =>
68+
(normalizeFingerprint(fp).match(/.{1,2}/g) ?? []).join(":").toUpperCase();
69+
70+
// The mock adoption's installed-node fingerprint, and the confirmations it is
71+
// waiting for, keyed by adoption ID.
72+
const MOCK_INSTALLED_SHA256 = "5f".repeat(32);
73+
const mockWaiting = new Map<string, { reject: (e: Error) => void; resolve: () => void }>();
74+
let mockAdoptions = 0;
75+
76+
// confirmAdoptionFingerprint tells the manager the operator checked the
77+
// installed node's fingerprint against its console, so the paused adoption
78+
// continues on the AdoptNode stream. A mismatch is refused by the manager and
79+
// the adoption fails; the error propagates to the caller.
80+
export const confirmAdoptionFingerprint = async (
81+
adoptionId: string,
82+
certSha256: string,
83+
): Promise<void> => {
84+
if (!adoptionId || !certSha256) {
85+
throw new Error("An adoption and a fingerprint are required.");
86+
}
87+
if (fleetMode() === "mock") {
88+
const waiting = mockWaiting.get(adoptionId);
89+
if (!waiting) {
90+
throw new Error(`No adoption ${adoptionId} is waiting for a fingerprint.`);
91+
}
92+
mockWaiting.delete(adoptionId);
93+
if (!fingerprintsMatch(certSha256, MOCK_INSTALLED_SHA256)) {
94+
const mismatch = new Error(
95+
"The confirmed fingerprint does not match the node's certificate.",
96+
);
97+
waiting.reject(mismatch);
98+
throw mismatch;
99+
}
100+
waiting.resolve();
101+
return;
102+
}
103+
await fleetClient().confirmAdoptionFingerprint({ adoptionId, certSha256 });
104+
};
105+
47106
// previewAdoption fetches the maintenance node's certificate fingerprint and
48107
// subject so the operator can confirm it (TOFU). `mock` returns a stable
49108
// canned preview so the wizard is exercisable offline; live surfaces a
@@ -140,6 +199,7 @@ export async function* adoptNode(
140199
endpoint: string,
141200
pinnedCertSha256: string,
142201
config: MachineConfig,
202+
signal?: AbortSignal,
143203
): AsyncGenerator<AdoptPhase> {
144204
const trimmed = endpoint.trim();
145205
if (!trimmed) {
@@ -155,35 +215,65 @@ export async function* adoptNode(
155215
// script coherent with the role so the offline demo matches the live flow.
156216
const kind = config.role?.kind ?? "";
157217
const subordinate = kind !== "" && kind !== "root";
218+
mockAdoptions += 1;
219+
const adoptionId = `mock-adoption-${mockAdoptions}`;
220+
const step = (phase: string, detail: string, done = false): AdoptPhase => ({
221+
adoptionId,
222+
detail,
223+
done,
224+
phase,
225+
presentedCertSha256: phase === AWAITING_FINGERPRINT ? MOCK_INSTALLED_SHA256 : "",
226+
});
158227
const common: AdoptPhase[] = [
159-
{ detail: "Applying the initial machine config.", done: false, phase: "applying-config" },
160-
{ detail: "Installing the CryptOS runtime.", done: false, phase: "installing" },
161-
{ detail: "Waiting for the node to reboot.", done: false, phase: "awaiting-reboot" },
228+
step("applying-config", "Applying the initial machine config."),
229+
step("installing", "Installing the CryptOS runtime."),
230+
step("awaiting-reboot", "Waiting for the node to reboot."),
231+
step(AWAITING_FINGERPRINT, "Confirm the node's Mgmt SHA-256 from its console."),
162232
];
163233
const scripted: AdoptPhase[] = subordinate
164234
? [
165235
...common,
166-
{
167-
detail: "Subordinate provisioned; awaiting a parent-signed certificate.",
168-
done: true,
169-
phase: "awaiting-certificate",
170-
},
236+
step(
237+
"awaiting-certificate",
238+
"Subordinate provisioned; awaiting a parent-signed certificate.",
239+
true,
240+
),
171241
]
172242
: [
173243
...common,
174-
{ detail: "Running the enrollment ceremony.", done: false, phase: "ceremony" },
175-
{ detail: "Node established and linked to the fleet.", done: true, phase: "established" },
244+
step("ceremony", "Running the enrollment ceremony."),
245+
step("established", "Node established and linked to the fleet.", true),
176246
];
177-
for (const step of scripted) {
247+
for (const s of scripted) {
178248
// A short delay makes the mock progress visibly step through phases.
179249
await new Promise((resolve) => setTimeout(resolve, 150));
180-
yield step;
250+
if (s.phase === AWAITING_FINGERPRINT) {
251+
const confirmed = new Promise<void>((resolve, reject) => {
252+
mockWaiting.set(adoptionId, { reject, resolve });
253+
});
254+
// Keep a mismatch that nobody is awaiting yet from surfacing as an
255+
// unhandled rejection; it still rejects the await below.
256+
confirmed.catch(() => undefined);
257+
yield s;
258+
await confirmed;
259+
continue;
260+
}
261+
yield s;
181262
}
182263
return;
183264
}
184265

185-
const stream = fleetClient().adoptNode({ config, endpoint: trimmed, pinnedCertSha256 });
266+
const request = { config, endpoint: trimmed, pinnedCertSha256 };
267+
const stream = signal
268+
? fleetClient().adoptNode(request, { signal })
269+
: fleetClient().adoptNode(request);
186270
for await (const message of stream) {
187-
yield { detail: message.detail, done: message.done, phase: message.phase };
271+
yield {
272+
adoptionId: message.adoptionId,
273+
detail: message.detail,
274+
done: message.done,
275+
phase: message.phase,
276+
presentedCertSha256: message.presentedCertSha256,
277+
};
188278
}
189279
}

‎src/lib/wire-fields.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { type DescMessage, fromJson, type JsonValue, toJson } from "@bufbuild/pr
1818
import { describe, expect, it } from "vitest";
1919

2020
import {
21+
AdoptNodeResponseSchema,
2122
ApprovalSchema,
2223
AuditEventSchema as FleetAuditEventSchema,
2324
McpKeySchema,
@@ -37,6 +38,15 @@ const roundTrip = (schema: DescMessage, wire: JsonValue): JsonValue =>
3738
toJson(schema, fromJson(schema, wire, { ignoreUnknownFields: true }));
3839

3940
describe("generated stubs keep every field the node sends", () => {
41+
it("keeps the adoption ID and the presented fingerprint on an adoption message", () => {
42+
const wire = {
43+
adoptionId: "adopt-1",
44+
phase: "awaiting-fingerprint-confirmation",
45+
presentedCertSha256: "5f".repeat(32),
46+
};
47+
expect(roundTrip(AdoptNodeResponseSchema, wire)).toEqual(wire);
48+
});
49+
4050
it("keeps network nameservers and search domains on a MachineConfig", () => {
4151
const wire = {
4252
apiVersion: "cryptos.dev/v1alpha1",

0 commit comments

Comments
 (0)