From d3f8efcdc072f336176cc83be78754e0c0316a7b Mon Sep 17 00:00:00 2001 From: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:14:08 -0400 Subject: [PATCH] feat(first-run): a first-run wizard behind a gate that asks the bootstrap state first Before sign-in the web UI now asks GetBootstrapState. Closed, not applicable, or a manager without first run: the normal sign-in. Open: it tries WhoAmI, and a browser that already holds an admin certificate goes straight in; otherwise the first-run wizard opens. Unavailable: it says why, then shows the sign-in. The wizard: - Token: the server certificate fingerprint check first, where to find the token on Linux, macOS and Windows, and the already-started notice. The session secret lives only in memory and goes only in the Fleetos-Bootstrap-Session header. - Operator CA: the shared registration form (CRL source, OCSP mode, fingerprint confirm), or re-confirming a CA a previous session registered. - First admin, path A: name and email, a P-384 key with the key backup downloaded before the CSR is shown, the CSR with the admin section and signing command, then the signed certificate is recorded and the PKCS#12 built with the backup's passphrase and the CA as the chain. It can also continue from an existing key backup. - First admin, path B: the OpenSSL recipe and an optional pre-flight. - Install: steps for each OS and a reload. Mock mode keeps first run closed unless VITE_BOOTSTRAP_MOCK_STATE=open. The stubs are refreshed to api main for the new 1610 FULL_NAME sub-reason, which the first admin submission can return; it has its own message. Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> --- AGENTS.md | 6 + README.md | 4 + src/App.test.tsx | 6 +- src/App.tsx | 9 +- src/components/first-run/admin-step.tsx | 406 ++++++++++++++++++ src/components/first-run/ca-step.tsx | 153 +++++++ .../first-run/first-run-wizard.test.tsx | 287 +++++++++++++ src/components/first-run/first-run-wizard.tsx | 143 ++++++ src/components/first-run/token-step.tsx | 136 ++++++ src/components/layout/bootstrap-gate.test.tsx | 135 ++++++ src/components/layout/bootstrap-gate.tsx | 113 +++++ src/components/operator-ca-register-form.tsx | 7 +- src/context/auth.tsx | 9 + src/gen/fleet/cryptos/fleet/v1/errors_pb.ts | 10 +- src/gen/fleet/cryptos/fleet/v1/fleet_pb.ts | 94 +++- src/lib/bootstrap.test.ts | 149 +++++++ src/lib/bootstrap.ts | 191 ++++++++ src/lib/fleet/error-copy.test.ts | 1 + src/lib/fleet/error-copy.ts | 1 + src/lib/operator-cas.ts | 4 +- vite.config.ts | 4 + 21 files changed, 1844 insertions(+), 24 deletions(-) create mode 100644 src/components/first-run/admin-step.tsx create mode 100644 src/components/first-run/ca-step.tsx create mode 100644 src/components/first-run/first-run-wizard.test.tsx create mode 100644 src/components/first-run/first-run-wizard.tsx create mode 100644 src/components/first-run/token-step.tsx create mode 100644 src/components/layout/bootstrap-gate.test.tsx create mode 100644 src/components/layout/bootstrap-gate.tsx create mode 100644 src/lib/bootstrap.test.ts create mode 100644 src/lib/bootstrap.ts diff --git a/AGENTS.md b/AGENTS.md index 1838e60..add5641 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,12 @@ woff2, strict CSP, no runtime CDN) for air-gap use. encrypted key backup (PBES2 PKCS#8), reads it back, and builds the PKCS#12 with the backup's own passphrase; the credential wizards hold only the encrypted backup in memory. Tests assert on the request bodies that neither the key nor the passphrase is ever sent. +- First run: `BootstrapGate` (`src/components/layout/bootstrap-gate.tsx`) wraps the auth gate and + asks `GetBootstrapState` before anything else. Open first run tries WhoAmI, then shows the + `FirstRunWizard` (`src/components/first-run/`). `src/lib/bootstrap.ts` keeps the session secret + in module memory only and sends it as the `Fleetos-Bootstrap-Session` header; never put it in + web storage, a cookie or a URL. Mock mode keeps first run closed; set + `VITE_BOOTSTRAP_MOCK_STATE=open` to try the wizard offline. - Operator CA registration is a preview then a confirm with `confirm_sha256`. `OperatorCARegisterForm` takes the submit call as a prop, so the admin page and first run share it; `registerRequestFields()` builds the body both RegisterOperatorCA RPCs take. diff --git a/README.md b/README.md index 058db7c..abe7cf9 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,10 @@ The same bundle adapts at runtime based on the role of the node being viewed: - 🔌 **Intermediate / Issuing nodes** — issuance profiles, certificate inventory, CSR review, CRL / OCSP status, adapter health, audit log tail. - 👁️ **All nodes** — live status, configuration view (read-only when the node is linked to FM, which is the normal mode). +## 🚀 First run + +A new Fleet Manager with no operator CA configured starts in first run. Before sign-in the web UI asks the manager for its first-run state. While it is open, a wizard takes the single-use bootstrap token from the manager's log (after a check of the server certificate's fingerprint), registers your external operator CA with its CRL source and OCSP mode once you confirm its fingerprint, and gets the first admin certificate. Path A makes the key in the browser, with a mandatory encrypted key backup, then builds the PKCS#12 once your CA signs the CSR. Path B gives the OpenSSL recipe and an optional pre-flight. It ends with install steps for each OS. The session secret stays in memory only. + ## 🪪 Operator credentials Operator certificates come from your own external operator CA (an offline OpenSSL CA or an enterprise CA). The Fleet Manager never signs one; it records them and can deny them. diff --git a/src/App.test.tsx b/src/App.test.tsx index be07b05..c83fc50 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -50,9 +50,11 @@ describe("App routing", () => { expect(screen.queryByRole("button", { name: /log in/i })).not.toBeInTheDocument(); }); - it("keeps the console behind the login gate", () => { + // The gate asks the Fleet Manager whether first run is open before it shows + // the sign-in, so the Log in button appears once that answer is in. + it("keeps the console behind the login gate", async () => { renderAt("/agent-keys"); - expect(screen.getByRole("button", { name: /log in/i })).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: /log in/i })).toBeInTheDocument(); }); }); diff --git a/src/App.tsx b/src/App.tsx index d226ea8..a361507 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import { Route, Routes } from "react-router-dom"; import { AppShell } from "@/components/layout/app-shell"; import { AuthGate } from "@/components/layout/auth-gate"; +import { BootstrapGate } from "@/components/layout/bootstrap-gate"; import { AdoptPage } from "@/pages/adopt"; import { AgentKeysPage } from "@/pages/agent-keys"; import { ApprovalsPage } from "@/pages/approvals"; @@ -54,9 +55,11 @@ export const App = () => { } path="request-credential" /> - - + + + + + } > } index /> diff --git a/src/components/first-run/admin-step.tsx b/src/components/first-run/admin-step.tsx new file mode 100644 index 0000000..1468228 --- /dev/null +++ b/src/components/first-run/admin-step.tsx @@ -0,0 +1,406 @@ +/* +Copyright The CryptOS Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { useState } from "react"; + +import { CopyBlock } from "@/components/copy-block"; +import { KeyBackupStep } from "@/components/key-backup-step"; +import { Button } from "@/components/ui/button"; +import { type FirstAdminResult, submitFirstAdminCertificate } from "@/lib/bootstrap"; +import { + buildCredentialPkcs12, + keyMatchesCertificate, + readCertificateDer, + readKeyBackup, +} from "@/lib/crypto/key-backup"; +import { downloadBytes, readFileBytes } from "@/lib/download"; +import { fleetErrorMessage } from "@/lib/fleet/error-copy"; +import { fieldClass, labelClass, looksLikeEmail } from "@/lib/form"; +import { credentialFileBase, csrToPem, levelOpensslCommand } from "@/lib/operators"; + +const SessionCaution = () => ( +

