Skip to content

Commit e8099d6

Browse files
authored
feat(operators): credential requests, complete, deny and a client-side request page (#145)
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> Co-authored-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
1 parent 29f9b59 commit e8099d6

42 files changed

Lines changed: 6717 additions & 1021 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎AGENTS.md‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ woff2, strict CSP, no runtime CDN) for air-gap use.
2121
approval link), Audit, 404). The MCP sign-in consent page `/oauth/consent` is routed
2222
outside the auth gate on purpose: the manager authenticates that request with the browser's
2323
client certificate, and it talks to `/oauth2/consent/<id>` with plain `fetch`, not Connect.
24+
The "Make a credential request" page `/request-credential` is outside the gate too, and makes
25+
no network calls at all: it must not import the Fleet Manager client.
2426
- `src/components/layout/` - app shell: header, sidebar nav, wordmark, theme toggle, auth gate.
2527
- `src/components/ui/` - shadcn/ui primitives (button, card, badge, separator).
2628
- `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.
4850
- The manager puts a stable numeric code on every error it returns (metadata key
4951
`x-cryptos-error-code`; the table is the manager's `docs/error-codes.md`). Branch on it with
5052
`errorCode()` from `src/lib/fleet/error-code.ts`, never on the message text, and add a code to
51-
its `ErrorCode` map when a page handles one. The Operators page does this for 1400 (no
52-
`operator_ca_node` configured) and shows a not-configured view instead of the refusal.
53+
its `ErrorCode` map when a page handles one. The Operators page does this for 1400 (no operator
54+
CA configured) and shows a not-configured view instead of the refusal. The 1600-1611 block
55+
(first run, operator CAs, operator credentials) also carries a sub-reason
56+
(`x-cryptos-error-reason`, read with `errorReason()`); `src/lib/fleet/error-copy.ts` maps every
57+
code and sub-reason to a message, and `fleetErrorMessage()` is what those screens show.
58+
- Operator private keys never leave the browser. `src/lib/crypto/key-backup.ts` writes the
59+
encrypted key backup (PBES2 PKCS#8), reads it back, and builds the PKCS#12 with the backup's
60+
own passphrase; the credential wizards hold only the encrypted backup in memory. Tests assert
61+
on the request bodies that neither the key nor the passphrase is ever sent.
5362
- The Approvals nav badge counts pending approvals through `listApprovals`. It recounts on every
5463
navigation, every 30 seconds, and when `decideApproval` dispatches the `APPROVALS_CHANGED`
5564
window event (`src/lib/approvals.ts`). A new surface that changes an approval should go through

‎README.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ The same bundle adapts at runtime based on the role of the node being viewed:
2727
- 🔌 **Intermediate / Issuing nodes** — issuance profiles, certificate inventory, CSR review, CRL / OCSP status, adapter health, audit log tail.
2828
- 👁️ **All nodes** — live status, configuration view (read-only when the node is linked to FM, which is the normal mode).
2929

30+
## 🪪 Operator credentials
31+
32+
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.
33+
34+
- 📝 **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.
35+
- ✅ **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.
36+
- ⛔ **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.
37+
- 🙋 **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.
38+
3039
## 🤖 Agent access (MCP)
3140

3241
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:

‎src/App.test.tsx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@ describe("App routing", () => {
4141
expect(screen.queryByRole("button", { name: /log in/i })).not.toBeInTheDocument();
4242
});
4343

44+
// Someone without a credential yet makes their key and CSR here, so it can't
45+
// sit behind the gate that needs one.
46+
it("serves the credential request page without logging in first", () => {
47+
renderAt("/request-credential");
48+
49+
expect(screen.getByRole("heading", { name: /make a credential request/i })).toBeInTheDocument();
50+
expect(screen.queryByRole("button", { name: /log in/i })).not.toBeInTheDocument();
51+
});
52+
4453
it("keeps the console behind the login gate", () => {
4554
renderAt("/agent-keys");
4655

‎src/App.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { AgentKeysPage } from "@/pages/agent-keys";
2323
import { ApprovalsPage } from "@/pages/approvals";
2424
import { AuditPage } from "@/pages/audit";
2525
import { CertificatesPage } from "@/pages/certificates";
26+
import { CredentialRequestPage } from "@/pages/credential-request";
2627
import { DashboardPage } from "@/pages/dashboard";
2728
import { EnrollmentPage } from "@/pages/enrollment";
2829
import { EnrollmentDetailPage } from "@/pages/enrollment-detail";
@@ -49,6 +50,7 @@ export const App = () => {
4950
return (
5051
<Routes>
5152
<Route element={<OAuthConsentPage />} path="oauth/consent" />
53+
<Route element={<CredentialRequestPage />} path="request-credential" />
5254
<Route
5355
element={
5456
<AuthGate>

‎src/components/copy-block.tsx‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
Copyright The CryptOS Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { Button } from "@/components/ui/button";
18+
import { downloadText } from "@/lib/download";
19+
20+
// CopyBlock shows generated text (a CSR, an OpenSSL section, a command) with a
21+
// copy button and, when a filename is given, a download button.
22+
export const CopyBlock = ({
23+
filename,
24+
label,
25+
text,
26+
}: {
27+
filename?: string;
28+
label: string;
29+
text: string;
30+
}) => (
31+
<div className="space-y-1">
32+
<div className="flex items-center justify-between gap-2">
33+
<span className="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">
34+
{label}
35+
</span>
36+
<div className="flex gap-2">
37+
<Button
38+
aria-label={`Copy ${label}`}
39+
onClick={() => void navigator.clipboard?.writeText(text)}
40+
size="sm"
41+
type="button"
42+
variant="outline"
43+
>
44+
Copy
45+
</Button>
46+
{filename ? (
47+
<Button
48+
aria-label={`Download ${label}`}
49+
onClick={() => downloadText(filename, text)}
50+
size="sm"
51+
type="button"
52+
variant="outline"
53+
>
54+
Download
55+
</Button>
56+
) : null}
57+
</div>
58+
</div>
59+
<pre
60+
aria-label={label}
61+
className="max-h-48 overflow-auto whitespace-pre-wrap break-all rounded-md border bg-secondary/40 p-2 font-mono text-[11px]"
62+
>
63+
{text}
64+
</pre>
65+
</div>
66+
);
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
/*
2+
Copyright The CryptOS Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import "reflect-metadata";
18+
import { Code, ConnectError } from "@connectrpc/connect";
19+
import { X509CertificateGenerator } from "@peculiar/x509";
20+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
21+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22+
23+
import type { CredentialRequestRow } from "@/lib/operators";
24+
25+
import { CredentialCompleteDialog } from "@/components/credential-complete-dialog";
26+
import { generateBackupPassphrase, keyBackupPem } from "@/lib/crypto/key-backup";
27+
import { base64 } from "@/lib/crypto/leaf-key";
28+
import * as modeMod from "@/lib/fleet/mode";
29+
import * as operators from "@/lib/operators";
30+
31+
const { buildCredentialPkcs12, downloadBytes } = vi.hoisted(() => ({
32+
buildCredentialPkcs12: vi.fn(),
33+
downloadBytes: vi.fn(),
34+
}));
35+
vi.mock("@/lib/download", async (importOriginal) => ({
36+
...(await importOriginal<typeof import("@/lib/download")>()),
37+
downloadBytes: (...a: unknown[]) => downloadBytes(...a),
38+
}));
39+
40+
vi.mock("@/lib/crypto/key-backup", async (importOriginal) => {
41+
const actual = await importOriginal<typeof import("@/lib/crypto/key-backup")>();
42+
buildCredentialPkcs12.mockImplementation(actual.buildCredentialPkcs12);
43+
return { ...actual, buildCredentialPkcs12: (...a: unknown[]) => buildCredentialPkcs12(...a) };
44+
});
45+
46+
const EMAIL = "new-hire@example.org";
47+
48+
const request: CredentialRequestRow = {
49+
completedSerial: "",
50+
createdAt: "2026-09-29T14:00:00Z",
51+
createdByCn: "admin@example.org",
52+
csrPem: "",
53+
email: EMAIL,
54+
expiresAt: "2026-10-29T14:00:00Z",
55+
fullName: "New Hire",
56+
id: "req-1",
57+
level: "operator",
58+
state: "pending",
59+
};
60+
61+
const material = async () => {
62+
const keys = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-384" }, true, [
63+
"sign",
64+
"verify",
65+
])) as CryptoKeyPair;
66+
const passphrase = generateBackupPassphrase();
67+
const backup = new TextEncoder().encode(await keyBackupPem(keys.privateKey, passphrase));
68+
const cert = await X509CertificateGenerator.createSelfSigned({
69+
keys,
70+
name: `CN=${EMAIL}`,
71+
notAfter: new Date(Date.now() + 86_400_000),
72+
notBefore: new Date(Date.now() - 60_000),
73+
serialNumber: "0a",
74+
signingAlgorithm: { hash: "SHA-384", name: "ECDSA" },
75+
});
76+
return { backup, certPem: cert.toString("pem"), keys, passphrase };
77+
};
78+
79+
const fill = (label: RegExp, value: string) =>
80+
fireEvent.change(screen.getByLabelText(label), { target: { value } });
81+
82+
beforeEach(() => {
83+
downloadBytes.mockReset();
84+
buildCredentialPkcs12.mockClear();
85+
operators.__resetOperators();
86+
});
87+
afterEach(() => {
88+
vi.restoreAllMocks();
89+
vi.unstubAllGlobals();
90+
});
91+
92+
describe("CredentialCompleteDialog", () => {
93+
it("records the certificate, then builds the PKCS#12 with the key backup's passphrase", async () => {
94+
const { backup, certPem, passphrase } = await material();
95+
const record = vi.spyOn(operators, "recordOperatorCredential");
96+
const onRecorded = vi.fn();
97+
render(
98+
<CredentialCompleteDialog
99+
heldBackup={backup}
100+
onClose={vi.fn()}
101+
onRecorded={onRecorded}
102+
request={request}
103+
/>,
104+
);
105+
106+
fill(/signed certificate/i, certPem);
107+
fill(/backup passphrase/i, passphrase);
108+
fireEvent.click(screen.getByRole("button", { name: /record/i }));
109+
110+
await waitFor(() => expect(downloadBytes).toHaveBeenCalled(), { timeout: 15_000 });
111+
expect(record).toHaveBeenCalledWith(expect.objectContaining({ requestId: "req-1" }));
112+
const [args] = buildCredentialPkcs12.mock.calls[0] as [
113+
{ backup: Uint8Array; friendlyName: string; passphrase: string },
114+
];
115+
expect(args.backup).toBe(backup);
116+
expect(args.passphrase).toBe(passphrase);
117+
expect(args.friendlyName).toBe(`FleetOS operator (${EMAIL})`);
118+
expect(downloadBytes.mock.calls[0][0]).toBe(`${EMAIL}.p12`);
119+
expect(onRecorded).toHaveBeenCalled();
120+
expect(screen.getByText(/PKCS#12 downloaded/i)).toBeInTheDocument();
121+
}, 30_000);
122+
123+
it("stops before recording when the passphrase doesn't open the backup", async () => {
124+
const { backup, certPem } = await material();
125+
const record = vi.spyOn(operators, "recordOperatorCredential");
126+
render(
127+
<CredentialCompleteDialog
128+
heldBackup={backup}
129+
onClose={vi.fn()}
130+
onRecorded={vi.fn()}
131+
request={request}
132+
/>,
133+
);
134+
fill(/signed certificate/i, certPem);
135+
fill(/backup passphrase/i, "wrong-passphrase-wrong-pass");
136+
fireEvent.click(screen.getByRole("button", { name: /record/i }));
137+
138+
await screen.findByText(/passphrase is wrong/i, {}, { timeout: 15_000 });
139+
expect(record).not.toHaveBeenCalled();
140+
expect(downloadBytes).not.toHaveBeenCalled();
141+
}, 30_000);
142+
143+
it("takes a key backup file when the browser holds none", async () => {
144+
const { backup, certPem, passphrase } = await material();
145+
render(<CredentialCompleteDialog onClose={vi.fn()} onRecorded={vi.fn()} request={request} />);
146+
expect(screen.queryByLabelText(/held in this browser/i)).not.toBeInTheDocument();
147+
fill(/signed certificate/i, certPem);
148+
fireEvent.change(screen.getByLabelText(/^key backup file/i), {
149+
target: { files: [new File([new TextDecoder().decode(backup)], "k.key.pem")] },
150+
});
151+
fill(/backup passphrase/i, passphrase);
152+
await waitFor(() => expect(screen.getByRole("button", { name: /record/i })).toBeEnabled());
153+
fireEvent.click(screen.getByRole("button", { name: /record/i }));
154+
await waitFor(() => expect(downloadBytes).toHaveBeenCalled(), { timeout: 15_000 });
155+
}, 30_000);
156+
157+
it("only records when the holder keeps the key", async () => {
158+
const { certPem } = await material();
159+
const record = vi.spyOn(operators, "recordOperatorCredential");
160+
render(<CredentialCompleteDialog onClose={vi.fn()} onRecorded={vi.fn()} request={request} />);
161+
fill(/signed certificate/i, certPem);
162+
fireEvent.click(screen.getByLabelText(/holder has the key/i));
163+
fireEvent.click(screen.getByRole("button", { name: /record/i }));
164+
await screen.findByText(/recorded/i, { selector: "[role=status]" });
165+
expect(record).toHaveBeenCalled();
166+
expect(downloadBytes).not.toHaveBeenCalled();
167+
expect(screen.getByText(/openssl pkcs12 -export/)).toBeInTheDocument();
168+
}, 30_000);
169+
170+
it("explains a refusal by its reason code", async () => {
171+
const { certPem } = await material();
172+
vi.spyOn(operators, "recordOperatorCredential").mockRejectedValue(
173+
new ConnectError("refused", Code.InvalidArgument, {
174+
"x-cryptos-error-code": "1610",
175+
"x-cryptos-error-reason": "LEVEL_EXT_CRITICAL",
176+
}),
177+
);
178+
render(<CredentialCompleteDialog onClose={vi.fn()} onRecorded={vi.fn()} request={request} />);
179+
fill(/signed certificate/i, certPem);
180+
fireEvent.click(screen.getByLabelText(/holder has the key/i));
181+
fireEvent.click(screen.getByRole("button", { name: /record/i }));
182+
await screen.findByText(/marked critical/i);
183+
}, 30_000);
184+
185+
it("asks for the holder's name when recording without a request", async () => {
186+
const { certPem } = await material();
187+
const record = vi.spyOn(operators, "recordOperatorCredential");
188+
render(<CredentialCompleteDialog onClose={vi.fn()} onRecorded={vi.fn()} request={null} />);
189+
fill(/signed certificate/i, certPem);
190+
fill(/holder's full name/i, "Out Of Band");
191+
fireEvent.click(screen.getByLabelText(/holder has the key/i));
192+
fireEvent.click(screen.getByRole("button", { name: /record/i }));
193+
await waitFor(() =>
194+
expect(record).toHaveBeenCalledWith(
195+
expect.objectContaining({ fullName: "Out Of Band", requestId: undefined }),
196+
),
197+
);
198+
}, 30_000);
199+
200+
it("never puts the key or the passphrase in a request body (live)", async () => {
201+
const { backup, certPem, keys, passphrase } = await material();
202+
vi.spyOn(modeMod, "fleetMode").mockReturnValue("live");
203+
const bodies: string[] = [];
204+
vi.stubGlobal(
205+
"fetch",
206+
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
207+
const raw = init?.body;
208+
bodies.push(ArrayBuffer.isView(raw) ? new TextDecoder().decode(raw) : String(raw ?? ""));
209+
return Response.json(
210+
{
211+
credential: { commonName: EMAIL, email: EMAIL, level: "operator", serialHex: "0A" },
212+
},
213+
{ headers: { "content-type": "application/json" }, status: 200 },
214+
);
215+
}),
216+
);
217+
render(
218+
<CredentialCompleteDialog
219+
heldBackup={backup}
220+
onClose={vi.fn()}
221+
onRecorded={vi.fn()}
222+
request={request}
223+
/>,
224+
);
225+
fill(/signed certificate/i, certPem);
226+
fill(/backup passphrase/i, passphrase);
227+
fireEvent.click(screen.getByRole("button", { name: /record/i }));
228+
await waitFor(() => expect(downloadBytes).toHaveBeenCalled(), { timeout: 15_000 });
229+
230+
expect(bodies).toHaveLength(1);
231+
const pkcs8 = base64(new Uint8Array(await crypto.subtle.exportKey("pkcs8", keys.privateKey)));
232+
const jwk = await crypto.subtle.exportKey("jwk", keys.privateKey);
233+
const backupB64 = base64(backup);
234+
for (const secret of [pkcs8, jwk.d ?? "unset", passphrase, backupB64]) {
235+
expect(bodies[0]).not.toContain(secret);
236+
}
237+
}, 30_000);
238+
});

0 commit comments

Comments
 (0)