From f41719510ddf31ae8694966436ef5d77a9cdd0c5 Mon Sep 17 00:00:00 2001 From: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:59:06 -0400 Subject: [PATCH] feat(operators): credential requests, complete, deny and a client-side request page The manager no longer signs operator credentials (IssueOperatorCredential is gone), so the Operators page could not issue anything. It now follows the external operator CA model: - Request credential: a P-384 key and CSR made in the browser with a mandatory encrypted key backup and a passphrase shown once, or a CSR the holder made. Shows the CSR, the extension section and the openssl ca command the manager returns. - Pending requests tab with cancel, and Complete: record the signed certificate and, when the browser can open the key backup, build the PKCS#12 with the same passphrase. - Deny at the Fleet Manager replaces revoke: RFC 5280 reasons 0-10 except 7, a note, and the reminder to revoke at the CA as well. - Kind, issuer, denylisted, CRL-revoked and last-seen columns. - An anonymous /request-credential page that makes the key, backup and CSR, and later the PKCS#12, with no network calls. - Messages for every 1600-1611 code and sub-reason. The TypeScript stubs are refreshed to api main. Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> --- AGENTS.md | 13 +- README.md | 9 + src/App.test.tsx | 9 + src/App.tsx | 2 + src/components/copy-block.tsx | 66 + .../credential-complete-dialog.test.tsx | 238 +++ src/components/credential-complete-dialog.tsx | 312 ++++ .../credential-request-wizard.test.tsx | 188 +++ src/components/credential-request-wizard.tsx | 279 ++++ src/components/dialog-frame.tsx | 47 + src/components/key-backup-step.test.tsx | 88 ++ src/components/key-backup-step.tsx | 170 +++ src/components/layout/auth-gate.test.tsx | 9 + src/components/layout/auth-gate.tsx | 3 + src/components/operator-deny-dialog.test.tsx | 93 ++ src/components/operator-deny-dialog.tsx | 148 ++ src/components/operator-issue-dialog.test.tsx | 107 -- src/components/operator-issue-dialog.tsx | 241 --- src/components/operator-revoke-dialog.tsx | 117 -- .../fleet/cryptos/fleet/v1/bootstrap_pb.ts | 450 ++++++ src/gen/fleet/cryptos/fleet/v1/errors_pb.ts | 418 ++++++ src/gen/fleet/cryptos/fleet/v1/fleet_pb.ts | 1305 ++++++++++++++--- .../fleet/cryptos/fleet/v1/operator_ca_pb.ts | 429 ++++++ src/gen/fleet/cryptos/v1/audit_pb.ts | 203 ++- src/gen/fleet/cryptos/v1/node_pb.ts | 135 +- src/lib/crypto/key-backup.test.ts | 203 +++ src/lib/crypto/key-backup.ts | 126 ++ src/lib/crypto/pkcs12.test.ts | 29 + src/lib/crypto/pkcs12.ts | 28 +- src/lib/download.ts | 41 + src/lib/fingerprint.ts | 20 + src/lib/fleet/error-code.ts | 22 + src/lib/fleet/error-copy.test.ts | 132 ++ src/lib/fleet/error-copy.ts | 192 +++ src/lib/form.ts | 21 + src/lib/operators.test.ts | 273 +++- src/lib/operators.ts | 483 ++++-- src/lib/wire-fields.test.ts | 69 + src/pages/credential-request.test.tsx | 119 ++ src/pages/credential-request.tsx | 260 ++++ src/pages/operators.test.tsx | 290 ++-- src/pages/operators.tsx | 351 +++-- 42 files changed, 6717 insertions(+), 1021 deletions(-) create mode 100644 src/components/copy-block.tsx create mode 100644 src/components/credential-complete-dialog.test.tsx create mode 100644 src/components/credential-complete-dialog.tsx create mode 100644 src/components/credential-request-wizard.test.tsx create mode 100644 src/components/credential-request-wizard.tsx create mode 100644 src/components/dialog-frame.tsx create mode 100644 src/components/key-backup-step.test.tsx create mode 100644 src/components/key-backup-step.tsx create mode 100644 src/components/operator-deny-dialog.test.tsx create mode 100644 src/components/operator-deny-dialog.tsx delete mode 100644 src/components/operator-issue-dialog.test.tsx delete mode 100644 src/components/operator-issue-dialog.tsx delete mode 100644 src/components/operator-revoke-dialog.tsx create mode 100644 src/gen/fleet/cryptos/fleet/v1/bootstrap_pb.ts create mode 100644 src/gen/fleet/cryptos/fleet/v1/errors_pb.ts create mode 100644 src/gen/fleet/cryptos/fleet/v1/operator_ca_pb.ts create mode 100644 src/lib/crypto/key-backup.test.ts create mode 100644 src/lib/crypto/key-backup.ts create mode 100644 src/lib/download.ts create mode 100644 src/lib/fingerprint.ts create mode 100644 src/lib/fleet/error-copy.test.ts create mode 100644 src/lib/fleet/error-copy.ts create mode 100644 src/lib/form.ts create mode 100644 src/pages/credential-request.test.tsx create mode 100644 src/pages/credential-request.tsx diff --git a/AGENTS.md b/AGENTS.md index 27d2b76..91fa359 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,8 @@ woff2, strict CSP, no runtime CDN) for air-gap use. approval link), Audit, 404). The MCP sign-in consent page `/oauth/consent` is routed outside the auth gate on purpose: the manager authenticates that request with the browser's client certificate, and it talks to `/oauth2/consent/` with plain `fetch`, not Connect. + The "Make a credential request" page `/request-credential` is outside the gate too, and makes + no network calls at all: it must not import the Fleet Manager client. - `src/components/layout/` - app shell: header, sidebar nav, wordmark, theme toggle, auth gate. - `src/components/ui/` - shadcn/ui primitives (button, card, badge, separator). - `src/context/` - `theme.tsx` (dark/light, persisted) and `auth.tsx` (browser-mTLS gate stub). @@ -48,8 +50,15 @@ woff2, strict CSP, no runtime CDN) for air-gap use. - The manager puts a stable numeric code on every error it returns (metadata key `x-cryptos-error-code`; the table is the manager's `docs/error-codes.md`). Branch on it with `errorCode()` from `src/lib/fleet/error-code.ts`, never on the message text, and add a code to - its `ErrorCode` map when a page handles one. The Operators page does this for 1400 (no - `operator_ca_node` configured) and shows a not-configured view instead of the refusal. + its `ErrorCode` map when a page handles one. The Operators page does this for 1400 (no operator + CA configured) and shows a not-configured view instead of the refusal. The 1600-1611 block + (first run, operator CAs, operator credentials) also carries a sub-reason + (`x-cryptos-error-reason`, read with `errorReason()`); `src/lib/fleet/error-copy.ts` maps every + code and sub-reason to a message, and `fleetErrorMessage()` is what those screens show. +- Operator private keys never leave the browser. `src/lib/crypto/key-backup.ts` writes the + 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. - The Approvals nav badge counts pending approvals through `listApprovals`. It recounts on every navigation, every 30 seconds, and when `decideApproval` dispatches the `APPROVALS_CHANGED` window event (`src/lib/approvals.ts`). A new surface that changes an approval should go through diff --git a/README.md b/README.md index ce47492..e1d9b09 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,15 @@ 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). +## 🪪 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. + +- šŸ“ **Request credential** (Operators page, admin) makes a P-384 key and CSR in the browser, with a mandatory encrypted key backup whose passphrase is shown once, or takes a CSR the holder made. It returns the CSR, the OpenSSL extension section for the level and the `openssl ca` command for the CA operator. +- āœ… **Complete** records the signed certificate against its request and, when this browser can open the key backup, builds the PKCS#12 locally with the same passphrase. **Record certificate** imports one made entirely at the CA. +- ā›” **Deny at the Fleet Manager** puts a credential on the Fleet Manager's denylist, with an RFC 5280 reason and a note. It doesn't revoke at the CA, and says so. +- šŸ™‹ **Make a credential request** at `/request-credential` is an anonymous page where a future operator makes their own key, key backup and CSR, and later builds their PKCS#12. It makes no network calls. + ## šŸ¤– Agent access (MCP) The Fleet Manager serves an MCP endpoint so AI agents can manage the fleet. An agent never gets more than the operator who let it in, and this UI is where that consent happens: diff --git a/src/App.test.tsx b/src/App.test.tsx index aa78bc4..be07b05 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -41,6 +41,15 @@ describe("App routing", () => { expect(screen.queryByRole("button", { name: /log in/i })).not.toBeInTheDocument(); }); + // Someone without a credential yet makes their key and CSR here, so it can't + // sit behind the gate that needs one. + it("serves the credential request page without logging in first", () => { + renderAt("/request-credential"); + + expect(screen.getByRole("heading", { name: /make a credential request/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /log in/i })).not.toBeInTheDocument(); + }); + it("keeps the console behind the login gate", () => { renderAt("/agent-keys"); diff --git a/src/App.tsx b/src/App.tsx index d213ef4..6015c5a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,7 @@ import { AgentKeysPage } from "@/pages/agent-keys"; import { ApprovalsPage } from "@/pages/approvals"; import { AuditPage } from "@/pages/audit"; import { CertificatesPage } from "@/pages/certificates"; +import { CredentialRequestPage } from "@/pages/credential-request"; import { DashboardPage } from "@/pages/dashboard"; import { EnrollmentPage } from "@/pages/enrollment"; import { EnrollmentDetailPage } from "@/pages/enrollment-detail"; @@ -49,6 +50,7 @@ export const App = () => { return ( } path="oauth/consent" /> + } path="request-credential" /> diff --git a/src/components/copy-block.tsx b/src/components/copy-block.tsx new file mode 100644 index 0000000..412c316 --- /dev/null +++ b/src/components/copy-block.tsx @@ -0,0 +1,66 @@ +/* +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 { Button } from "@/components/ui/button"; +import { downloadText } from "@/lib/download"; + +// CopyBlock shows generated text (a CSR, an OpenSSL section, a command) with a +// copy button and, when a filename is given, a download button. +export const CopyBlock = ({ + filename, + label, + text, +}: { + filename?: string; + label: string; + text: string; +}) => ( +
+
+ + {label} + +
+ + {filename ? ( + + ) : null} +
+
+
+      {text}
+    
+
+); diff --git a/src/components/credential-complete-dialog.test.tsx b/src/components/credential-complete-dialog.test.tsx new file mode 100644 index 0000000..3633328 --- /dev/null +++ b/src/components/credential-complete-dialog.test.tsx @@ -0,0 +1,238 @@ +/* +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 "reflect-metadata"; +import { Code, ConnectError } from "@connectrpc/connect"; +import { X509CertificateGenerator } from "@peculiar/x509"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { CredentialRequestRow } from "@/lib/operators"; + +import { CredentialCompleteDialog } from "@/components/credential-complete-dialog"; +import { generateBackupPassphrase, keyBackupPem } from "@/lib/crypto/key-backup"; +import { base64 } from "@/lib/crypto/leaf-key"; +import * as modeMod from "@/lib/fleet/mode"; +import * as operators from "@/lib/operators"; + +const { buildCredentialPkcs12, downloadBytes } = vi.hoisted(() => ({ + buildCredentialPkcs12: vi.fn(), + downloadBytes: vi.fn(), +})); +vi.mock("@/lib/download", async (importOriginal) => ({ + ...(await importOriginal()), + downloadBytes: (...a: unknown[]) => downloadBytes(...a), +})); + +vi.mock("@/lib/crypto/key-backup", async (importOriginal) => { + const actual = await importOriginal(); + buildCredentialPkcs12.mockImplementation(actual.buildCredentialPkcs12); + return { ...actual, buildCredentialPkcs12: (...a: unknown[]) => buildCredentialPkcs12(...a) }; +}); + +const EMAIL = "new-hire@example.org"; + +const request: CredentialRequestRow = { + completedSerial: "", + createdAt: "2026-09-29T14:00:00Z", + createdByCn: "admin@example.org", + csrPem: "", + email: EMAIL, + expiresAt: "2026-10-29T14:00:00Z", + fullName: "New Hire", + id: "req-1", + level: "operator", + state: "pending", +}; + +const material = async () => { + const keys = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-384" }, true, [ + "sign", + "verify", + ])) as CryptoKeyPair; + const passphrase = generateBackupPassphrase(); + const backup = new TextEncoder().encode(await keyBackupPem(keys.privateKey, passphrase)); + const cert = await X509CertificateGenerator.createSelfSigned({ + keys, + name: `CN=${EMAIL}`, + notAfter: new Date(Date.now() + 86_400_000), + notBefore: new Date(Date.now() - 60_000), + serialNumber: "0a", + signingAlgorithm: { hash: "SHA-384", name: "ECDSA" }, + }); + return { backup, certPem: cert.toString("pem"), keys, passphrase }; +}; + +const fill = (label: RegExp, value: string) => + fireEvent.change(screen.getByLabelText(label), { target: { value } }); + +beforeEach(() => { + downloadBytes.mockReset(); + buildCredentialPkcs12.mockClear(); + operators.__resetOperators(); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("CredentialCompleteDialog", () => { + it("records the certificate, then builds the PKCS#12 with the key backup's passphrase", async () => { + const { backup, certPem, passphrase } = await material(); + const record = vi.spyOn(operators, "recordOperatorCredential"); + const onRecorded = vi.fn(); + render( + , + ); + + fill(/signed certificate/i, certPem); + fill(/backup passphrase/i, passphrase); + fireEvent.click(screen.getByRole("button", { name: /record/i })); + + await waitFor(() => expect(downloadBytes).toHaveBeenCalled(), { timeout: 15_000 }); + expect(record).toHaveBeenCalledWith(expect.objectContaining({ requestId: "req-1" })); + const [args] = buildCredentialPkcs12.mock.calls[0] as [ + { backup: Uint8Array; friendlyName: string; passphrase: string }, + ]; + expect(args.backup).toBe(backup); + expect(args.passphrase).toBe(passphrase); + expect(args.friendlyName).toBe(`FleetOS operator (${EMAIL})`); + expect(downloadBytes.mock.calls[0][0]).toBe(`${EMAIL}.p12`); + expect(onRecorded).toHaveBeenCalled(); + expect(screen.getByText(/PKCS#12 downloaded/i)).toBeInTheDocument(); + }, 30_000); + + it("stops before recording when the passphrase doesn't open the backup", async () => { + const { backup, certPem } = await material(); + const record = vi.spyOn(operators, "recordOperatorCredential"); + render( + , + ); + fill(/signed certificate/i, certPem); + fill(/backup passphrase/i, "wrong-passphrase-wrong-pass"); + fireEvent.click(screen.getByRole("button", { name: /record/i })); + + await screen.findByText(/passphrase is wrong/i, {}, { timeout: 15_000 }); + expect(record).not.toHaveBeenCalled(); + expect(downloadBytes).not.toHaveBeenCalled(); + }, 30_000); + + it("takes a key backup file when the browser holds none", async () => { + const { backup, certPem, passphrase } = await material(); + render(); + expect(screen.queryByLabelText(/held in this browser/i)).not.toBeInTheDocument(); + fill(/signed certificate/i, certPem); + fireEvent.change(screen.getByLabelText(/^key backup file/i), { + target: { files: [new File([new TextDecoder().decode(backup)], "k.key.pem")] }, + }); + fill(/backup passphrase/i, passphrase); + await waitFor(() => expect(screen.getByRole("button", { name: /record/i })).toBeEnabled()); + fireEvent.click(screen.getByRole("button", { name: /record/i })); + await waitFor(() => expect(downloadBytes).toHaveBeenCalled(), { timeout: 15_000 }); + }, 30_000); + + it("only records when the holder keeps the key", async () => { + const { certPem } = await material(); + const record = vi.spyOn(operators, "recordOperatorCredential"); + render(); + fill(/signed certificate/i, certPem); + fireEvent.click(screen.getByLabelText(/holder has the key/i)); + fireEvent.click(screen.getByRole("button", { name: /record/i })); + await screen.findByText(/recorded/i, { selector: "[role=status]" }); + expect(record).toHaveBeenCalled(); + expect(downloadBytes).not.toHaveBeenCalled(); + expect(screen.getByText(/openssl pkcs12 -export/)).toBeInTheDocument(); + }, 30_000); + + it("explains a refusal by its reason code", async () => { + const { certPem } = await material(); + vi.spyOn(operators, "recordOperatorCredential").mockRejectedValue( + new ConnectError("refused", Code.InvalidArgument, { + "x-cryptos-error-code": "1610", + "x-cryptos-error-reason": "LEVEL_EXT_CRITICAL", + }), + ); + render(); + fill(/signed certificate/i, certPem); + fireEvent.click(screen.getByLabelText(/holder has the key/i)); + fireEvent.click(screen.getByRole("button", { name: /record/i })); + await screen.findByText(/marked critical/i); + }, 30_000); + + it("asks for the holder's name when recording without a request", async () => { + const { certPem } = await material(); + const record = vi.spyOn(operators, "recordOperatorCredential"); + render(); + fill(/signed certificate/i, certPem); + fill(/holder's full name/i, "Out Of Band"); + fireEvent.click(screen.getByLabelText(/holder has the key/i)); + fireEvent.click(screen.getByRole("button", { name: /record/i })); + await waitFor(() => + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ fullName: "Out Of Band", requestId: undefined }), + ), + ); + }, 30_000); + + it("never puts the key or the passphrase in a request body (live)", async () => { + const { backup, certPem, keys, passphrase } = await material(); + vi.spyOn(modeMod, "fleetMode").mockReturnValue("live"); + const bodies: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const raw = init?.body; + bodies.push(ArrayBuffer.isView(raw) ? new TextDecoder().decode(raw) : String(raw ?? "")); + return Response.json( + { + credential: { commonName: EMAIL, email: EMAIL, level: "operator", serialHex: "0A" }, + }, + { headers: { "content-type": "application/json" }, status: 200 }, + ); + }), + ); + render( + , + ); + fill(/signed certificate/i, certPem); + fill(/backup passphrase/i, passphrase); + fireEvent.click(screen.getByRole("button", { name: /record/i })); + await waitFor(() => expect(downloadBytes).toHaveBeenCalled(), { timeout: 15_000 }); + + expect(bodies).toHaveLength(1); + const pkcs8 = base64(new Uint8Array(await crypto.subtle.exportKey("pkcs8", keys.privateKey))); + const jwk = await crypto.subtle.exportKey("jwk", keys.privateKey); + const backupB64 = base64(backup); + for (const secret of [pkcs8, jwk.d ?? "unset", passphrase, backupB64]) { + expect(bodies[0]).not.toContain(secret); + } + }, 30_000); +}); diff --git a/src/components/credential-complete-dialog.tsx b/src/components/credential-complete-dialog.tsx new file mode 100644 index 0000000..8aef66c --- /dev/null +++ b/src/components/credential-complete-dialog.tsx @@ -0,0 +1,312 @@ +/* +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 { DialogFrame } from "@/components/dialog-frame"; +import { Button } from "@/components/ui/button"; +import { + buildCredentialPkcs12, + keyMatchesCertificate, + readCertificateDer, + readKeyBackup, +} from "@/lib/crypto/key-backup"; +import { base64 } from "@/lib/crypto/leaf-key"; +import { downloadBytes, readFileBytes } from "@/lib/download"; +import { fleetErrorMessage } from "@/lib/fleet/error-copy"; +import { fieldClass, labelClass } from "@/lib/form"; +import { + type CredentialRequestRow, + type OperatorCredentialRow, + recordOperatorCredential, +} from "@/lib/operators"; + +type KeySource = "file" | "held" | "holder"; + +// CredentialCompleteDialog records a certificate the operator CA signed, for +// a pending request or (with no request) as an out-of-band import. When this +// browser can reach the key, through the backup it still holds or a backup +// file, it checks the key against the certificate before recording and then +// builds the PKCS#12, sealed with the key backup's own passphrase. +export const CredentialCompleteDialog = ({ + heldBackup, + onClose, + onRecorded, + request, +}: { + heldBackup?: Uint8Array; + onClose: () => void; + onRecorded: () => void; + request: CredentialRequestRow | null; +}) => { + const [certText, setCertText] = useState(""); + const [fullName, setFullName] = useState(""); + const [keySource, setKeySource] = useState(heldBackup ? "held" : "file"); + const [backupFile, setBackupFile] = useState(null); + const [passphrase, setPassphrase] = useState(""); + const [chainText, setChainText] = useState(""); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const [recorded, setRecorded] = useState(null); + const [warnings, setWarnings] = useState([]); + const [p12Name, setP12Name] = useState(""); + + const backupFor = (source: KeySource): null | Uint8Array | undefined => { + if (source === "held") return heldBackup; + if (source === "file") return backupFile; + return undefined; + }; + const backup = backupFor(keySource); + const needsKey = keySource !== "holder"; + const ready = + certText.trim() !== "" && + (request !== null || fullName.trim() !== "") && + (!needsKey || (backup !== null && backup !== undefined && passphrase !== "")) && + !pending; + + const complete = async () => { + setPending(true); + setError(null); + try { + const certDer = readCertificateDer(new TextEncoder().encode(certText.trim())); + const chainDer = + chainText.trim() === "" + ? [] + : [readCertificateDer(new TextEncoder().encode(chainText.trim()))]; + + if (needsKey && backup) { + const key = await readKeyBackup(backup, 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 request's CSR.", + ); + } + } + + let result; + try { + result = await recordOperatorCredential({ + certDer, + fullName: request ? undefined : fullName, + requestId: request?.id, + }); + } catch (error_: unknown) { + setError(fleetErrorMessage(error_, "Recording the certificate failed.")); + return; + } + setRecorded(result.credential); + setWarnings(result.warnings); + onRecorded(); + + if (needsKey && backup) { + const email = result.credential.email || request?.email || "operator"; + const level = result.credential.level || request?.level || "operator"; + const pfx = await buildCredentialPkcs12({ + backup, + certDer, + chainDer, + friendlyName: `FleetOS ${level} (${email})`, + passphrase, + }); + const name = `${email}.p12`; + downloadBytes(name, pfx, "application/x-pkcs12"); + setP12Name(name); + } + setPassphrase(""); + } catch (error_: unknown) { + setError(error_ instanceof Error ? error_.message : "Completing the request failed."); + } finally { + setPending(false); + } + }; + + let submitLabel = needsKey ? "Record and build PKCS#12" : "Record"; + if (pending) submitLabel = "Working…"; + + const title = request + ? `Complete request for ${request.email}` + : "Record an operator certificate"; + + return ( + +

+ {title} +

+ + {recorded ? ( +
+

+ Recorded {recorded.commonName} ({recorded.level}), serial{" "} + {recorded.serialHex}. +

+ {warnings.map((w) => ( +

+ {w} +

+ ))} + {p12Name ? ( +

+ PKCS#12 downloaded as {p12Name}. It opens with the + key backup's passphrase. Import it into the operator's browser or OS + certificate store, then keep the key backup somewhere safe or delete it. +

+ ) : ( +

+ The holder builds the PKCS#12 with their key:{" "} + + openssl pkcs12 -export -inkey <key file> -in <certificate> -certfile + operator-ca.crt -out <name>.p12 + +

+ )} +
+ +
+
+ ) : ( + <> +