From 502f66f6727a4961e2c470c2b6592b334ee5adcb Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 12 Jun 2026 18:58:51 +0000 Subject: [PATCH] Make Audience ingest keys revealable instead of show-once Store the cpk_ plaintext AES-256-GCM-encrypted (lib/sp/vault.ts, SOCIAL_VAULT_KEY) alongside the verification hash, add a revealProjectApiKey server action (project access required, viewers excluded, revoked keys refused), and give each key a Reveal/Hide + Copy control in the Audience UI. Keys minted before the migration have no ciphertext and simply don't get a Reveal button. Co-Authored-By: Claude Fable 5 --- app/(app)/projects/[id]/audience/client.tsx | 119 ++++++++++++++---- app/(app)/projects/[id]/audience/page.tsx | 14 ++- app/actions/audience.ts | 37 +++++- lib/audience/projectKeys.ts | 17 ++- ...0612160000_project_api_keys_ciphertext.sql | 7 ++ tests/audience-hub.test.ts | 8 ++ tests/setup.ts | 2 + 7 files changed, 169 insertions(+), 35 deletions(-) create mode 100644 supabase/migrations/20260612160000_project_api_keys_ciphertext.sql diff --git a/app/(app)/projects/[id]/audience/client.tsx b/app/(app)/projects/[id]/audience/client.tsx index 75ad4032..0b70205c 100644 --- a/app/(app)/projects/[id]/audience/client.tsx +++ b/app/(app)/projects/[id]/audience/client.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { createProjectApiKey, + revealProjectApiKey, revokeProjectApiKey, } from "@/app/actions/audience"; @@ -15,6 +16,7 @@ type KeyRow = { last_used_at: string | null; revoked_at: string | null; created_at: string; + can_reveal: boolean; }; type RepoRow = { @@ -33,6 +35,29 @@ function fmt(iso: string | null): string { } } +function KeyValue({ value }: { value: string }) { + const [copied, setCopied] = useState(false); + return ( +
+ + {value} + + +
+ ); +} + export function AudienceKeysClient({ projectId, keys, @@ -44,6 +69,7 @@ export function AudienceKeysClient({ const [pending, start] = useTransition(); const [name, setName] = useState(""); const [justMinted, setJustMinted] = useState<{ name: string; key: string } | null>(null); + const [revealed, setRevealed] = useState>({}); const [error, setError] = useState(null); function submit(e: React.FormEvent) { @@ -70,6 +96,26 @@ export function AudienceKeysClient({ }); } + function reveal(keyId: string) { + setError(null); + start(async () => { + const r = await revealProjectApiKey({ projectId, keyId }); + if (!r.ok) { + setError(r.error); + return; + } + setRevealed((prev) => ({ ...prev, [keyId]: r.key })); + }); + } + + function hide(keyId: string) { + setRevealed((prev) => { + const next = { ...prev }; + delete next[keyId]; + return next; + }); + } + return (
@@ -97,13 +143,12 @@ export function AudienceKeysClient({ {error &&

{error}

} {justMinted && ( -
+

- Copy “{justMinted.name}” now — it won't be shown again. + “{justMinted.name}” is ready — you can reveal it again from this + list anytime.

- - {justMinted.key} - +
)} @@ -114,28 +159,54 @@ export function AudienceKeysClient({ {keys.map((key) => (
  • -
    - {key.name} - {key.key_prefix}… - {key.revoked_at ? ( - revoked - ) : ( - - last used {fmt(key.last_used_at)} - +
    +
    + {key.name} + {key.key_prefix}… + {key.revoked_at ? ( + revoked + ) : ( + + last used {fmt(key.last_used_at)} + + )} +
    + {!key.revoked_at && ( +
    + {key.can_reveal && + (revealed[key.id] ? ( + + ) : ( + + ))} + +
    )}
    - {!key.revoked_at && ( - + {revealed[key.id] && !key.revoked_at && ( + )}
  • ))} diff --git a/app/(app)/projects/[id]/audience/page.tsx b/app/(app)/projects/[id]/audience/page.tsx index e0910307..d2023119 100644 --- a/app/(app)/projects/[id]/audience/page.tsx +++ b/app/(app)/projects/[id]/audience/page.tsx @@ -18,6 +18,10 @@ type KeyRow = { created_at: string; }; +// Raw select row; the ciphertext itself never leaves the server — the +// client component only gets a can_reveal flag. +type KeySelectRow = KeyRow & { key_ciphertext: string | null }; + type RepoRow = { installation_id: number; repo_owner: string; @@ -64,7 +68,7 @@ export default async function ProjectAudiencePage({ await Promise.all([ supabase .from("project_api_keys") - .select("id, name, key_prefix, last_used_at, revoked_at, created_at") + .select("id, name, key_prefix, last_used_at, revoked_at, created_at, key_ciphertext") .eq("project_id", projectId) .order("created_at", { ascending: false }), supabase @@ -98,7 +102,9 @@ export default async function ProjectAudiencePage({ .eq("project_id", projectId), ]); - const keys = (keysRes.data ?? []) as KeyRow[]; + const keys = ((keysRes.data ?? []) as KeySelectRow[]).map( + ({ key_ciphertext, ...rest }) => ({ ...rest, can_reveal: !!key_ciphertext }), + ); const repos = (reposRes.data ?? []) as RepoRow[]; const runs = (runsRes.data ?? []) as RunRow[]; const lastBrowser = (lastBrowserRes.data?.[0]?.occurred_at as string | undefined) ?? null; @@ -216,8 +222,8 @@ export default async function ProjectAudiencePage({

    Server API keys

    Authenticate POST {siteUrl}/api/events with{" "} - Authorization: Bearer cpk_…. Keys are hashed at rest and - shown once at mint time. + Authorization: Bearer cpk_…. Keys are encrypted at rest + — reveal one again anytime from the list below.

    diff --git a/app/actions/audience.ts b/app/actions/audience.ts index 565658c8..12171ad1 100644 --- a/app/actions/audience.ts +++ b/app/actions/audience.ts @@ -1,11 +1,13 @@ "use server"; // Audience Hub server actions: per-project ingest key management. -// Keys authenticate POST /api/events; plaintext is shown once at mint time. +// Keys authenticate POST /api/events; the plaintext is stored encrypted +// (AES-256-GCM, lib/sp/vault.ts) so members can re-reveal it on demand. import { serviceClient } from "@/lib/supabase/service"; import { requireProjectAccess } from "@/lib/lx/currentSite"; import { mintProjectKey } from "@/lib/audience/projectKeys"; +import { decryptSecret } from "@/lib/sp/vault"; export async function createProjectApiKey(input: { projectId: string; @@ -31,6 +33,7 @@ export async function createProjectApiKey(input: { name, key_prefix: minted.prefix, key_hash: minted.hash, + key_ciphertext: minted.ciphertext, created_by: access.userId, }); if (error) return { ok: false, error: error.message }; @@ -38,6 +41,38 @@ export async function createProjectApiKey(input: { return { ok: true, key: minted.plaintext, prefix: minted.prefix }; } +export async function revealProjectApiKey(input: { + projectId: string; + keyId: string; +}): Promise<{ ok: true; key: string } | { ok: false; error: string }> { + const access = await requireProjectAccess(input.projectId); + if (!access.ok) return { ok: false, error: "Not found." }; + if (access.isViewer) return { ok: false, error: "Viewers can't reveal API keys." }; + + const svc = serviceClient(); + const { data: row, error } = await svc + .from("project_api_keys") + .select("key_ciphertext, revoked_at") + .eq("id", input.keyId) + .eq("project_id", input.projectId) + .maybeSingle(); + if (error) return { ok: false, error: error.message }; + if (!row) return { ok: false, error: "Not found." }; + if (row.revoked_at) return { ok: false, error: "Key has been revoked." }; + if (!row.key_ciphertext) { + return { + ok: false, + error: + "This key predates recoverable storage and can't be shown again — generate a new one.", + }; + } + try { + return { ok: true, key: decryptSecret(row.key_ciphertext as string) }; + } catch { + return { ok: false, error: "Could not decrypt this key." }; + } +} + export async function revokeProjectApiKey(input: { projectId: string; keyId: string; diff --git a/lib/audience/projectKeys.ts b/lib/audience/projectKeys.ts index 2005d7ad..0878cdf5 100644 --- a/lib/audience/projectKeys.ts +++ b/lib/audience/projectKeys.ts @@ -1,22 +1,26 @@ // Per-project server ingest keys for POST /api/events. // // Token shape: `cpk_` prefix + 43 base64url chars from 32 bytes of -// crypto-random. Mirrors lib/sp/apiToken.ts: we never persist the -// plaintext — only the display prefix and sha256(plaintext + pepper). -// SHA-256 is fine because the plaintext carries 256 bits of entropy; -// the shared SP_TOKEN_PEPPER means a DB leak alone is useless. +// crypto-random. Verification mirrors lib/sp/apiToken.ts: lookup is by +// sha256(plaintext + pepper), which is fine because the plaintext +// carries 256 bits of entropy; the shared SP_TOKEN_PEPPER means a DB +// leak alone is useless. We additionally persist the plaintext +// AES-256-GCM-encrypted under SOCIAL_VAULT_KEY (lib/sp/vault.ts) so +// owners can re-reveal a key from the dashboard instead of show-once. import crypto from "node:crypto"; import { env } from "@/lib/env"; +import { encryptSecret } from "@/lib/sp/vault"; import { serviceClient } from "@/lib/supabase/service"; const PREFIX = "cpk_"; const PREFIX_DISPLAY_LEN = 8; export type MintedProjectKey = { - plaintext: string; // shown to the user ONCE; never re-derivable. + plaintext: string; prefix: string; - hash: string; + hash: string; // verification lookup + ciphertext: string; // at-rest encrypted copy for later reveal }; export function mintProjectKey(): MintedProjectKey { @@ -31,6 +35,7 @@ export function mintProjectKey(): MintedProjectKey { plaintext, prefix: plaintext.slice(0, PREFIX_DISPLAY_LEN), hash: hashProjectKey(plaintext), + ciphertext: encryptSecret(plaintext), }; } diff --git a/supabase/migrations/20260612160000_project_api_keys_ciphertext.sql b/supabase/migrations/20260612160000_project_api_keys_ciphertext.sql new file mode 100644 index 00000000..8cdda322 --- /dev/null +++ b/supabase/migrations/20260612160000_project_api_keys_ciphertext.sql @@ -0,0 +1,7 @@ +-- Recoverable Audience ingest keys: store the key AES-256-GCM-encrypted +-- (lib/sp/vault.ts, SOCIAL_VAULT_KEY) alongside the verification hash so +-- owners can re-reveal a key instead of the old show-once flow. +-- Keys minted before this column exists stay null = not revealable. + +alter table public.project_api_keys + add column if not exists key_ciphertext text; diff --git a/tests/audience-hub.test.ts b/tests/audience-hub.test.ts index ea16b09d..1996fdfa 100644 --- a/tests/audience-hub.test.ts +++ b/tests/audience-hub.test.ts @@ -168,4 +168,12 @@ describe("project ingest keys", () => { expect(isProjectKeyShape("crp_not_a_project_key_aaaaaaaaaaaaaaaa")).toBe(false); expect(isProjectKeyShape("cpk_short")).toBe(false); }); + + it("stores a ciphertext that decrypts back to the plaintext", async () => { + const { mintProjectKey } = await import("@/lib/audience/projectKeys"); + const { decryptSecret } = await import("@/lib/sp/vault"); + const minted = mintProjectKey(); + expect(minted.ciphertext).not.toContain(minted.plaintext); + expect(decryptSecret(minted.ciphertext)).toBe(minted.plaintext); + }); }); diff --git a/tests/setup.ts b/tests/setup.ts index 13a7e3bd..f0d2e258 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -17,3 +17,5 @@ process.env.POSTHOG_INBOUND_WEBHOOK_SECRET ??= "stub_posthog_webhook_secret"; process.env.ANTHROPIC_API_KEY ??= "stub_anthropic"; process.env.RESEND_FROM ??= "Test "; process.env.SP_TOKEN_PEPPER ??= "stub_pepper"; +// AES-256-GCM vault key (lib/sp/vault.ts) — must decode to 32 bytes. +process.env.SOCIAL_VAULT_KEY ??= Buffer.alloc(32, 7).toString("base64");