From 65cc69fa106d162c6061c2b28d39f6f0bdfe4d43 Mon Sep 17 00:00:00 2001 From: Ellie Melton Date: Sat, 6 Jun 2026 14:32:02 +1000 Subject: [PATCH 1/2] Add contact public-key management flow --- CHANGELOG.md | 2 + README.md | 4 +- docs/api-client.md | 24 ++ docs/security-model.md | 2 + src/App.test.tsx | 237 ++++++++++- src/api/client.test.ts | 154 ++++++++ src/api/client.ts | 124 ++++++ src/api/schemas.test.ts | 46 +++ src/api/schemas.ts | 1 + src/components/proofline/AppShell.tsx | 1 + src/router.tsx | 2 + src/routes/contact-keys.tsx | 545 ++++++++++++++++++++++++++ 12 files changed, 1138 insertions(+), 4 deletions(-) create mode 100644 src/routes/contact-keys.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bcfe7b..11b3f6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Added account-level contact public-key management for create, update, and + revoke flows. - Added owner incident deletion status and request UI for the authenticated incident detail route. - Added an authenticated account profile route with safe account metadata and diff --git a/README.md b/README.md index 8b53e5d..b2faca7 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ The current bootstrap includes: owner-scoped `GET /v1/incidents` responses in live mode - incident detail metadata UI - stream and chunk metadata review -- contact public-key metadata views +- contact public-key metadata views and account-level management - sharing-grant metadata views - wrapped-key metadata views - owner incident deletion status and request UI @@ -149,8 +149,6 @@ The web client must not expose private admin/operator behavior or route `/v1/adm Planned sharing/contact work includes: -- contact public-key registration and management -- contact public-key state display - sharing-grant creation and revocation - incident-scoped and stream-scoped grant management - wrapped-key metadata review and delivery status diff --git a/docs/api-client.md b/docs/api-client.md index abae27e..3d4c59e 100644 --- a/docs/api-client.md +++ b/docs/api-client.md @@ -42,8 +42,11 @@ From current `open-proofline/server` docs and route registration: - `GET /v1/incidents/{incident_id}` - `POST /v1/incidents/{incident_id}/deletion` - `GET /v1/incidents/{incident_id}/deletion` +- `POST /v1/contact-public-keys` - `GET /v1/contact-public-keys` - `GET /v1/contact-public-keys/{public_key_id}` +- `PATCH /v1/contact-public-keys/{public_key_id}` +- `POST /v1/contact-public-keys/{public_key_id}/revoke` - `GET /v1/incidents/{incident_id}/sharing-grants` - `GET /v1/sharing-grants/{grant_id}` - `GET /v1/incidents/{incident_id}/wrapped-keys` @@ -101,6 +104,27 @@ but this metadata-review prototype keeps only wrapped-key identifiers, grant and contact bindings, wrapping metadata, and state until a separate trusted-contact delivery flow is designed and reviewed. +Contact public-key management uses the server's authenticated account-scoped +routes: + +- `POST /v1/contact-public-keys` +- `GET /v1/contact-public-keys` +- `GET /v1/contact-public-keys/{public_key_id}` +- `PATCH /v1/contact-public-keys/{public_key_id}` +- `POST /v1/contact-public-keys/{public_key_id}/revoke` + +The client sends tightly shaped request bodies for display label, wrapping +algorithm, public key, fingerprint, optional `contact_id`, and reviewed +`key_state`. It does not send or retain contact private keys, raw media keys, +plaintext, wrapped-key ciphertext, browser fragment secrets, request bodies, +stored paths, object keys, or private deployment details. + +Contact-key states are `pending_verification`, `active`, `replaced`, `revoked`, +and `lost`. Only `active` keys are eligible for new sharing grants. The UI keeps +revoked keys visibly ineligible and does not offer reactivation controls for +revoked records; the server also rejects revoked-key reactivation with +`409 invalid_contact_key_state`. + ## Public Registration Contracts The API client includes typed public calls for `POST /v1/auth/register` and diff --git a/docs/security-model.md b/docs/security-model.md index 40d2186..566718f 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -17,6 +17,8 @@ This web client is experimental and not production-ready. - API responses are parsed with Zod before use where route shapes are known. - UI states avoid showing raw tokens, Authorization headers, request bodies, plaintext, raw keys, wrapped-key ciphertext, stored paths, or object keys. +- Contact public-key management sends only reviewed public-key metadata fields + and keeps revoked keys visibly ineligible for new sharing grants. - Registration responses use the server's generic verification-required success message and do not create a browser session. - The email-verification route reads the token from the URL fragment, submits diff --git a/src/App.test.tsx b/src/App.test.tsx index df648ed..a17c309 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,6 +1,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createMemoryHistory, RouterProvider } from "@tanstack/react-router"; -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { afterEach, beforeEach, vi } from "vitest"; import { AuthProvider } from "./auth/use-auth"; @@ -600,6 +600,241 @@ test("renders authenticated mock incident detail metadata sections", async () => expect(screen.getByText("No key delivery")).toBeInTheDocument(); }); +test("renders live contact-key empty state", async () => { + vi.stubEnv("VITE_PROOFLINE_API_MODE", "live"); + saveLiveSession(); + server.use( + http.get("*/v1/contact-public-keys", () => + HttpResponse.json({ contact_public_keys: [] }), + ), + ); + + renderRoute("/contact-keys"); + + expect( + await screen.findByRole("heading", { name: "Contact public keys" }), + ).toBeInTheDocument(); + expect(await screen.findByText("No contact keys")).toBeInTheDocument(); + expect( + screen.getByText( + "Only active contact keys are eligible for new sharing grants. This app does not handle contact private keys, media keys, decryption, or wrapped-key ciphertext.", + ), + ).toBeInTheDocument(); +}); + +test("creates live contact public keys without displaying private fields", async () => { + vi.stubEnv("VITE_PROOFLINE_API_MODE", "live"); + saveLiveSession(); + const contactKeys: unknown[] = []; + server.use( + http.get("*/v1/contact-public-keys", () => + HttpResponse.json({ contact_public_keys: contactKeys }), + ), + http.post("*/v1/contact-public-keys", async ({ request }) => { + await expect(request.json()).resolves.toEqual({ + display_label: "Trusted contact", + wrapping_algorithm: "age-v1-x25519", + public_key: "age1public", + public_key_fingerprint: "fingerprint-live", + key_state: "pending_verification", + }); + const contactKey = { + public_key_id: "cpk_live", + owner_account_id: "acct_live", + contact_id: "ctc_live", + version: 1, + display_label: "Trusted contact", + wrapping_algorithm: "age-v1-x25519", + public_key: "age1public", + public_key_fingerprint: "fingerprint-live", + key_state: "pending_verification", + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:00:00Z", + contact_private_key: "must-not-display", + raw_media_key: "raw-media-key", + plaintext: "private plaintext", + wrapped_key_ciphertext: "wrapped-ciphertext", + request_body: "private request", + stored_path: "incidents/inc_live/private.enc", + object_key: "private/object/key", + }; + contactKeys.push(contactKey); + return HttpResponse.json( + { contact_public_key: contactKey }, + { status: 201 }, + ); + }), + ); + + renderRoute("/contact-keys"); + + expect( + await screen.findByRole("heading", { name: "Contact public keys" }), + ).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("Display label"), { + target: { value: "Trusted contact" }, + }); + fireEvent.change(screen.getByLabelText("Public key"), { + target: { value: "age1public" }, + }); + fireEvent.change(screen.getByLabelText("Fingerprint"), { + target: { value: "fingerprint-live" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save contact key" })); + + expect( + await screen.findByText("Contact public key saved."), + ).toBeInTheDocument(); + expect(await screen.findAllByText("Trusted contact")).not.toHaveLength(0); + expect(screen.getByText("fingerprint-live")).toBeInTheDocument(); + expect(screen.queryByText("must-not-display")).toBeNull(); + expect(screen.queryByText("raw-media-key")).toBeNull(); + expect(screen.queryByText("private plaintext")).toBeNull(); + expect(screen.queryByText("wrapped-ciphertext")).toBeNull(); + expect(screen.queryByText("private request")).toBeNull(); + expect(screen.queryByText("incidents/inc_live/private.enc")).toBeNull(); + expect(screen.queryByText("private/object/key")).toBeNull(); +}); + +test("updates and revokes live contact public keys with safe states", async () => { + vi.stubEnv("VITE_PROOFLINE_API_MODE", "live"); + saveLiveSession(); + let contactKey: Record = { + public_key_id: "cpk_live", + owner_account_id: "acct_live", + contact_id: "ctc_live", + version: 1, + display_label: "Trusted contact", + wrapping_algorithm: "age-v1-x25519", + public_key: "age1public", + public_key_fingerprint: "fingerprint-live", + key_state: "active", + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:00:00Z", + }; + server.use( + http.get("*/v1/contact-public-keys", () => + HttpResponse.json({ contact_public_keys: [contactKey] }), + ), + http.patch("*/v1/contact-public-keys/cpk_live", async ({ request }) => { + await expect(request.json()).resolves.toEqual({ + display_label: "Verified contact", + key_state: "lost", + }); + contactKey = { + ...contactKey, + display_label: "Verified contact", + key_state: "lost", + updated_at: "2026-06-01T00:10:00Z", + }; + return HttpResponse.json({ contact_public_key: contactKey }); + }), + http.post("*/v1/contact-public-keys/cpk_live/revoke", () => { + contactKey = { + ...contactKey, + key_state: "revoked", + revoked_at: "2026-06-01T00:20:00Z", + updated_at: "2026-06-01T00:20:00Z", + }; + return HttpResponse.json({ contact_public_key: contactKey }); + }), + ); + + renderRoute("/contact-keys"); + + expect(await screen.findAllByText("Trusted contact")).not.toHaveLength(0); + expect(screen.getByText("Yes")).toBeInTheDocument(); + const record = screen + .getAllByText("Trusted contact") + .find((element) => element.tagName !== "OPTION") + ?.closest(".space-y-4") as HTMLElement | null; + if (!record) { + throw new Error("expected contact key record"); + } + fireEvent.change(within(record).getByLabelText("Display label"), { + target: { value: "Verified contact" }, + }); + fireEvent.change(within(record).getByLabelText("Reviewed state"), { + target: { value: "lost" }, + }); + fireEvent.click(within(record).getByRole("button", { name: "Save" })); + + expect(await screen.findByText("Contact key updated.")).toBeInTheDocument(); + expect(await screen.findAllByText("Verified contact")).not.toHaveLength(0); + expect(await screen.findByText("No")).toBeInTheDocument(); + + const updatedRecord = screen + .getAllByText("Verified contact") + .find((element) => element.tagName !== "OPTION") + ?.closest(".space-y-4") as HTMLElement | null; + if (!updatedRecord) { + throw new Error("expected updated contact key record"); + } + fireEvent.click(within(updatedRecord).getByRole("button", { name: "Revoke" })); + + expect(await screen.findByText("Contact key revoked.")).toBeInTheDocument(); + expect( + await screen.findByText( + "Revoked keys are not eligible for new sharing grants and cannot be reactivated here.", + ), + ).toBeInTheDocument(); + expect( + within(updatedRecord).getByRole("button", { name: "Revoke" }), + ).toBeDisabled(); +}); + +test("shows generic contact-key loading and request errors", async () => { + vi.stubEnv("VITE_PROOFLINE_API_MODE", "live"); + saveLiveSession(); + server.use( + http.get("*/v1/contact-public-keys", () => + HttpResponse.json( + { + error: { + code: "unavailable", + message: "backend private contact detail", + }, + }, + { status: 503 }, + ), + ), + http.post("*/v1/contact-public-keys", () => + HttpResponse.json( + { + error: { + code: "invalid_public_key", + message: "private request detail", + }, + }, + { status: 400 }, + ), + ), + ); + + renderRoute("/contact-keys"); + + expect( + await screen.findByRole("heading", { name: "Contact public keys" }), + ).toBeInTheDocument(); + expect( + await screen.findByText("Contact keys could not be loaded."), + ).toBeInTheDocument(); + expect(screen.queryByText("backend private contact detail")).toBeNull(); + + fireEvent.change(screen.getByLabelText("Public key"), { + target: { value: "age1public" }, + }); + fireEvent.change(screen.getByLabelText("Fingerprint"), { + target: { value: "fingerprint-live" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save contact key" })); + + expect( + await screen.findByText("Contact public key could not be saved."), + ).toBeInTheDocument(); + expect(screen.queryByText("private request detail")).toBeNull(); +}); + test("renders existing incident deletion status without private deletion internals", async () => { vi.stubEnv("VITE_PROOFLINE_API_MODE", "live"); saveLiveSession(); diff --git a/src/api/client.test.ts b/src/api/client.test.ts index cc7d4e2..7e45631 100644 --- a/src/api/client.test.ts +++ b/src/api/client.test.ts @@ -652,3 +652,157 @@ test("requests live incident deletion with a safe reason code", async () => { expect("object_key" in deletion).toBe(false); expect("wrapped_key_ciphertext" in deletion).toBe(false); }); + +test("creates live contact public keys with only public metadata fields", async () => { + server.use( + http.post("*/v1/contact-public-keys", async ({ request }) => { + expect(request.headers.get("authorization")).toBe( + "Bearer test-session-token", + ); + await expect(request.json()).resolves.toEqual({ + contact_id: "ctc_live", + display_label: "Trusted contact", + wrapping_algorithm: "age-v1-x25519", + public_key: "age1public", + public_key_fingerprint: "fingerprint-live", + key_state: "pending_verification", + }); + return HttpResponse.json( + { + contact_public_key: { + public_key_id: "cpk_live", + owner_account_id: "acct_live", + contact_id: "ctc_live", + version: 2, + display_label: "Trusted contact", + wrapping_algorithm: "age-v1-x25519", + public_key: "age1public", + public_key_fingerprint: "fingerprint-live", + key_state: "pending_verification", + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:00:00Z", + contact_private_key: "must-not-retain", + raw_media_key: "raw-media-key", + plaintext: "private plaintext", + wrapped_key_ciphertext: "wrapped-ciphertext", + request_body: "private request", + stored_path: "incidents/inc_live/private.enc", + object_key: "private/object/key", + }, + }, + { status: 201 }, + ); + }), + ); + + const client = createProoflineApiClient({ + mode: "live", + getToken: () => "test-session-token", + }); + + const contactKey = await client.createContactPublicKey({ + contactId: "ctc_live", + displayLabel: "Trusted contact", + wrappingAlgorithm: "age-v1-x25519", + publicKey: "age1public", + publicKeyFingerprint: "fingerprint-live", + keyState: "pending_verification", + }); + + expect(contactKey).toMatchObject({ + public_key_id: "cpk_live", + contact_id: "ctc_live", + key_state: "pending_verification", + }); + expect("contact_private_key" in contactKey).toBe(false); + expect("raw_media_key" in contactKey).toBe(false); + expect("plaintext" in contactKey).toBe(false); + expect("wrapped_key_ciphertext" in contactKey).toBe(false); + expect("request_body" in contactKey).toBe(false); + expect("stored_path" in contactKey).toBe(false); + expect("object_key" in contactKey).toBe(false); +}); + +test("updates live contact public-key label and state", async () => { + server.use( + http.patch("*/v1/contact-public-keys/cpk_live", async ({ request }) => { + expect(request.headers.get("authorization")).toBe( + "Bearer test-session-token", + ); + await expect(request.json()).resolves.toEqual({ + display_label: "Verified contact", + key_state: "active", + }); + return HttpResponse.json({ + contact_public_key: { + public_key_id: "cpk_live", + owner_account_id: "acct_live", + contact_id: "ctc_live", + version: 1, + display_label: "Verified contact", + wrapping_algorithm: "age-v1-x25519", + public_key: "age1public", + public_key_fingerprint: "fingerprint-live", + key_state: "active", + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:10:00Z", + }, + }); + }), + ); + + const client = createProoflineApiClient({ + mode: "live", + getToken: () => "test-session-token", + }); + + await expect( + client.updateContactPublicKey("cpk_live", { + displayLabel: "Verified contact", + keyState: "active", + }), + ).resolves.toMatchObject({ + public_key_id: "cpk_live", + display_label: "Verified contact", + key_state: "active", + }); +}); + +test("revokes live contact public keys through the revoke route", async () => { + server.use( + http.post("*/v1/contact-public-keys/cpk_live/revoke", ({ request }) => { + expect(request.headers.get("authorization")).toBe( + "Bearer test-session-token", + ); + return HttpResponse.json({ + contact_public_key: { + public_key_id: "cpk_live", + owner_account_id: "acct_live", + contact_id: "ctc_live", + version: 1, + display_label: "Verified contact", + wrapping_algorithm: "age-v1-x25519", + public_key: "age1public", + public_key_fingerprint: "fingerprint-live", + key_state: "revoked", + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:10:00Z", + revoked_at: "2026-06-01T00:10:00Z", + }, + }); + }), + ); + + const client = createProoflineApiClient({ + mode: "live", + getToken: () => "test-session-token", + }); + + await expect(client.revokeContactPublicKey("cpk_live")).resolves.toMatchObject( + { + public_key_id: "cpk_live", + key_state: "revoked", + revoked_at: "2026-06-01T00:10:00Z", + }, + ); +}); diff --git a/src/api/client.ts b/src/api/client.ts index f73e9d4..a7970d2 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -59,6 +59,20 @@ type RequestIncidentDeletionRequest = { allowOpen: boolean; }; +export type CreateContactPublicKeyRequest = { + contactId?: string; + displayLabel?: string; + wrappingAlgorithm: string; + publicKey: string; + publicKeyFingerprint: string; + keyState?: string; +}; + +export type UpdateContactPublicKeyRequest = { + displayLabel?: string; + keyState?: string; +}; + type RequestOptions = { includeAuth?: boolean; includeCredentials?: boolean; @@ -509,6 +523,116 @@ export class ProoflineApiClient { ).contact_public_key; } + async createContactPublicKey( + request: CreateContactPublicKeyRequest, + ): Promise { + if (this.mode === "mock") { + const now = new Date().toISOString(); + const contactId = request.contactId || `ctc_prototype_${Date.now()}`; + const contactKey: ContactPublicKey = { + public_key_id: `cpk_prototype_${Date.now()}`, + owner_account_id: mockAccount.id, + contact_id: contactId, + version: request.contactId ? 2 : 1, + display_label: request.displayLabel, + wrapping_algorithm: request.wrappingAlgorithm, + public_key: request.publicKey, + public_key_fingerprint: request.publicKeyFingerprint, + key_state: request.keyState ?? "pending_verification", + created_at: now, + updated_at: now, + }; + mockContactPublicKeys.push(contactKey); + return contactKey; + } + + const body: Record = { + wrapping_algorithm: request.wrappingAlgorithm, + public_key: request.publicKey, + public_key_fingerprint: request.publicKeyFingerprint, + }; + if (request.contactId) { + body.contact_id = request.contactId; + } + if (request.displayLabel !== undefined) { + body.display_label = request.displayLabel; + } + if (request.keyState !== undefined) { + body.key_state = request.keyState; + } + + return contactPublicKeyResponseSchema.parse( + await this.request("/v1/contact-public-keys", { + method: "POST", + body: JSON.stringify(body), + }), + ).contact_public_key; + } + + async updateContactPublicKey( + publicKeyId: string, + request: UpdateContactPublicKeyRequest, + ): Promise { + if (this.mode === "mock") { + const updatedAt = new Date().toISOString(); + const contactKey = mockContactPublicKeys.find( + (record) => record.public_key_id === publicKeyId, + ); + const nextContactKey: ContactPublicKey = { + ...(contactKey ?? mockContactPublicKey), + public_key_id: publicKeyId, + ...(request.displayLabel !== undefined + ? { display_label: request.displayLabel } + : {}), + ...(request.keyState !== undefined + ? { key_state: request.keyState } + : {}), + updated_at: updatedAt, + ...(request.keyState === "revoked" ? { revoked_at: updatedAt } : {}), + }; + const index = mockContactPublicKeys.findIndex( + (record) => record.public_key_id === publicKeyId, + ); + if (index >= 0) { + mockContactPublicKeys[index] = nextContactKey; + } + return nextContactKey; + } + + const body: Record = {}; + if (request.displayLabel !== undefined) { + body.display_label = request.displayLabel; + } + if (request.keyState !== undefined) { + body.key_state = request.keyState; + } + + return contactPublicKeyResponseSchema.parse( + await this.request( + `/v1/contact-public-keys/${encodeURIComponent(publicKeyId)}`, + { + method: "PATCH", + body: JSON.stringify(body), + }, + ), + ).contact_public_key; + } + + async revokeContactPublicKey(publicKeyId: string): Promise { + if (this.mode === "mock") { + return this.updateContactPublicKey(publicKeyId, { + keyState: "revoked", + }); + } + + return contactPublicKeyResponseSchema.parse( + await this.request( + `/v1/contact-public-keys/${encodeURIComponent(publicKeyId)}/revoke`, + { method: "POST" }, + ), + ).contact_public_key; + } + async listSharingGrants(incidentId: string): Promise { if (this.mode === "mock") { return mockSharingGrants.filter( diff --git a/src/api/schemas.test.ts b/src/api/schemas.test.ts index be01004..8b11aa9 100644 --- a/src/api/schemas.test.ts +++ b/src/api/schemas.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "vitest"; import { + contactPublicKeyResponseSchema, incidentDeletionResponseSchema, incidentDetailSchema, incidentsResponseSchema, @@ -154,6 +155,51 @@ test("incident deletion status parsing drops deletion item internals", () => { expect("user_safety_narrative" in parsed.deletion).toBe(false); }); +test("contact public-key parsing drops private key material and request internals", () => { + const parsed = contactPublicKeyResponseSchema.parse({ + contact_public_key: { + public_key_id: "cpk_test", + owner_account_id: "acct_test", + contact_id: "ctc_test", + version: 1, + display_label: "Trusted contact", + wrapping_algorithm: "age-v1-x25519", + public_key: "age1public", + public_key_fingerprint: "fingerprint-test", + key_state: "active", + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:00:00Z", + revoked_at: "2026-06-01T00:10:00Z", + contact_private_key: "must-not-retain", + raw_media_key: "raw-media-key", + plaintext: "private plaintext", + wrapped_key_ciphertext: "wrapped-ciphertext", + request_body: "private request", + stored_path: "incidents/inc_test/private.enc", + object_key: "private/object/key", + browser_fragment_secret: "fragment-secret", + private_deployment_detail: "private deployment", + }, + }); + + expect(parsed.contact_public_key).toMatchObject({ + public_key_id: "cpk_test", + contact_id: "ctc_test", + display_label: "Trusted contact", + key_state: "active", + revoked_at: "2026-06-01T00:10:00Z", + }); + expect("contact_private_key" in parsed.contact_public_key).toBe(false); + expect("raw_media_key" in parsed.contact_public_key).toBe(false); + expect("plaintext" in parsed.contact_public_key).toBe(false); + expect("wrapped_key_ciphertext" in parsed.contact_public_key).toBe(false); + expect("request_body" in parsed.contact_public_key).toBe(false); + expect("stored_path" in parsed.contact_public_key).toBe(false); + expect("object_key" in parsed.contact_public_key).toBe(false); + expect("browser_fragment_secret" in parsed.contact_public_key).toBe(false); + expect("private_deployment_detail" in parsed.contact_public_key).toBe(false); +}); + const wrappedKeyFixture = { wrapped_key_id: "wkey_test", owner_account_id: "acct_test", diff --git a/src/api/schemas.ts b/src/api/schemas.ts index c77e837..25bfd9b 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -165,6 +165,7 @@ export const contactPublicKeySchema = z.object({ key_state: z.string(), created_at: z.string().optional(), updated_at: z.string().optional(), + revoked_at: z.string().optional(), }); export const contactPublicKeysResponseSchema = z.object({ diff --git a/src/components/proofline/AppShell.tsx b/src/components/proofline/AppShell.tsx index 1b361dc..eeaa963 100644 --- a/src/components/proofline/AppShell.tsx +++ b/src/components/proofline/AppShell.tsx @@ -11,6 +11,7 @@ import { useAuth } from "../../auth/use-auth"; const navigation = [ { to: "/", label: "Overview" }, { to: "/incidents", label: "Records" }, + { to: "/contact-keys", label: "Contact keys" }, { to: "/account", label: "Account" }, ]; diff --git a/src/router.tsx b/src/router.tsx index 15b0010..c3ccd0c 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -6,6 +6,7 @@ import { import { incidentDetailRoute } from "./routes/incidents/$incidentId"; import { incidentsRoute } from "./routes/incidents/index"; import { accountRoute } from "./routes/account"; +import { contactKeysRoute } from "./routes/contact-keys"; import { indexRoute } from "./routes/index"; import { loginRoute } from "./routes/login"; import { registerRoute } from "./routes/register"; @@ -18,6 +19,7 @@ const routeTree = rootRoute.addChildren([ registerRoute, verifyEmailRoute, accountRoute, + contactKeysRoute, incidentsRoute, incidentDetailRoute, ]); diff --git a/src/routes/contact-keys.tsx b/src/routes/contact-keys.tsx new file mode 100644 index 0000000..1198961 --- /dev/null +++ b/src/routes/contact-keys.tsx @@ -0,0 +1,545 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Navigate, createRoute } from "@tanstack/react-router"; +import { useMemo, useState, type FormEvent } from "react"; +import { prooflineQueryKeys } from "../api/client"; +import type { ContactPublicKey } from "../api/schemas"; +import { useAuth } from "../auth/use-auth"; +import { Button } from "../components/catalyst/button"; +import { + Description, + ErrorMessage, + Field, + FieldGroup, + Label, +} from "../components/catalyst/fieldset"; +import { Input } from "../components/catalyst/input"; +import { Select } from "../components/catalyst/select"; +import { Textarea } from "../components/catalyst/textarea"; +import { EmptyState } from "../components/proofline/EmptyState"; +import { + ContentSection, + InlineStatus, + MetadataRow, + PageHeader, +} from "../components/proofline/Layout"; +import { StatusBadge } from "../components/proofline/StatusBadge"; +import { rootRoute } from "./__root"; + +type ResultState = + | { state: "idle" } + | { state: "success"; message: string } + | { state: "error"; message: string }; + +type CreateFormErrorField = + | "displayLabel" + | "wrappingAlgorithm" + | "publicKey" + | "fingerprint"; +type CreateFormErrors = Partial>; + +const maxDisplayLabelBytes = 200; +const maxWrappingAlgorithmBytes = 80; +const maxPublicKeyBytes = 4096; +const maxFingerprintBytes = 256; + +function byteLength(value: string): number { + return new TextEncoder().encode(value).length; +} + +function isGrantEligible(contactKey: ContactPublicKey): boolean { + return contactKey.key_state === "active"; +} + +function validateCreateForm({ + displayLabel, + wrappingAlgorithm, + publicKey, + fingerprint, +}: { + displayLabel: string; + wrappingAlgorithm: string; + publicKey: string; + fingerprint: string; +}): CreateFormErrors { + const errors: CreateFormErrors = {}; + if (byteLength(displayLabel) > maxDisplayLabelBytes) { + errors.displayLabel = "Display label must be 200 bytes or less."; + } + if (wrappingAlgorithm.trim() === "") { + errors.wrappingAlgorithm = "Enter a wrapping algorithm."; + } else if (byteLength(wrappingAlgorithm) > maxWrappingAlgorithmBytes) { + errors.wrappingAlgorithm = "Wrapping algorithm must be 80 bytes or less."; + } + if (publicKey.trim() === "") { + errors.publicKey = "Enter the contact public key."; + } else if (byteLength(publicKey) > maxPublicKeyBytes) { + errors.publicKey = "Public key must be 4096 bytes or less."; + } + if (fingerprint.trim() === "") { + errors.fingerprint = "Enter the public-key fingerprint."; + } else if (byteLength(fingerprint) > maxFingerprintBytes) { + errors.fingerprint = "Fingerprint must be 256 bytes or less."; + } + return errors; +} + +function hasErrors(errors: CreateFormErrors): boolean { + return Object.values(errors).some(Boolean); +} + +function clearCreateFormError( + errors: CreateFormErrors, + field: CreateFormErrorField, +): CreateFormErrors { + const next = { ...errors }; + delete next[field]; + return next; +} + +function ContactKeysPage() { + const { isAuthenticated, apiClient } = useAuth(); + const queryClient = useQueryClient(); + const contactKeys = useQuery({ + queryKey: prooflineQueryKeys.contactPublicKeys, + queryFn: () => apiClient.listContactPublicKeys(), + enabled: isAuthenticated, + }); + const [displayLabel, setDisplayLabel] = useState(""); + const [wrappingAlgorithm, setWrappingAlgorithm] = + useState("age-v1-x25519"); + const [publicKey, setPublicKey] = useState(""); + const [fingerprint, setFingerprint] = useState(""); + const [keyState, setKeyState] = useState("pending_verification"); + const [contactId, setContactId] = useState(""); + const [createErrors, setCreateErrors] = useState({}); + const [result, setResult] = useState({ state: "idle" }); + + const existingContacts = useMemo(() => { + const contacts = new Map(); + for (const contactKey of contactKeys.data ?? []) { + contacts.set( + contactKey.contact_id, + contactKey.display_label ?? contactKey.contact_id, + ); + } + return Array.from(contacts.entries()).sort((first, second) => + first[1].localeCompare(second[1]), + ); + }, [contactKeys.data]); + + const createContactKey = useMutation({ + mutationFn: () => { + const request = { + wrappingAlgorithm: wrappingAlgorithm.trim(), + publicKey: publicKey.trim(), + publicKeyFingerprint: fingerprint.trim(), + keyState, + ...(contactId ? { contactId } : {}), + ...(displayLabel.trim() + ? { displayLabel: displayLabel.trim() } + : {}), + }; + return apiClient.createContactPublicKey(request); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: prooflineQueryKeys.contactPublicKeys, + }); + }, + }); + + if (!isAuthenticated) { + return ; + } + + async function handleCreateSubmit(event: FormEvent) { + event.preventDefault(); + setResult({ state: "idle" }); + const errors = validateCreateForm({ + displayLabel, + wrappingAlgorithm, + publicKey, + fingerprint, + }); + setCreateErrors(errors); + if (hasErrors(errors)) { + return; + } + try { + await createContactKey.mutateAsync(); + setDisplayLabel(""); + setPublicKey(""); + setFingerprint(""); + setKeyState("pending_verification"); + setContactId(""); + setResult({ + state: "success", + message: "Contact public key saved.", + }); + } catch { + setResult({ + state: "error", + message: "Contact public key could not be saved.", + }); + } + } + + return ( +
+ +

+ Manage trusted-contact public-key metadata for future sharing + grants. +

+

+ Only active contact keys are eligible for new sharing grants. This + app does not handle contact private keys, media keys, decryption, + or wrapped-key ciphertext. +

+ + } + /> + + + Public-key metadata is access-enabling metadata. Verify keys out of band + before marking them active. + + + +
+ + + + + Choose an existing contact to rotate their key, or leave this + set to new contact. + + + + + + + Use a short label only. Do not enter private relationship + context or safety narrative. + + { + setDisplayLabel(event.target.value); + if (createErrors.displayLabel !== undefined) { + if (byteLength(event.target.value) > maxDisplayLabelBytes) { + setCreateErrors((current) => ({ + ...current, + displayLabel: + "Display label must be 200 bytes or less.", + })); + } else { + setCreateErrors((current) => + clearCreateFormError(current, "displayLabel"), + ); + } + } + }} + /> + {createErrors.displayLabel ? ( + {createErrors.displayLabel} + ) : null} + + + + { + setWrappingAlgorithm(event.target.value); + if (createErrors.wrappingAlgorithm !== undefined) { + setCreateErrors((current) => + clearCreateFormError(current, "wrappingAlgorithm"), + ); + } + }} + required + /> + {createErrors.wrappingAlgorithm ? ( + {createErrors.wrappingAlgorithm} + ) : null} + + + +