+ The first-run session ends after 15 minutes without a call. If it ends while you are at the CA, + start again with the newest token from the log, confirm the CA's fingerprint again, and + continue from your key backup. +

+); + +const certFrom = (text: string): Uint8Array => + readCertificateDer(new TextEncoder().encode(text.trim())); + +// PathA: the browser makes the admin's P-384 key and CSR, with the key backup +// saved first. The CA signs the CSR out of band; the signed certificate comes +// back here, the manager checks and records it, and the browser builds the +// PKCS#12 with the backup's passphrase. +const PathA = ({ + adminExtfile, + anchorDer, + onDone, +}: { + adminExtfile: string; + anchorDer: null | Uint8Array; + onDone: () => void; +}) => { + const [fullName, setFullName] = useState(""); + const [email, setEmail] = useState(""); + const [mode, setMode] = useState<"details" | "make" | "upload">("details"); + const [backup, setBackup] = useState(null); + const [csrDer, setCsrDer] = useState(null); + const [certText, setCertText] = useState(""); + const [chainText, setChainText] = useState(""); + const [passphrase, setPassphrase] = useState(""); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + const cleanEmail = email.trim().toLowerCase(); + const detailsReady = fullName.trim() !== "" && looksLikeEmail(email); + + const record = async () => { + setPending(true); + setError(null); + try { + const certDer = certFrom(certText); + let chainDer: Uint8Array[] = []; + if (anchorDer) chainDer = [anchorDer]; + else if (chainText.trim()) chainDer = [certFrom(chainText)]; + const key = await readKeyBackup(backup ?? new Uint8Array(), passphrase); + if (!(await keyMatchesCertificate(key, certDer))) { + throw new Error( + "The certificate does not match the key in this backup. Check that it was signed from this CSR.", + ); + } + let recorded: FirstAdminResult; + try { + recorded = await submitFirstAdminCertificate({ + certDer, + csrDer: csrDer ?? undefined, + fullName, + }); + } catch (error_: unknown) { + setError(fleetErrorMessage(error_, "Recording the certificate failed.")); + return; + } + setResult(recorded); + const pfx = await buildCredentialPkcs12({ + backup: backup ?? new Uint8Array(), + certDer, + chainDer, + friendlyName: `FleetOS admin (${cleanEmail})`, + passphrase, + }); + downloadBytes(`${cleanEmail}.p12`, pfx, "application/x-pkcs12"); + setPassphrase(""); + onDone(); + } catch (error_: unknown) { + setError(error_ instanceof Error ? error_.message : "Building the PKCS#12 failed."); + } finally { + setPending(false); + } + }; + + if (mode === "details") { + return ( +
+ + +
+ + +
+
+ ); + } + + if (mode === "make" && !csrDer) { + return ( + { + setBackup(key.backup); + setCsrDer(key.csrDer); + }} + /> + ); + } + + return ( +
+ {csrDer ? ( + <> + + + +

+ Take the CSR to your operator CA and sign it with the admin section above (add it to the + CA's OpenSSL config; never copy extensions from the CSR). Bring back the signed + certificate. +

+ + + ) : ( + + )} +