Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>` 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).
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -49,6 +50,7 @@ export const App = () => {
return (
<Routes>
<Route element={<OAuthConsentPage />} path="oauth/consent" />
<Route element={<CredentialRequestPage />} path="request-credential" />
<Route
element={
<AuthGate>
Expand Down
66 changes: 66 additions & 0 deletions src/components/copy-block.tsx
Original file line number Diff line number Diff line change
@@ -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;
}) => (
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">
{label}
</span>
<div className="flex gap-2">
<Button
aria-label={`Copy ${label}`}
onClick={() => void navigator.clipboard?.writeText(text)}
size="sm"
type="button"
variant="outline"
>
Copy
</Button>
{filename ? (
<Button
aria-label={`Download ${label}`}
onClick={() => downloadText(filename, text)}
size="sm"
type="button"
variant="outline"
>
Download
</Button>
) : null}
</div>
</div>
<pre
aria-label={label}
className="max-h-48 overflow-auto whitespace-pre-wrap break-all rounded-md border bg-secondary/40 p-2 font-mono text-[11px]"
>
{text}
</pre>
</div>
);
238 changes: 238 additions & 0 deletions src/components/credential-complete-dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import("@/lib/download")>()),
downloadBytes: (...a: unknown[]) => downloadBytes(...a),
}));

vi.mock("@/lib/crypto/key-backup", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/crypto/key-backup")>();
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(
<CredentialCompleteDialog
heldBackup={backup}
onClose={vi.fn()}
onRecorded={onRecorded}
request={request}
/>,
);

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(
<CredentialCompleteDialog
heldBackup={backup}
onClose={vi.fn()}
onRecorded={vi.fn()}
request={request}
/>,
);
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(<CredentialCompleteDialog onClose={vi.fn()} onRecorded={vi.fn()} request={request} />);
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(<CredentialCompleteDialog onClose={vi.fn()} onRecorded={vi.fn()} request={request} />);
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(<CredentialCompleteDialog onClose={vi.fn()} onRecorded={vi.fn()} request={request} />);
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(<CredentialCompleteDialog onClose={vi.fn()} onRecorded={vi.fn()} request={null} />);
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(
<CredentialCompleteDialog
heldBackup={backup}
onClose={vi.fn()}
onRecorded={vi.fn()}
request={request}
/>,
);
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);
});
Loading
Loading