diff --git a/examples/playground/src/App.tsx b/examples/playground/src/App.tsx index 8db5a0a4..e780e915 100644 --- a/examples/playground/src/App.tsx +++ b/examples/playground/src/App.tsx @@ -4,6 +4,7 @@ import type { BoundTreecrdtEngineLocal, MaterializationEvent } from "@treecrdt/i import { bytesToHex } from "@treecrdt/interface/ids"; import { createTreecrdtClient, detectOpfsSupport, type TreecrdtClient } from "@treecrdt/wa-sqlite"; +import { rotateDocPayloadKey } from "./auth"; import { hexToBytes16 } from "./sync-v0"; import { useVirtualizer } from "./virtualizer"; @@ -115,6 +116,7 @@ export default function App() { return false; }); const [online, setOnline] = useState(true); + const [payloadRotateBusy, setPayloadRotateBusy] = useState(false); const joinMode = typeof window !== "undefined" && new URLSearchParams(window.location.search).get("join") === "1"; @@ -153,6 +155,7 @@ export default function App() { const opfsSupport = useMemo(detectOpfsSupport, []); const { encryptPayloadBytes, + payloadKeyKid, payloadDisplayForNode, refreshDocPayloadKey, refreshPayloadsForNodes, @@ -190,6 +193,7 @@ export default function App() { showAuthAdvanced, setShowAuthAdvanced, authInfo, + setAuthInfo, authError, setAuthError, authBusy, @@ -254,9 +258,24 @@ export default function App() { syncServerUrl, syncTransportMode, onPeerIdentityChain, + payloadKeyKid, refreshDocPayloadKey, }); + const handleRotatePayloadKey = React.useCallback(async () => { + setPayloadRotateBusy(true); + setAuthError(null); + try { + const rotated = await rotateDocPayloadKey(docId); + await refreshDocPayloadKey(); + setAuthInfo(`Rotated payload key (${rotated.payloadKeyKid}). Share a new invite or grant with peers.`); + } catch (err) { + setAuthError(err instanceof Error ? err.message : String(err)); + } finally { + setPayloadRotateBusy(false); + } + }, [docId, refreshDocPayloadKey, setAuthError, setAuthInfo]); + const textEncoder = useMemo(() => new TextEncoder(), []); const { ops, recordOps, resetOps } = usePlaygroundOpsLog({ client, @@ -1098,6 +1117,9 @@ export default function App() { authTokenCount, authTokenScope, authTokenActions, + payloadKeyKid, + payloadRotateBusy, + rotatePayloadKey: handleRotatePayloadKey, nodeLabelForId, selfPeerId, revealIdentity, diff --git a/examples/playground/src/auth.ts b/examples/playground/src/auth.ts index a9be07cf..54670459 100644 --- a/examples/playground/src/auth.ts +++ b/examples/playground/src/auth.ts @@ -1,13 +1,17 @@ import { + createTreecrdtPayloadKeyringV1, generateTreecrdtDeviceWrapKeyV1, generateTreecrdtDocPayloadKeyV1, openTreecrdtDocPayloadKeyV1, openTreecrdtIssuerKeyV1, openTreecrdtLocalIdentityV1, + rotateTreecrdtPayloadKeyringV1, sealTreecrdtDocPayloadKeyV1, sealTreecrdtIssuerKeyV1, sealTreecrdtLocalIdentityV1, type TreecrdtDeviceWrapKeyV1, + type TreecrdtPayloadKeyringV1, + upsertTreecrdtPayloadKeyringKeyV1, } from "@treecrdt/crypto"; import { base64urlDecode, @@ -29,7 +33,8 @@ const DEVICE_WRAP_KEY_KEY = "treecrdt-playground-device-wrap-key:v1"; const ISSUER_PK_KEY_PREFIX = "treecrdt-playground-auth-issuer-pk:"; const ISSUER_SK_SEALED_KEY_PREFIX = "treecrdt-playground-auth-issuer-sk-sealed:"; const LOCAL_IDENTITY_SEALED_KEY_PREFIX = "treecrdt-playground-auth-local-identity-sealed:"; -const DOC_PAYLOAD_KEY_SEALED_KEY_PREFIX = "treecrdt-playground-e2ee-doc-payload-key-sealed:"; +const DOC_PAYLOAD_KEYRING_META_KEY_PREFIX = "treecrdt-playground-e2ee-doc-payload-keyring-meta:"; +const DOC_PAYLOAD_KEYRING_SEALED_KEY_PREFIX = "treecrdt-playground-e2ee-doc-payload-keyring-sealed:"; const IDENTITY_SK_SEALED_KEY = "treecrdt-playground-identity-sk-sealed:v1"; const DEVICE_SIGNING_SK_SEALED_KEY = "treecrdt-playground-device-signing-sk-sealed:v1"; const LOCAL_IDENTITY_LABEL_V1 = "replica"; @@ -155,37 +160,184 @@ export function clearDeviceWrapKey() { gsDel(DEVICE_WRAP_KEY_KEY); } -export async function loadOrCreateDocPayloadKeyB64(docId: string): Promise { +type StoredDocPayloadKeyringMetaV1 = { + v: 1; + activeKid: string; + kids: string[]; +}; + +function docPayloadKeyringMetaStorageKey(docId: string): string { + return `${DOC_PAYLOAD_KEYRING_META_KEY_PREFIX}${docId}`; +} + +function docPayloadKeyringEntryStorageKey(docId: string, kid: string): string { + return `${DOC_PAYLOAD_KEYRING_SEALED_KEY_PREFIX}${docId}:${kid}`; +} + +function parseDocPayloadKeyringMeta(raw: string): StoredDocPayloadKeyringMetaV1 | null { + try { + const parsed = JSON.parse(raw) as Partial; + if (parsed.v !== 1) return null; + if ( + typeof parsed.activeKid !== "string" || + parsed.activeKid.length === 0 || + parsed.activeKid !== parsed.activeKid.trim() + ) { + return null; + } + if ( + !Array.isArray(parsed.kids) || + parsed.kids.length === 0 || + parsed.kids.some((kid) => typeof kid !== "string" || kid.length === 0 || kid !== kid.trim()) + ) { + return null; + } + const kids = Array.from(new Set(parsed.kids)); + if (kids.length !== parsed.kids.length || !kids.includes(parsed.activeKid)) return null; + return { v: 1, activeKid: parsed.activeKid, kids }; + } catch { + return null; + } +} + +async function writeDocPayloadKeyringV1(opts: { + docId: string; + wrapKey: TreecrdtDeviceWrapKeyV1; + keyring: TreecrdtPayloadKeyringV1; +}) { + const metaKey = docPayloadKeyringMetaStorageKey(opts.docId); + const kids = Object.keys(opts.keyring.keys).sort(); + + for (const kid of kids) { + const payloadKey = opts.keyring.keys[kid]; + if (!payloadKey) throw new Error(`doc payload key is missing for key id ${kid}`); + const sealed = await sealTreecrdtDocPayloadKeyV1({ + wrapKey: opts.wrapKey, + docId: opts.docId, + payloadKey + }); + gsSet(docPayloadKeyringEntryStorageKey(opts.docId, kid), base64urlEncode(sealed)); + } + + const meta: StoredDocPayloadKeyringMetaV1 = { + v: 1, + activeKid: opts.keyring.activeKid, + kids + }; + gsSet(metaKey, JSON.stringify(meta)); +} + +async function readDocPayloadKeyringV1OrNull(opts: { + docId: string; + wrapKey: TreecrdtDeviceWrapKeyV1; +}): Promise { + const rawMeta = gsGet(docPayloadKeyringMetaStorageKey(opts.docId)); + if (!rawMeta) return null; + const meta = parseDocPayloadKeyringMeta(rawMeta); + if (!meta) throw new Error("doc payload keyring metadata is invalid; reset storage or restore a valid keyring"); + + const openedKeys = new Map(); + for (const kid of meta.kids) { + const sealedB64 = gsGet(docPayloadKeyringEntryStorageKey(opts.docId, kid)); + if (!sealedB64) throw new Error(`doc payload key blob is missing for key id ${kid}`); + const sealed = base64urlDecodeSafe(sealedB64); + if (!sealed) throw new Error(`doc payload key blob is not valid base64url for key id ${kid}`); + const opened = await openTreecrdtDocPayloadKeyV1({ + wrapKey: opts.wrapKey, + docId: opts.docId, + sealed + }); + openedKeys.set(kid, opened.payloadKey); + } + + const firstKid = meta.kids[0]!; + let keyring = createTreecrdtPayloadKeyringV1({ + activeKid: firstKid, + payloadKey: openedKeys.get(firstKid)! + }); + for (const kid of meta.kids.slice(1)) { + keyring = upsertTreecrdtPayloadKeyringKeyV1({ + keyring, + kid, + payloadKey: openedKeys.get(kid)! + }); + } + return upsertTreecrdtPayloadKeyringKeyV1({ + keyring, + kid: meta.activeKid, + payloadKey: openedKeys.get(meta.activeKid)!, + makeActive: true + }); +} + +async function loadOrCreateDocPayloadKeyringV1Unlocked(opts: { + docId: string; + wrapKey: TreecrdtDeviceWrapKeyV1; +}): Promise { + const existing = await readDocPayloadKeyringV1OrNull(opts); + if (existing) return existing; + + const { payloadKey } = generateTreecrdtDocPayloadKeyV1({ docId: opts.docId }); + const created = createTreecrdtPayloadKeyringV1({ payloadKey }); + await writeDocPayloadKeyringV1({ ...opts, keyring: created }); + return created; +} + +export async function loadOrCreateDocPayloadKeyringV1(docId: string): Promise { if (!docId || docId.trim().length === 0) throw new Error("docId must not be empty"); return await withGlobalLock(`treecrdt-playground-doc-payload-key:${docId}`, async () => { const wrapKey = await requireDeviceWrapKeyBytes(); - const sealedKey = `${DOC_PAYLOAD_KEY_SEALED_KEY_PREFIX}${docId}`; + return await loadOrCreateDocPayloadKeyringV1Unlocked({ docId, wrapKey }); + }); +} - if (!gsGet(sealedKey)) { - const { payloadKey } = generateTreecrdtDocPayloadKeyV1({ docId }); - const sealed = await sealTreecrdtDocPayloadKeyV1({ wrapKey, docId, payloadKey }); - gsSet(sealedKey, base64urlEncode(sealed)); - } +export async function getDocPayloadActiveKeyInfoB64(docId: string): Promise<{ + payloadKeyB64: string; + payloadKeyKid: string; +}> { + const keyring = await loadOrCreateDocPayloadKeyringV1(docId); + const payloadKey = keyring.keys[keyring.activeKid]; + if (!payloadKey) throw new Error("doc payload active key is missing"); + return { + payloadKeyB64: base64urlEncode(payloadKey), + payloadKeyKid: keyring.activeKid + }; +} - const sealedB64 = gsGet(sealedKey); - if (!sealedB64) throw new Error("doc payload key is missing after initialization"); - const sealedBytes = base64urlDecodeSafe(sealedB64); - if (!sealedBytes) throw new Error("doc payload key blob is not valid base64url"); +export async function rotateDocPayloadKey(docId: string): Promise<{ payloadKeyKid: string }> { + if (!docId || docId.trim().length === 0) throw new Error("docId must not be empty"); - const opened = await openTreecrdtDocPayloadKeyV1({ wrapKey, docId, sealed: sealedBytes }); - return base64urlEncode(opened.payloadKey); + return await withGlobalLock(`treecrdt-playground-doc-payload-key:${docId}`, async () => { + const wrapKey = await requireDeviceWrapKeyBytes(); + const current = await loadOrCreateDocPayloadKeyringV1Unlocked({ docId, wrapKey }); + const rotated = rotateTreecrdtPayloadKeyringV1({ keyring: current }); + await writeDocPayloadKeyringV1({ docId, wrapKey, keyring: rotated.keyring }); + return { payloadKeyKid: rotated.rotatedKid }; }); } -export async function saveDocPayloadKeyB64(docId: string, payloadKeyB64: string) { +export async function saveDocPayloadKeyB64(docId: string, payloadKeyB64: string, payloadKeyKid: string) { if (!docId || docId.trim().length === 0) throw new Error("docId must not be empty"); const payloadKey = base64urlDecodeSafe(payloadKeyB64.trim()); if (!payloadKey || payloadKey.length !== 32) throw new Error("payload key must be a base64url-encoded 32-byte value"); + if (!payloadKeyKid || payloadKeyKid !== payloadKeyKid.trim()) { + throw new Error("payload key id must be non-empty and canonical"); + } - const wrapKey = await requireDeviceWrapKeyBytes(); - const sealed = await sealTreecrdtDocPayloadKeyV1({ wrapKey, docId, payloadKey }); - gsSet(`${DOC_PAYLOAD_KEY_SEALED_KEY_PREFIX}${docId}`, base64urlEncode(sealed)); + await withGlobalLock(`treecrdt-playground-doc-payload-key:${docId}`, async () => { + const wrapKey = await requireDeviceWrapKeyBytes(); + const current = await readDocPayloadKeyringV1OrNull({ docId, wrapKey }); + const keyring = current + ? upsertTreecrdtPayloadKeyringKeyV1({ + keyring: current, + kid: payloadKeyKid, + payloadKey, + makeActive: true + }) + : createTreecrdtPayloadKeyringV1({ activeKid: payloadKeyKid, payloadKey }); + await writeDocPayloadKeyringV1({ docId, wrapKey, keyring }); + }); } export function initialAuthEnabled(): boolean { @@ -486,7 +638,8 @@ export type InvitePayloadV1 = { issuerPkB64: string; subjectSkB64: string; tokenB64: string; - payloadKeyB64?: string; + payloadKeyB64: string; + payloadKeyKid: string; }; export function encodeInvitePayload(payload: InvitePayloadV1): string { @@ -505,8 +658,7 @@ export function decodeInvitePayload(inviteB64: string): InvitePayloadV1 { if (!parsed.issuerPkB64 || typeof parsed.issuerPkB64 !== "string") throw new Error("invite issuerPkB64 missing"); if (!parsed.subjectSkB64 || typeof parsed.subjectSkB64 !== "string") throw new Error("invite subjectSkB64 missing"); if (!parsed.tokenB64 || typeof parsed.tokenB64 !== "string") throw new Error("invite tokenB64 missing"); - if (parsed.payloadKeyB64 !== undefined && typeof parsed.payloadKeyB64 !== "string") { - throw new Error("invite payloadKeyB64 must be a string if present"); - } + if (!parsed.payloadKeyB64 || typeof parsed.payloadKeyB64 !== "string") throw new Error("invite payloadKeyB64 missing"); + if (!parsed.payloadKeyKid || typeof parsed.payloadKeyKid !== "string") throw new Error("invite payloadKeyKid missing"); return parsed as InvitePayloadV1; } diff --git a/examples/playground/src/playground/components/SharingAuthPanel.tsx b/examples/playground/src/playground/components/SharingAuthPanel.tsx index 8b9555af..314febb6 100644 --- a/examples/playground/src/playground/components/SharingAuthPanel.tsx +++ b/examples/playground/src/playground/components/SharingAuthPanel.tsx @@ -31,6 +31,9 @@ type SharingAuthPanelProps = { authTokenCount: number; authTokenScope: PlaygroundAuthApi["authTokenScope"]; authTokenActions: PlaygroundAuthApi["authTokenActions"]; + payloadKeyKid: string | null; + payloadRotateBusy: boolean; + rotatePayloadKey: () => Promise; nodeLabelForId: (id: string) => string; selfPeerId: string | null; @@ -169,6 +172,9 @@ export function SharingAuthPanel(props: SharingAuthPanelProps) { authTokenCount, authTokenScope, authTokenActions, + payloadKeyKid, + payloadRotateBusy, + rotatePayloadKey, nodeLabelForId, selfPeerId, openMintingPeerTab, @@ -323,6 +329,33 @@ export function SharingAuthPanel(props: SharingAuthPanelProps) { )} +
+
+
+
Payload key
+
+ Active key id: {payloadKeyKid ?? "-"} +
+
+ Rotation affects future writes only. Re-share an invite or grant to distribute the new key. +
+
+ +
+
+ {showAuthAdvanced && ( <>
diff --git a/examples/playground/src/playground/hooks/usePlaygroundAuth.ts b/examples/playground/src/playground/hooks/usePlaygroundAuth.ts index b1fcca7d..a85fe028 100644 --- a/examples/playground/src/playground/hooks/usePlaygroundAuth.ts +++ b/examples/playground/src/playground/hooks/usePlaygroundAuth.ts @@ -11,6 +11,7 @@ import { issueTreecrdtDelegatedCapabilityTokenV1, type TreecrdtCapabilityTokenV1, } from "@treecrdt/auth"; +import type { TreecrdtPayloadKeyringV1 } from "@treecrdt/crypto"; import { createTreecrdtSqliteSyncDiagnostics } from "@treecrdt/sync-sqlite"; import { createTreecrdtSqliteAuthApi } from "@treecrdt/sync-sqlite/auth"; import type { SyncAuth } from "@treecrdt/sync-protocol"; @@ -23,9 +24,9 @@ import { encodeInvitePayload, generateEd25519KeyPair, deriveEd25519PublicKey, + getDocPayloadActiveKeyInfoB64, initialAuthEnabled, initialRevealIdentity, - loadOrCreateDocPayloadKeyB64, loadAuthMaterial, persistRevealIdentity, persistAuthEnabled, @@ -198,6 +199,7 @@ export type PlaygroundAuthApi = { showAuthAdvanced: boolean; setShowAuthAdvanced: React.Dispatch>; authInfo: string | null; + setAuthInfo: React.Dispatch>; authError: string | null; setAuthError: React.Dispatch>; authBusy: boolean; @@ -282,15 +284,24 @@ type UsePlaygroundAuthOptions = { devicePublicKey: Uint8Array; replicaPublicKey: Uint8Array; }) => void; + payloadKeyKid: string | null; /** - * App-owned doc payload key refresher (used by invite/grant import flows). - * This keeps crypto state (decrypt/encrypt) in App while auth UI is extracted. + * Payload-hook-owned keyring refresher used after invite/grant imports. */ - refreshDocPayloadKey: () => Promise; + refreshDocPayloadKey: () => Promise; }; export function usePlaygroundAuth(opts: UsePlaygroundAuthOptions): PlaygroundAuthApi { - const { docId, joinMode, client, syncServerUrl, syncTransportMode, onPeerIdentityChain, refreshDocPayloadKey } = opts; + const { + docId, + joinMode, + client, + syncServerUrl, + syncTransportMode, + onPeerIdentityChain, + payloadKeyKid, + refreshDocPayloadKey, + } = opts; const authApi = useMemo( () => client ? createTreecrdtSqliteAuthApi({ runner: client.runner, docId: client.docId }) : null, @@ -606,10 +617,8 @@ export function usePlaygroundAuth(opts: UsePlaygroundAuthOptions): PlaygroundAut throw new Error(`invite doc mismatch: got ${payload.docId}, expected ${docId}`); } - if (payload.payloadKeyB64) { - await saveDocPayloadKeyB64(docId, payload.payloadKeyB64); - await refreshDocPayloadKey(); - } + await saveDocPayloadKeyB64(docId, payload.payloadKeyB64, payload.payloadKeyKid); + await refreshDocPayloadKey(); await saveIssuerKeys(docId, payload.issuerPkB64); @@ -840,6 +849,7 @@ export function usePlaygroundAuth(opts: UsePlaygroundAuthOptions): PlaygroundAut actions: [...actions].sort(), maxDepth: maxDepth ?? null, excludeNodeIds: [...excludeNodeIds].sort(), + payloadKeyKid, }); }; @@ -950,7 +960,7 @@ export function usePlaygroundAuth(opts: UsePlaygroundAuthOptions): PlaygroundAut issuerPkB64, subjectSkB64: base64urlEncode(subjectSk), tokenB64: base64urlEncode(tokenBytes), - payloadKeyB64: await loadOrCreateDocPayloadKeyB64(docId), + ...(await getDocPayloadActiveKeyInfoB64(docId)), }); } if (!issuerSkB64 || !issuerPkB64) { @@ -988,7 +998,7 @@ export function usePlaygroundAuth(opts: UsePlaygroundAuthOptions): PlaygroundAut issuerPkB64, subjectSkB64: base64urlEncode(subjectSk), tokenB64: base64urlEncode(tokenBytes), - payloadKeyB64: await loadOrCreateDocPayloadKeyB64(docId), + ...(await getDocPayloadActiveKeyInfoB64(docId)), }); }; @@ -1059,7 +1069,8 @@ export function usePlaygroundAuth(opts: UsePlaygroundAuthOptions): PlaygroundAut (grant: AuthGrantMessageV1) => { const issuerPkB64 = grant.issuer_pk_b64; const tokenB64 = grant.token_b64; - const payloadKeyB64 = typeof grant.payload_key_b64 === "string" ? grant.payload_key_b64 : null; + const payloadKeyB64 = grant.payload_key_b64; + const payloadKeyKid = grant.payload_key_kid; const supersededTokenIds = Array.isArray(grant.supersedes_token_ids_hex) ? grant.supersedes_token_ids_hex .map((id) => normalizeTokenIdHex(id)) @@ -1073,10 +1084,8 @@ export function usePlaygroundAuth(opts: UsePlaygroundAuthOptions): PlaygroundAut try { await saveIssuerKeys(docId, issuerPkB64); - if (payloadKeyB64) { - await saveDocPayloadKeyB64(docId, payloadKeyB64); - await refreshDocPayloadKey(); - } + await saveDocPayloadKeyB64(docId, payloadKeyB64, payloadKeyKid); + await refreshDocPayloadKey(); const current = await loadAuthMaterial(docId); if (!current.localPkB64 || !current.localSkB64) { @@ -1233,6 +1242,7 @@ export function usePlaygroundAuth(opts: UsePlaygroundAuthOptions): PlaygroundAut }); } + const { payloadKeyB64, payloadKeyKid } = await getDocPayloadActiveKeyInfoB64(docId); const msg: AuthGrantMessageV1 = { t: "auth_grant_v1", doc_id: docId, @@ -1240,7 +1250,8 @@ export function usePlaygroundAuth(opts: UsePlaygroundAuthOptions): PlaygroundAut issuer_pk_b64: issuerPkB64, token_b64: base64urlEncode(tokenBytes), ...(supersedesTokenIds.length > 0 ? { supersedes_token_ids_hex: supersedesTokenIds } : {}), - payload_key_b64: await loadOrCreateDocPayloadKeyB64(docId), + payload_key_b64: payloadKeyB64, + payload_key_kid: payloadKeyKid, from_peer_id: selfPeerId, ts: Date.now(), }; @@ -1321,6 +1332,7 @@ export function usePlaygroundAuth(opts: UsePlaygroundAuthOptions): PlaygroundAut showAuthAdvanced, setShowAuthAdvanced, authInfo, + setAuthInfo, authError, setAuthError, authBusy, diff --git a/examples/playground/src/playground/hooks/usePlaygroundPayloads.ts b/examples/playground/src/playground/hooks/usePlaygroundPayloads.ts index 1b22045c..f47850f7 100644 --- a/examples/playground/src/playground/hooks/usePlaygroundPayloads.ts +++ b/examples/playground/src/playground/hooks/usePlaygroundPayloads.ts @@ -1,9 +1,12 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { base64urlDecode } from '@treecrdt/auth'; -import { encryptTreecrdtPayloadV1, maybeDecryptTreecrdtPayloadV1 } from '@treecrdt/crypto'; +import { + encryptTreecrdtPayloadWithKeyring, + maybeDecryptTreecrdtPayloadWithKeyring, + type TreecrdtPayloadKeyringV1, +} from '@treecrdt/crypto'; import type { TreecrdtClient } from '@treecrdt/wa-sqlite'; -import { loadOrCreateDocPayloadKeyB64 } from '../../auth'; +import { loadOrCreateDocPayloadKeyringV1 } from '../../auth'; import { ROOT_ID } from '../constants'; type PayloadRecord = { @@ -34,8 +37,9 @@ export function usePlaygroundPayloads(opts: { }) { const { docId, setError } = opts; const [payloadVersion, setPayloadVersion] = useState(0); + const [payloadKeyKid, setPayloadKeyKid] = useState(null); const textDecoder = useMemo(() => new TextDecoder(), []); - const docPayloadKeyRef = useRef(null); + const docPayloadKeyringRef = useRef(null); const payloadByNodeRef = useRef>(new Map()); const payloadEventQueueRef = useRef>(Promise.resolve()); @@ -46,13 +50,15 @@ export function usePlaygroundPayloads(opts: { }, []); const refreshDocPayloadKey = React.useCallback(async () => { - const keyB64 = await loadOrCreateDocPayloadKeyB64(docId); - docPayloadKeyRef.current = base64urlDecode(keyB64); - return docPayloadKeyRef.current; + const keyring = await loadOrCreateDocPayloadKeyringV1(docId); + docPayloadKeyringRef.current = keyring; + setPayloadKeyKid(keyring.activeKid); + return keyring; }, [docId]); useEffect(() => { - docPayloadKeyRef.current = null; + docPayloadKeyringRef.current = null; + setPayloadKeyKid(null); resetPayloadCache(); let cancelled = false; void (async () => { @@ -68,29 +74,28 @@ export function usePlaygroundPayloads(opts: { }; }, [refreshDocPayloadKey, resetPayloadCache, setError]); - const requireDocPayloadKey = React.useCallback(async (): Promise => { - if (docPayloadKeyRef.current) return docPayloadKeyRef.current; - const next = await refreshDocPayloadKey(); - if (!next) throw new Error('doc payload key is missing'); - return next; + const requireDocPayloadKeyring = React.useCallback(async (): Promise => { + if (docPayloadKeyringRef.current) return docPayloadKeyringRef.current; + return await refreshDocPayloadKey(); }, [refreshDocPayloadKey]); const decodePayloadRecord = React.useCallback( async (raw: Uint8Array | null): Promise => { if (raw === null) return { payload: null, encrypted: false }; try { - const key = await requireDocPayloadKey(); - const res = await maybeDecryptTreecrdtPayloadV1({ + const keyring = await requireDocPayloadKeyring(); + const result = await maybeDecryptTreecrdtPayloadWithKeyring({ docId, - payloadKey: key, + keyring, bytes: raw, }); - return { payload: res.plaintext, encrypted: res.encrypted }; + if (result.failure !== null) return { payload: null, encrypted: true }; + return { payload: result.plaintext, encrypted: result.encrypted }; } catch { return { payload: null, encrypted: true }; } }, - [docId, requireDocPayloadKey], + [docId, requireDocPayloadKeyring], ); const applyPayloadUpdatesFromRaw = React.useCallback( @@ -137,10 +142,10 @@ export function usePlaygroundPayloads(opts: { const encryptPayloadBytes = React.useCallback( async (payload: Uint8Array | null): Promise => { if (payload === null) return null; - const key = await requireDocPayloadKey(); - return await encryptTreecrdtPayloadV1({ docId, payloadKey: key, plaintext: payload }); + const keyring = await requireDocPayloadKeyring(); + return await encryptTreecrdtPayloadWithKeyring({ docId, keyring, plaintext: payload }); }, - [docId, requireDocPayloadKey], + [docId, requireDocPayloadKeyring], ); const payloadDisplayForNode = React.useCallback( @@ -159,6 +164,7 @@ export function usePlaygroundPayloads(opts: { return { encryptPayloadBytes, + payloadKeyKid, payloadDisplayForNode, refreshDocPayloadKey, refreshPayloadsForNodes, diff --git a/examples/playground/src/playground/hooks/usePlaygroundSync.ts b/examples/playground/src/playground/hooks/usePlaygroundSync.ts index cf29d297..fb9983dc 100644 --- a/examples/playground/src/playground/hooks/usePlaygroundSync.ts +++ b/examples/playground/src/playground/hooks/usePlaygroundSync.ts @@ -731,6 +731,8 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn if (typeof grant.to_replica_pk_hex !== 'string') return; if (typeof grant.issuer_pk_b64 !== 'string') return; if (typeof grant.token_b64 !== 'string') return; + if (typeof grant.payload_key_b64 !== 'string') return; + if (typeof grant.payload_key_kid !== 'string') return; const localReplicaHex = selfPeerId; if (!localReplicaHex) return; diff --git a/examples/playground/src/sync-v0.ts b/examples/playground/src/sync-v0.ts index 33aadf44..d0f0b6bc 100644 --- a/examples/playground/src/sync-v0.ts +++ b/examples/playground/src/sync-v0.ts @@ -7,7 +7,8 @@ export type AuthGrantMessageV1 = { issuer_pk_b64: string; token_b64: string; supersedes_token_ids_hex?: string[]; - payload_key_b64?: string; + payload_key_b64: string; + payload_key_kid: string; from_peer_id: string; ts: number; };