From ac8a86073a3b6254fd2d04c489367f47f5d69f85 Mon Sep 17 00:00:00 2001 From: Ellie Melton Date: Sat, 6 Jun 2026 16:18:54 +1000 Subject: [PATCH] Add wrapped-key delivery revocation UI --- CHANGELOG.md | 2 + README.md | 6 +- docs/api-client.md | 18 ++- docs/security-model.md | 4 + src/App.test.tsx | 121 +++++++++++++++++++ src/api/client.test.ts | 66 +++++++++++ src/api/client.ts | 31 ++++- src/api/schemas.test.ts | 35 ++++++ src/api/schemas.ts | 3 +- src/routes/incidents/$incidentId.tsx | 168 ++++++++++++++++++++++----- 10 files changed, 417 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 919338c..4eec328 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Added incident-detail wrapped-key delivery revocation for account-owned + wrapped-key metadata. - Added incident-scoped sharing-grant management for active contact keys, optional stream scope, optional expiry, and revocation. - Added account-level contact public-key management for create, update, and diff --git a/README.md b/README.md index 90e6518..fc99d4b 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ The current bootstrap includes: - stream and chunk metadata review - contact public-key metadata views and account-level management - sharing-grant metadata views and incident-scoped management -- wrapped-key metadata views +- wrapped-key metadata views and delivery revocation - owner incident deletion status and request UI - safe loading, empty, and error states - visible prototype and emergency-reliance warnings @@ -149,11 +149,9 @@ The web client must not expose private admin/operator behavior or route `/v1/adm Planned sharing/contact work includes: -- wrapped-key metadata review and delivery status -- clear warnings that wrapped-key metadata is access-enabling metadata - trusted-contact access design, once separately scoped and threat-modeled -Sharing metadata support does not imply browser decryption, trusted-contact decryption, raw key access, key escrow, or playable export. +Sharing metadata support does not imply browser decryption, trusted-contact decryption, raw key access, key escrow, or playable export. Wrapped-key revocation stops future delivery only and cannot claw back material an authorized actor may already have received. ## Future Trusted-Contact Scope diff --git a/docs/api-client.md b/docs/api-client.md index 86916ad..769c71a 100644 --- a/docs/api-client.md +++ b/docs/api-client.md @@ -53,6 +53,7 @@ From current `open-proofline/server` docs and route registration: - `POST /v1/sharing-grants/{grant_id}/revoke` - `GET /v1/incidents/{incident_id}/wrapped-keys` - `GET /v1/wrapped-keys/{wrapped_key_id}` +- `POST /v1/wrapped-keys/{wrapped_key_id}/revoke` ## Live Owned Incident List Client @@ -103,8 +104,21 @@ narrative. Wrapped-key parsing does not retain `wrapped_key_ciphertext` in frontend state. The current server may return ciphertext on authenticated wrapped-key routes, 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 bindings, reviewed public wrapping profile metadata, state, and safe +timestamps until a separate trusted-contact delivery flow is designed and +reviewed. + +Wrapped-key delivery revocation uses the server's authenticated owner-scoped +route: + +- `POST /v1/wrapped-keys/{wrapped_key_id}/revoke` + +Revocation marks one wrapped-key record revoked and stops future delivery of +that record. It cannot claw back material an authorized actor may already have +received. The UI keeps revoke errors generic and does not expose owner-boundary +account IDs, wrapped-key ciphertext, raw media keys, contact private keys, +plaintext, request bodies, stored paths, object keys, or private deployment +details. Contact public-key management uses the server's authenticated account-scoped routes: diff --git a/docs/security-model.md b/docs/security-model.md index f3dfd86..9a6e810 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -22,6 +22,10 @@ This web client is experimental and not production-ready. - Sharing-grant management uses active contact public keys only, keeps expired or revoked grants out of active delivery paths, and does not add decryption, notification, or emergency-response behavior. +- Wrapped-key delivery revocation stops future wrapped-key delivery for + account-owned records, does not recover material already received, and does + not expose wrapped-key ciphertext, owner-boundary account IDs, raw keys, + plaintext, stored paths, or object keys. - 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 5163547..39d2a07 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1111,6 +1111,127 @@ test("creates and revokes live sharing grants from active contact keys", async ( expect(screen.getByRole("button", { name: "Revoke" })).toBeDisabled(); }); +test("revokes live wrapped-key delivery without displaying sensitive fields", async () => { + vi.stubEnv("VITE_PROOFLINE_API_MODE", "live"); + saveLiveSession(); + let wrappedKeys: Record[] = [ + { + wrapped_key_id: "wkey_live", + owner_account_id: "acct_live", + incident_id: "inc_live", + stream_id: "str_audio", + grant_id: "sgr_live", + recipient_type: "trusted_contact", + contact_id: "ctc_live", + contact_public_key_id: "cpk_live", + contact_public_key_version: 1, + media_key_id: "media-key-live", + wrapping_algorithm: "age-v1-x25519", + wrapping_algorithm_version: "1", + public_wrapping_metadata: { + profile: "age-v1-x25519", + recipient: { + raw_media_key: "raw-media-key", + }, + }, + wrapped_key_state: "active", + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:00:00Z", + wrapped_key_ciphertext: "wrapped-ciphertext", + raw_media_key: "raw-media-key", + contact_private_key: "contact-private-key", + plaintext: "private plaintext", + request_body: "private request", + stored_path: "incidents/inc_live/private.enc", + object_key: "private/object/key", + }, + ]; + server.use( + http.get("*/v1/incidents/inc_live", () => + HttpResponse.json({ + incident: { + id: "inc_live", + status: "closed", + deletion_state: "active", + }, + streams: [ + { + id: "str_audio", + incident_id: "inc_live", + media_type: "audio", + status: "complete", + }, + ], + chunks: [], + checkins: [], + }), + ), + http.get("*/v1/incidents/inc_live/deletion", () => + HttpResponse.json( + { + error: { + code: "incident_deletion_not_found", + message: "incident deletion was not found", + }, + }, + { status: 404 }, + ), + ), + http.get("*/v1/contact-public-keys", () => + HttpResponse.json({ contact_public_keys: [] }), + ), + http.get("*/v1/incidents/inc_live/sharing-grants", () => + HttpResponse.json({ sharing_grants: [] }), + ), + http.get("*/v1/incidents/inc_live/wrapped-keys", () => + HttpResponse.json({ wrapped_keys: wrappedKeys }), + ), + http.post("*/v1/wrapped-keys/wkey_live/revoke", () => { + const revoked = { + ...wrappedKeys[0], + wrapped_key_state: "revoked", + updated_at: "2026-06-01T00:10:00Z", + revoked_at: "2026-06-01T00:10:00Z", + revoked_by_account_id: "acct_live", + }; + wrappedKeys = []; + return HttpResponse.json({ wrapped_key: revoked }); + }), + ); + + renderRoute("/incidents/inc_live"); + + expect( + await screen.findByRole("heading", { name: "inc_live" }), + ).toBeInTheDocument(); + expect(await screen.findByText("wkey_live")).toBeInTheDocument(); + expect(screen.getByText("media-key-live")).toBeInTheDocument(); + expect(screen.getAllByText("age-v1-x25519")).toHaveLength(2); + expect(screen.getByText("Yes")).toBeInTheDocument(); + expect( + screen.getByText( + /Revocation stops future delivery of a wrapped-key record/, + ), + ).toBeInTheDocument(); + expect(screen.queryByText("wrapped-ciphertext")).toBeNull(); + expect(screen.queryByText("raw-media-key")).toBeNull(); + expect(screen.queryByText("contact-private-key")).toBeNull(); + expect(screen.queryByText("private plaintext")).toBeNull(); + expect(screen.queryByText("private request")).toBeNull(); + expect(screen.queryByText("incidents/inc_live/private.enc")).toBeNull(); + expect(screen.queryByText("private/object/key")).toBeNull(); + expect(screen.queryByText("acct_live")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Revoke delivery" })); + + expect(await screen.findByText("Key delivery revoked.")).toBeInTheDocument(); + expect(await screen.findByText("No key delivery")).toBeInTheDocument(); + expect(screen.queryByText("wkey_live")).toBeNull(); + expect(screen.queryByText("wrapped-ciphertext")).toBeNull(); + expect(screen.queryByText("raw-media-key")).toBeNull(); + expect(screen.queryByText("acct_live")).toBeNull(); +}); + test("shows safe sharing-grant empty state without active contact keys", async () => { vi.stubEnv("VITE_PROOFLINE_API_MODE", "live"); saveLiveSession(); diff --git a/src/api/client.test.ts b/src/api/client.test.ts index 2fa27b4..a05c5dd 100644 --- a/src/api/client.test.ts +++ b/src/api/client.test.ts @@ -921,3 +921,69 @@ test("revokes live sharing grants through the revoke route", async () => { revoked_by_account_id: "acct_live", }); }); + +test("revokes live wrapped keys without retaining sensitive fields", async () => { + server.use( + http.post("*/v1/wrapped-keys/wkey_live/revoke", ({ request }) => { + expect(request.headers.get("authorization")).toBe( + "Bearer test-session-token", + ); + return HttpResponse.json({ + wrapped_key: { + wrapped_key_id: "wkey_live", + owner_account_id: "acct_live", + incident_id: "inc_live", + stream_id: "str_live", + grant_id: "sgr_live", + recipient_type: "trusted_contact", + contact_id: "ctc_live", + contact_public_key_id: "cpk_live", + contact_public_key_version: 1, + media_key_id: "media-key-live", + wrapping_algorithm: "age-v1-x25519", + wrapping_algorithm_version: "1", + public_wrapping_metadata: { + profile: "age-v1-x25519", + }, + wrapped_key_state: "revoked", + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:10:00Z", + revoked_at: "2026-06-01T00:10:00Z", + revoked_by_account_id: "acct_live", + wrapped_key_ciphertext: "wrapped-ciphertext", + raw_media_key: "raw-media-key", + contact_private_key: "contact-private-key", + plaintext: "private plaintext", + request_body: "private request", + stored_path: "incidents/inc_live/private.enc", + object_key: "private/object/key", + }, + }); + }), + ); + + const client = createProoflineApiClient({ + mode: "live", + getToken: () => "test-session-token", + }); + + const wrappedKey = await client.revokeWrappedKey("wkey_live"); + + expect(wrappedKey).toMatchObject({ + wrapped_key_id: "wkey_live", + wrapped_key_state: "revoked", + revoked_at: "2026-06-01T00:10:00Z", + public_wrapping_metadata: { + profile: "age-v1-x25519", + }, + }); + expect("owner_account_id" in wrappedKey).toBe(false); + expect("revoked_by_account_id" in wrappedKey).toBe(false); + expect("wrapped_key_ciphertext" in wrappedKey).toBe(false); + expect("raw_media_key" in wrappedKey).toBe(false); + expect("contact_private_key" in wrappedKey).toBe(false); + expect("plaintext" in wrappedKey).toBe(false); + expect("request_body" in wrappedKey).toBe(false); + expect("stored_path" in wrappedKey).toBe(false); + expect("object_key" in wrappedKey).toBe(false); +}); diff --git a/src/api/client.ts b/src/api/client.ts index edd7136..4ac3e04 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -278,7 +278,6 @@ const mockSharingGrants: SharingGrant[] = [mockSharingGrant]; const mockWrappedKey: WrappedKey = { wrapped_key_id: "wkey_prototype_001", - owner_account_id: "acct_prototype", incident_id: "inc_prototype_002", stream_id: null, grant_id: "sgr_prototype_001", @@ -789,6 +788,36 @@ export class ProoflineApiClient { ).wrapped_key; } + async revokeWrappedKey(wrappedKeyId: string): Promise { + if (this.mode === "mock") { + const updatedAt = new Date().toISOString(); + const record = mockWrappedKeys.find( + (candidate) => candidate.wrapped_key_id === wrappedKeyId, + ); + const nextRecord: WrappedKey = { + ...(record ?? mockWrappedKey), + wrapped_key_id: wrappedKeyId, + wrapped_key_state: "revoked", + updated_at: updatedAt, + revoked_at: updatedAt, + }; + const index = mockWrappedKeys.findIndex( + (candidate) => candidate.wrapped_key_id === wrappedKeyId, + ); + if (index >= 0) { + mockWrappedKeys[index] = nextRecord; + } + return nextRecord; + } + + return wrappedKeyResponseSchema.parse( + await this.request( + `/v1/wrapped-keys/${encodeURIComponent(wrappedKeyId)}/revoke`, + { method: "POST" }, + ), + ).wrapped_key; + } + async refreshWebCSRF(): Promise { if (this.mode !== "live" || this.authMode !== "cookie") { this.webCSRF = null; diff --git a/src/api/schemas.test.ts b/src/api/schemas.test.ts index 98c3bf6..8cceea5 100644 --- a/src/api/schemas.test.ts +++ b/src/api/schemas.test.ts @@ -267,8 +267,19 @@ const wrappedKeyFixture = { ephemeral_public_key: "public-metadata", }, wrapped_key_state: "active", + revoked_at: "2026-06-01T00:10:00Z", + revoked_by_account_id: "acct_test", + rotated_at: "2026-06-01T00:20:00Z", created_at: "2026-06-01T00:00:00Z", updated_at: "2026-06-01T00:00:00Z", + raw_media_key: "raw-media-key", + contact_private_key: "contact-private-key", + plaintext: "private plaintext", + 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 detail", }; test("wrapped-key list parsing drops wrapped-key ciphertext", () => { @@ -292,8 +303,20 @@ test("wrapped-key list parsing drops wrapped-key ciphertext", () => { ephemeral_public_key: "public-metadata", }, wrapped_key_state: "active", + revoked_at: "2026-06-01T00:10:00Z", + rotated_at: "2026-06-01T00:20:00Z", }); + expect("owner_account_id" in wrappedKey).toBe(false); expect("wrapped_key_ciphertext" in wrappedKey).toBe(false); + expect("revoked_by_account_id" in wrappedKey).toBe(false); + expect("raw_media_key" in wrappedKey).toBe(false); + expect("contact_private_key" in wrappedKey).toBe(false); + expect("plaintext" in wrappedKey).toBe(false); + expect("request_body" in wrappedKey).toBe(false); + expect("stored_path" in wrappedKey).toBe(false); + expect("object_key" in wrappedKey).toBe(false); + expect("browser_fragment_secret" in wrappedKey).toBe(false); + expect("private_deployment_detail" in wrappedKey).toBe(false); }); test("wrapped-key detail parsing drops wrapped-key ciphertext", () => { @@ -306,6 +329,18 @@ test("wrapped-key detail parsing drops wrapped-key ciphertext", () => { contact_id: "ctc_test", contact_public_key_id: "cpk_test", wrapped_key_state: "active", + revoked_at: "2026-06-01T00:10:00Z", + rotated_at: "2026-06-01T00:20:00Z", }); + expect("owner_account_id" in parsed.wrapped_key).toBe(false); expect("wrapped_key_ciphertext" in parsed.wrapped_key).toBe(false); + expect("revoked_by_account_id" in parsed.wrapped_key).toBe(false); + expect("raw_media_key" in parsed.wrapped_key).toBe(false); + expect("contact_private_key" in parsed.wrapped_key).toBe(false); + expect("plaintext" in parsed.wrapped_key).toBe(false); + expect("request_body" in parsed.wrapped_key).toBe(false); + expect("stored_path" in parsed.wrapped_key).toBe(false); + expect("object_key" in parsed.wrapped_key).toBe(false); + expect("browser_fragment_secret" in parsed.wrapped_key).toBe(false); + expect("private_deployment_detail" in parsed.wrapped_key).toBe(false); }); diff --git a/src/api/schemas.ts b/src/api/schemas.ts index da84f61..3732d08 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -204,7 +204,6 @@ export const sharingGrantResponseSchema = z.object({ export const wrappedKeySchema = z.object({ wrapped_key_id: z.string(), - owner_account_id: z.string().optional(), incident_id: z.string(), stream_id: z.string().nullable().optional(), grant_id: z.string(), @@ -219,6 +218,8 @@ export const wrappedKeySchema = z.object({ wrapped_key_state: z.string(), created_at: z.string().optional(), updated_at: z.string().optional(), + revoked_at: z.string().optional(), + rotated_at: z.string().optional(), }); export const wrappedKeysResponseSchema = z.object({ diff --git a/src/routes/incidents/$incidentId.tsx b/src/routes/incidents/$incidentId.tsx index 38b9b53..1ffc18f 100644 --- a/src/routes/incidents/$incidentId.tsx +++ b/src/routes/incidents/$incidentId.tsx @@ -9,6 +9,7 @@ import type { ContactPublicKey, IncidentDeletionStatus, SharingGrant, + WrappedKey, } from "../../api/schemas"; import { ApiError } from "../../api/errors"; import { useAuth } from "../../auth/use-auth"; @@ -65,6 +66,19 @@ function isActiveDeliveryGrant(grant: SharingGrant): boolean { return grant.grant_state === "active" && !isExpiredGrant(grant); } +function isActiveWrappedKey(record: WrappedKey): boolean { + return record.wrapped_key_state === "active"; +} + +function publicWrappingProfile(record: WrappedKey): string | undefined { + const profile = record.public_wrapping_metadata?.profile; + if (typeof profile !== "string") { + return undefined; + } + const trimmed = profile.trim(); + return trimmed || undefined; +} + function normalizeFutureExpiry(value: string): { expiresAt?: string; error?: string; @@ -119,6 +133,9 @@ function IncidentDetailPage() { const [grantResult, setGrantResult] = useState({ state: "idle", }); + const [wrappedKeyResult, setWrappedKeyResult] = useState({ + state: "idle", + }); const incident = useQuery({ queryKey: prooflineQueryKeys.incident(incidentId), @@ -176,6 +193,20 @@ function IncidentDetailPage() { }); }, }); + const revokeWrappedKey = useMutation({ + mutationFn: (wrappedKeyId: string) => + apiClient.revokeWrappedKey(wrappedKeyId), + onSuccess: (record) => { + queryClient.setQueryData(wrappedKeysQueryKey, (current) => + (current ?? []).map((candidate) => + candidate.wrapped_key_id === record.wrapped_key_id + ? record + : candidate, + ), + ); + void queryClient.invalidateQueries({ queryKey: wrappedKeysQueryKey }); + }, + }); const requestDeletion = useMutation({ mutationFn: () => { const detail = incident.data; @@ -262,6 +293,22 @@ function IncidentDetailPage() { } } + async function handleRevokeWrappedKey(wrappedKeyId: string) { + setWrappedKeyResult({ state: "idle" }); + try { + await revokeWrappedKey.mutateAsync(wrappedKeyId); + setWrappedKeyResult({ + state: "success", + message: "Key delivery revoked.", + }); + } catch { + setWrappedKeyResult({ + state: "error", + message: "Key delivery could not be revoked.", + }); + } + } + if (!isAuthenticated) { return ; } @@ -640,35 +687,51 @@ function IncidentDetailPage() { title="Key delivery" count={wrappedKeys.data?.length ?? 0} > - {wrappedKeys.isError ? ( - - Key delivery details could not be loaded. - - ) : wrappedKeys.isLoading ? ( -

- Loading key delivery details. -

- ) : wrappedKeys.data?.length ? ( -
- {wrappedKeys.data.map((record) => ( - } - /> - ))} -
- ) : ( - - )} +
+ + Revocation stops future delivery of a wrapped-key record. It cannot + claw back material an authorized actor may already have received, + and this app does not unwrap keys or decrypt media. + + + {wrappedKeyResult.state === "success" ? ( + + {wrappedKeyResult.message} + + ) : null} + {wrappedKeyResult.state === "error" ? ( + {wrappedKeyResult.message} + ) : null} + + {wrappedKeys.isError ? ( + + Key delivery details could not be loaded. + + ) : wrappedKeys.isLoading ? ( +

+ Loading key delivery details. +

+ ) : wrappedKeys.data?.length ? ( +
+ {wrappedKeys.data.map((record) => ( + + ))} +
+ ) : ( + + )} +
); @@ -753,6 +816,53 @@ function SharingGrantRow({ ); } +function WrappedKeyRow({ + record, + isRevoking, + onRevoke, +}: { + record: WrappedKey; + isRevoking: boolean; + onRevoke: (wrappedKeyId: string) => void; +}) { + const isActive = isActiveWrappedKey(record); + return ( +
+ } + /> +
+ +
+
+ ); +} + function MetadataSection({ title, count,