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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
18 changes: 16 additions & 2 deletions docs/api-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions docs/security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
121 changes: 121 additions & 0 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>[] = [
{
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();
Expand Down
66 changes: 66 additions & 0 deletions src/api/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
31 changes: 30 additions & 1 deletion src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -789,6 +788,36 @@ export class ProoflineApiClient {
).wrapped_key;
}

async revokeWrappedKey(wrappedKeyId: string): Promise<WrappedKey> {
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<WebCSRFResponse | null> {
if (this.mode !== "live" || this.authMode !== "cookie") {
this.webCSRF = null;
Expand Down
35 changes: 35 additions & 0 deletions src/api/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -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);
});
3 changes: 2 additions & 1 deletion src/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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({
Expand Down
Loading