diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ac8a29b8..c9322e41 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,19 +1,10 @@ # Architecture -🚧 Work in progress 🚧 +TreeCRDT is organized as a multi-runtime workspace with one shared model for operations, access control, and sync. The Rust crates provide the core engine and extension targets, while the TypeScript packages provide transport, auth, runtime adapters, and browser/node integration. The goal is to keep behavior consistent across native, WASM, and SQLite-backed deployments without forking protocol or data model semantics. -## Goals -- Kleppmann Tree CRDT in Rust with clean traits for storage/indexing/access control. -- Runs native and WASM; embeddable as a SQLite/wa-sqlite extension. -- TypeScript interface stays stable across native/WASM/SQLite builds. -- Strong tests (unit/property/integration) and benchmarks (Rust + TS/WASM). +## Package Map -## Package map - -This diagram is meant to answer, "What depends on what in this repo?". - -Arrow direction is **depends on / uses**. -Solid arrows are runtime dependencies. Dotted arrows are build time, dev, or test connections. +This map answers one question: which packages depend on which others. Arrow direction means "depends on / uses." Solid edges are runtime dependencies, and dotted edges are build, dev, or test-time relationships. ```mermaid flowchart TD @@ -69,26 +60,33 @@ flowchart TD sqlite_node -. conformance tests .-> conformance ``` -## Core CRDT shape -- Operation log with `(OperationId { replica, counter }, lamport, kind)`; kinds: insert/move/delete/tombstone. -- Deterministic application rules following Kleppmann Tree CRDT; extend to support alternative tombstone semantics if needed (per linked proposal). -- Access control hooks applied before state mutation. -- Partial sync support via subtree filters + index provider for efficient fetch. - -## Trait contracts (Rust) -- `Clock`: lamport/HLC pluggable (`LamportClock` provided). -- `AccessControl`: guards apply/read. -- `Storage`: append operations, load since lamport, latest_lamport. -- `IndexProvider`: optional acceleration for subtree queries and existence checks. -- These traits are the seam for SQLite/wa-sqlite implementations; extension just implements them over tables/indexes. - -## WASM + TypeScript bindings -- `treecrdt-wasm`: wasm-bindgen surface mapping to `@treecrdt/interface`. -- `@treecrdt/interface`: TS types for operations, storage adapters, sync protocol, access control. -- Provide both in-memory adapter and SQLite-backed adapter (via wa-sqlite) to satisfy the interface. - -## Sync engine concept -- Transport-agnostic: push/pull batches with causal metadata + optional subtree filters. -- Progress hooks for UI, resumable checkpoints via lamport/head. -- Access control enforced at responder using subtree filters and ACL callbacks. -- Draft protocol: [`sync/v0.md`](sync/v0.md) +The diagram is intentionally scoped to library/runtime packages in this repository. Example applications such as Playground are left out to keep the dependency graph readable. `@treecrdt/crypto` is currently used in app/example flows for payload encryption and is not part of the runtime dependency chain between `@treecrdt/auth` and `@treecrdt/sync`. + +## Core Data Model + +At the center is an append-only operation log keyed by `OperationId { replica, counter }`, with Lamport ordering metadata and a kind (`insert`, `move`, `delete`, or `tombstone`). The implementation follows deterministic Tree CRDT rules so replicas converge from the same operation set, regardless of receive order. Access checks are applied before mutation, and partial replication is supported through subtree filters plus index-assisted lookups. + +## Rust Integration Seams + +The Rust core is designed around trait boundaries so the same CRDT logic can run over different storage/index backends: + +| Trait | Responsibility | +| --- | --- | +| `Clock` | Provides Lamport/HLC progression (`LamportClock` is included). | +| `AccessControl` | Authorizes apply/read paths. | +| `Storage` | Persists and loads operations (`append`, `load since lamport`, `latest_lamport`). | +| `IndexProvider` | Optional acceleration for subtree queries and existence checks. | + +`treecrdt-sqlite-ext` and related adapters implement these seams over SQLite tables and indexes instead of re-implementing CRDT rules. + +## WASM and TypeScript Boundary + +`treecrdt-wasm` exposes the Rust engine through `wasm-bindgen`, and `@treecrdt/interface` defines the shared TypeScript contract used by adapters and sync code. This keeps browser and node clients aligned on operation shape, storage adapter behavior, and sync/auth boundaries, whether the backing store is in-memory or wa-sqlite-based. + +## Sync Model + +The sync layer is transport-agnostic and exchanges operation batches with causal progress metadata. Subtree filters limit scope when needed, and responders enforce authorization at read time using capability checks plus filter constraints. Progress/checkpoint state is structured so sessions can resume without replaying the full history. The wire-level draft is documented in [`sync/v0.md`](sync/v0.md). + +## Quality and Performance + +The repository treats conformance and benchmarking as first-class architecture concerns. Rust and TypeScript tests cover unit and integration behavior, while `@treecrdt/sqlite-conformance` and `@treecrdt/benchmark` are used to validate correctness under realistic adapter and sync conditions, including browser/WASM paths. diff --git a/examples/playground/src/App.tsx b/examples/playground/src/App.tsx index dadd2f82..ea3074a6 100644 --- a/examples/playground/src/App.tsx +++ b/examples/playground/src/App.tsx @@ -3,10 +3,13 @@ import { type Operation, type OperationKind } from "@treecrdt/interface"; import { bytesToHex } from "@treecrdt/interface/ids"; import { createTreecrdtClient, type TreecrdtClient } from "@treecrdt/wa-sqlite/client"; import { detectOpfsSupport } from "@treecrdt/wa-sqlite/opfs"; -import { base64urlDecode } from "@treecrdt/auth"; -import { encryptTreecrdtPayloadV1, maybeDecryptTreecrdtPayloadV1 } from "@treecrdt/crypto"; +import { + encryptTreecrdtPayloadWithKeyringV1, + maybeDecryptTreecrdtPayloadWithKeyringV1, + type TreecrdtPayloadKeyringV1, +} from "@treecrdt/crypto"; -import { loadOrCreateDocPayloadKeyB64 } from "./auth"; +import { loadOrCreateDocPayloadKeyringV1, rotateDocPayloadKeyB64 } from "./auth"; import { hexToBytes16 } from "./sync-v0"; import { useVirtualizer } from "./virtualizer"; @@ -80,6 +83,8 @@ export default function App() { }); const [online, setOnline] = useState(true); const [payloadVersion, setPayloadVersion] = useState(0); + const [payloadRotateBusy, setPayloadRotateBusy] = useState(false); + const [payloadKeyKid, setPayloadKeyKid] = useState(null); const joinMode = typeof window !== "undefined" && new URLSearchParams(window.location.search).get("join") === "1"; @@ -96,11 +101,12 @@ export default function App() { const counterRef = useRef(0); const lamportRef = useRef(0); const opfsSupport = useMemo(detectOpfsSupport, []); - const docPayloadKeyRef = useRef(null); + const docPayloadKeyringRef = useRef(null); 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.keys[keyring.activeKid] ?? null; }, [docId]); const { @@ -115,6 +121,7 @@ export default function App() { showAuthAdvanced, setShowAuthAdvanced, authInfo, + setAuthInfo, authError, setAuthError, authBusy, @@ -215,13 +222,15 @@ export default function App() { ); useEffect(() => { - docPayloadKeyRef.current = null; + docPayloadKeyringRef.current = null; + setPayloadKeyKid(null); let cancelled = false; void (async () => { try { - const keyB64 = await loadOrCreateDocPayloadKeyB64(docId); + const keyring = await loadOrCreateDocPayloadKeyringV1(docId); if (cancelled) return; - docPayloadKeyRef.current = base64urlDecode(keyB64); + docPayloadKeyringRef.current = keyring; + setPayloadKeyKid(keyring.activeKid); } catch (err) { if (cancelled) return; setError(err instanceof Error ? err.message : String(err)); @@ -239,12 +248,13 @@ export default function App() { const payloadByNodeRef = useRef>(new Map()); - 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"); + const requireDocPayloadKeyring = React.useCallback(async (): Promise => { + if (docPayloadKeyringRef.current) return docPayloadKeyringRef.current; + const next = await loadOrCreateDocPayloadKeyringV1(docId); + docPayloadKeyringRef.current = next; + setPayloadKeyKid(next.activeKid); return next; - }, [refreshDocPayloadKey]); + }, [docId]); const ingestPayloadOps = React.useCallback( async (incoming: Operation[]) => { @@ -275,9 +285,13 @@ export default function App() { } try { - const key = await requireDocPayloadKey(); - const res = await maybeDecryptTreecrdtPayloadV1({ docId, payloadKey: key, bytes: payload }); - payloads.set(node, { ...meta, payload: res.plaintext, encrypted: res.encrypted }); + const keyring = await requireDocPayloadKeyring(); + const res = await maybeDecryptTreecrdtPayloadWithKeyringV1({ docId, keyring, bytes: payload }); + if (res.encrypted && res.keyMissing) { + payloads.set(node, { ...meta, payload: null, encrypted: true }); + } else { + payloads.set(node, { ...meta, payload: res.plaintext, encrypted: res.encrypted }); + } changed = true; } catch { payloads.set(node, { ...meta, payload: null, encrypted: true }); @@ -287,17 +301,31 @@ export default function App() { if (changed) setPayloadVersion((v) => v + 1); }, - [docId, replicaKey, requireDocPayloadKey] + [docId, replicaKey, requireDocPayloadKeyring] ); 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 encryptTreecrdtPayloadWithKeyringV1({ docId, keyring, plaintext: payload }); }, - [docId, requireDocPayloadKey] + [docId, requireDocPayloadKeyring] ); + + const handleRotatePayloadKey = React.useCallback(async () => { + setPayloadRotateBusy(true); + setAuthError(null); + try { + const rotated = await rotateDocPayloadKeyB64(docId); + await refreshDocPayloadKey(); + setAuthInfo(`Rotated payload key (${rotated.payloadKeyKid}). Share a new invite/grant for peers.`); + } catch (err) { + setAuthError(err instanceof Error ? err.message : String(err)); + } finally { + setPayloadRotateBusy(false); + } + }, [docId, refreshDocPayloadKey, setAuthError, setAuthInfo]); const knownOpsRef = useRef>(new Set()); const treeStateRef = useRef(treeState); @@ -1176,6 +1204,9 @@ export default function App() { authTokenCount, authTokenScope, authTokenActions, + payloadKeyKid, + payloadRotateBusy, + rotatePayloadKey: handleRotatePayloadKey, authScopeSummary, authScopeTitle, authSummaryBadges, diff --git a/examples/playground/src/auth.ts b/examples/playground/src/auth.ts index 27bad174..b4f4de48 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,189 @@ 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 | null): StoredDocPayloadKeyringMetaV1 | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as Partial; + if (parsed.v !== 1) return null; + if (typeof parsed.activeKid !== "string" || parsed.activeKid.trim().length === 0) return null; + if (!Array.isArray(parsed.kids) || parsed.kids.some((kid) => typeof kid !== "string" || kid.trim().length === 0)) return null; + return { v: 1, activeKid: parsed.activeKid, kids: parsed.kids }; + } catch { + return null; + } +} + +async function writeDocPayloadKeyringV1(opts: { + docId: string; + wrapKey: TreecrdtDeviceWrapKeyV1; + keyring: TreecrdtPayloadKeyringV1; +}) { + const metaKey = docPayloadKeyringMetaStorageKey(opts.docId); + const prevMeta = parseDocPayloadKeyringMeta(gsGet(metaKey)); + + const kids = Object.keys(opts.keyring.keys).sort(); + for (const kid of kids) { + const payloadKey = opts.keyring.keys[kid]; + if (!payloadKey) continue; + const sealed = await sealTreecrdtDocPayloadKeyV1({ + wrapKey: opts.wrapKey, + docId: opts.docId, + payloadKey, + }); + gsSet(docPayloadKeyringEntryStorageKey(opts.docId, kid), base64urlEncode(sealed)); + } + + if (prevMeta) { + const nextKidSet = new Set(kids); + for (const oldKid of prevMeta.kids) { + if (!nextKidSet.has(oldKid)) gsDel(docPayloadKeyringEntryStorageKey(opts.docId, oldKid)); + } + } + + const nextMeta: StoredDocPayloadKeyringMetaV1 = { + v: 1, + activeKid: opts.keyring.activeKid, + kids, + }; + gsSet(metaKey, JSON.stringify(nextMeta)); +} + +async function readDocPayloadKeyringV1OrNull(opts: { + docId: string; + wrapKey: TreecrdtDeviceWrapKeyV1; +}): Promise { + const meta = parseDocPayloadKeyringMeta(gsGet(docPayloadKeyringMetaStorageKey(opts.docId))); + if (meta) { + const decodedKeys: Record = {}; + for (const kid of meta.kids) { + const sealedB64 = gsGet(docPayloadKeyringEntryStorageKey(opts.docId, kid)); + if (!sealedB64) continue; + const sealedBytes = base64urlDecodeSafe(sealedB64); + if (!sealedBytes) continue; + const opened = await openTreecrdtDocPayloadKeyV1({ wrapKey: opts.wrapKey, docId: opts.docId, sealed: sealedBytes }); + decodedKeys[kid] = opened.payloadKey; + } + + const decodedKids = Object.keys(decodedKeys); + if (decodedKids.length > 0) { + const initialKid = decodedKids[0]!; + let keyring = createTreecrdtPayloadKeyringV1({ + payloadKey: decodedKeys[initialKid]!, + activeKid: initialKid, + }); + for (const kid of decodedKids) { + if (kid === initialKid) continue; + keyring = upsertTreecrdtPayloadKeyringKeyV1({ + keyring, + kid, + payloadKey: decodedKeys[kid]!, + }); + } + if (decodedKeys[meta.activeKid]) { + keyring = upsertTreecrdtPayloadKeyringKeyV1({ + keyring, + kid: meta.activeKid, + payloadKey: decodedKeys[meta.activeKid]!, + makeActive: true, + }); + } + return keyring; + } + } + return null; +} + +async function loadOrCreateDocPayloadKeyringV1Unlocked(opts: { + docId: string; + wrapKey: TreecrdtDeviceWrapKeyV1; +}): Promise { + const existing = await readDocPayloadKeyringV1OrNull(opts); + if (existing) { + await writeDocPayloadKeyringV1({ docId: opts.docId, wrapKey: opts.wrapKey, keyring: existing }); + return existing; + } + + const { payloadKey } = generateTreecrdtDocPayloadKeyV1({ docId: opts.docId }); + const created = createTreecrdtPayloadKeyringV1({ payloadKey }); + await writeDocPayloadKeyringV1({ docId: opts.docId, wrapKey: opts.wrapKey, 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 active = keyring.keys[keyring.activeKid]; + if (!active) throw new Error("doc payload active key is missing"); + return { + payloadKeyB64: base64urlEncode(active), + 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 rotateDocPayloadKeyB64(docId: string): Promise<{ + payloadKeyB64: string; + 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 { + payloadKeyB64: base64urlEncode(rotated.rotatedPayloadKey), + 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"); + const kid = payloadKeyKid.trim(); + if (kid.length === 0) throw new Error("payload key id must not be empty"); - 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(); + let keyring = await loadOrCreateDocPayloadKeyringV1Unlocked({ docId, wrapKey }); + + keyring = upsertTreecrdtPayloadKeyringKeyV1({ + keyring, + kid, + payloadKey, + makeActive: true, + }); + + await writeDocPayloadKeyringV1({ docId, wrapKey, keyring }); + }); } export function initialAuthEnabled(): boolean { @@ -476,7 +633,8 @@ export type InvitePayloadV1 = { issuerPkB64: string; subjectSkB64: string; tokenB64: string; - payloadKeyB64?: string; + payloadKeyB64: string; + payloadKeyKid: string; }; export function encodeInvitePayload(payload: InvitePayloadV1): string { @@ -495,8 +653,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 fcab515c..041d073a 100644 --- a/examples/playground/src/playground/components/SharingAuthPanel.tsx +++ b/examples/playground/src/playground/components/SharingAuthPanel.tsx @@ -44,6 +44,9 @@ export type SharingAuthPanelProps = { authTokenCount: number; authTokenScope: AuthTokenScope | null; authTokenActions: string[] | null; + payloadKeyKid: string | null; + payloadRotateBusy: boolean; + rotatePayloadKey: () => Promise; authScopeSummary: string; authScopeTitle: string; authSummaryBadges: string[]; @@ -138,6 +141,9 @@ export function SharingAuthPanel(props: SharingAuthPanelProps) { authTokenCount, authTokenScope, authTokenActions, + payloadKeyKid, + payloadRotateBusy, + rotatePayloadKey, authScopeSummary, authScopeTitle, authSummaryBadges, @@ -316,7 +322,7 @@ export function SharingAuthPanel(props: SharingAuthPanelProps) { -
+
scope {authScopeSummary} - {authSummaryBadges.map((name) => ( + {authSummaryBadges.map((name) => ( {name} - ))} -
-
- - {showAuthAdvanced && ( + ))} + + + +
+
+
+
Payload key
+
+ Active key id: {payloadKeyKid ?? "-"} +
+
+ Rotation affects future writes only. Re-share invite/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 72b7fdd7..bc2aecc4 100644 --- a/examples/playground/src/playground/hooks/usePlaygroundAuth.ts +++ b/examples/playground/src/playground/hooks/usePlaygroundAuth.ts @@ -26,9 +26,9 @@ import { encodeInvitePayload, generateEd25519KeyPair, deriveEd25519PublicKey, + getDocPayloadActiveKeyInfoB64, initialAuthEnabled, initialRevealIdentity, - loadOrCreateDocPayloadKeyB64, loadAuthMaterial, persistRevealIdentity, persistAuthEnabled, @@ -557,10 +557,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); @@ -898,6 +896,7 @@ export function usePlaygroundAuth(opts: UsePlaygroundAuthOptions): PlaygroundAut ...(excludeNodeIds.length > 0 ? { excludeNodeIds } : {}), }); + const { payloadKeyB64, payloadKeyKid } = await getDocPayloadActiveKeyInfoB64(docId); return encodeInvitePayload({ v: 1, t: "treecrdt.playground.invite", @@ -905,7 +904,8 @@ export function usePlaygroundAuth(opts: UsePlaygroundAuthOptions): PlaygroundAut issuerPkB64, subjectSkB64: base64urlEncode(subjectSk), tokenB64: base64urlEncode(tokenBytes), - payloadKeyB64: await loadOrCreateDocPayloadKeyB64(docId), + payloadKeyB64, + payloadKeyKid, }); } if (!issuerSkB64 || !issuerPkB64) { @@ -927,6 +927,7 @@ export function usePlaygroundAuth(opts: UsePlaygroundAuthOptions): PlaygroundAut ...(excludeNodeIds.length > 0 ? { excludeNodeIds } : {}), }); + const { payloadKeyB64, payloadKeyKid } = await getDocPayloadActiveKeyInfoB64(docId); return encodeInvitePayload({ v: 1, t: "treecrdt.playground.invite", @@ -934,7 +935,8 @@ export function usePlaygroundAuth(opts: UsePlaygroundAuthOptions): PlaygroundAut issuerPkB64, subjectSkB64: base64urlEncode(subjectSk), tokenB64: base64urlEncode(tokenBytes), - payloadKeyB64: await loadOrCreateDocPayloadKeyB64(docId), + payloadKeyB64, + payloadKeyKid, }); }; @@ -1022,7 +1024,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; void (async () => { setAuthBusy(true); @@ -1031,10 +1034,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) { @@ -1152,13 +1153,15 @@ export function usePlaygroundAuth(opts: UsePlaygroundAuthOptions): PlaygroundAut }); } + const { payloadKeyB64, payloadKeyKid } = await getDocPayloadActiveKeyInfoB64(docId); const msg: AuthGrantMessageV1 = { t: "auth_grant_v1", doc_id: docId, to_replica_pk_hex: bytesToHex(subjectPk), issuer_pk_b64: issuerPkB64, token_b64: base64urlEncode(tokenBytes), - payload_key_b64: await loadOrCreateDocPayloadKeyB64(docId), + payload_key_b64: payloadKeyB64, + payload_key_kid: payloadKeyKid, from_peer_id: selfPeerId, ts: Date.now(), }; diff --git a/examples/playground/src/playground/hooks/usePlaygroundSync.ts b/examples/playground/src/playground/hooks/usePlaygroundSync.ts index 6336bd0c..5f198ef7 100644 --- a/examples/playground/src/playground/hooks/usePlaygroundSync.ts +++ b/examples/playground/src/playground/hooks/usePlaygroundSync.ts @@ -798,6 +798,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 c79e5acd..a669d9cc 100644 --- a/examples/playground/src/sync-v0.ts +++ b/examples/playground/src/sync-v0.ts @@ -6,7 +6,8 @@ export type AuthGrantMessageV1 = { to_replica_pk_hex: string; issuer_pk_b64: string; token_b64: string; - payload_key_b64?: string; + payload_key_b64: string; + payload_key_kid: string; from_peer_id: string; ts: number; }; diff --git a/packages/treecrdt-crypto/src/e2ee.ts b/packages/treecrdt-crypto/src/e2ee.ts index 6c579c7c..f1f9201a 100644 --- a/packages/treecrdt-crypto/src/e2ee.ts +++ b/packages/treecrdt-crypto/src/e2ee.ts @@ -20,50 +20,167 @@ const AES_GCM_NONCE_LEN = 12; const ENCRYPTED_PAYLOAD_V1_TAG = "treecrdt/payload-encrypted/v1"; const ENCRYPTED_PAYLOAD_V1_AAD_DOMAIN = utf8ToBytes("treecrdt/payload-encrypted/v1"); +const PAYLOAD_KEY_ID_MAX_LEN = 128; +const PAYLOAD_KEY_ID_PATTERN = /^[A-Za-z0-9._:-]+$/; + function payloadAadV1(docId: string): Uint8Array { return concatBytes(ENCRYPTED_PAYLOAD_V1_AAD_DOMAIN, utf8ToBytes(docId)); } -function tryDecodeEncryptedPayloadV1(bytes: Uint8Array): { nonce: Uint8Array; ct: Uint8Array } | null { - let decoded: unknown; +function bytesToHex(bytes: Uint8Array): string { + let out = ""; + for (const b of bytes) out += b.toString(16).padStart(2, "0"); + return out; +} + +function randomPayloadKeyIdV1(): string { + return `k${bytesToHex(randomBytes(8))}`; +} + +function assertPayloadKeyIdV1(kid: string, field: string): string { + const clean = kid.trim(); + if (clean.length === 0) throw new Error(`${field} must not be empty`); + if (clean.length > PAYLOAD_KEY_ID_MAX_LEN) { + throw new Error(`${field} too long (max ${PAYLOAD_KEY_ID_MAX_LEN})`); + } + if (!PAYLOAD_KEY_ID_PATTERN.test(clean)) { + throw new Error(`${field} contains unsupported characters`); + } + return clean; +} + +export type TreecrdtPayloadKeyringV1 = { + activeKid: string; + keys: Record; +}; + +function clonePayloadKeyringV1(keyring: TreecrdtPayloadKeyringV1): TreecrdtPayloadKeyringV1 { + if (!keyring || typeof keyring !== "object") throw new Error("keyring must be an object"); + + const activeKid = assertPayloadKeyIdV1(keyring.activeKid, "keyring.activeKid"); + const entries = Object.entries(keyring.keys ?? {}); + if (entries.length === 0) throw new Error("keyring.keys must not be empty"); + + const keys: Record = {}; + for (const [rawKid, rawKey] of entries) { + const kid = assertPayloadKeyIdV1(rawKid, "keyring.keys[]"); + assertLen(rawKey, DOC_PAYLOAD_KEY_LEN, `keyring.keys[${kid}]`); + keys[kid] = new Uint8Array(rawKey); + } + + if (!Object.prototype.hasOwnProperty.call(keys, activeKid)) { + throw new Error("keyring.activeKid does not exist in keyring.keys"); + } + + return { activeKid, keys }; +} + +function decodeEncryptedPayloadV1Strict(bytes: Uint8Array): { nonce: Uint8Array; ct: Uint8Array; kid: string | null } { + const decoded = decodeCbor(bytes); + const map = assertMap(decoded, "EncryptedPayloadV1"); + + const v = mapGet(map, "v"); + if (v !== 1) throw new Error("EncryptedPayloadV1.v must be 1"); + + const t = assertString(mapGet(map, "t"), "EncryptedPayloadV1.t"); + if (t !== ENCRYPTED_PAYLOAD_V1_TAG) throw new Error("EncryptedPayloadV1.t mismatch"); + + const alg = assertString(mapGet(map, "alg"), "EncryptedPayloadV1.alg"); + if (alg !== "A256GCM") throw new Error("EncryptedPayloadV1.alg unsupported"); + + const nonce = assertLen(assertBytes(mapGet(map, "nonce"), "EncryptedPayloadV1.nonce"), AES_GCM_NONCE_LEN, "nonce"); + const ct = assertBytes(mapGet(map, "ct"), "EncryptedPayloadV1.ct"); + + const rawKid = mapGet(map, "kid"); + let kid: string | null = null; + if (rawKid !== undefined) { + kid = assertPayloadKeyIdV1(assertString(rawKid, "EncryptedPayloadV1.kid"), "EncryptedPayloadV1.kid"); + } + + return { nonce, ct, kid }; +} + +function tryDecodeEncryptedPayloadV1(bytes: Uint8Array): { nonce: Uint8Array; ct: Uint8Array; kid: string | null } | null { try { - decoded = decodeCbor(bytes); + return decodeEncryptedPayloadV1Strict(bytes); } catch { return null; } - if (!(decoded instanceof Map)) return null; +} - const map = decoded as Map; - const v = mapGet(map, "v"); - if (v !== 1) return null; - const t = mapGet(map, "t"); - if (t !== ENCRYPTED_PAYLOAD_V1_TAG) return null; +export function createTreecrdtPayloadKeyringV1(opts: { + payloadKey: Uint8Array; + activeKid?: string; +}): TreecrdtPayloadKeyringV1 { + assertLen(opts.payloadKey, DOC_PAYLOAD_KEY_LEN, "payloadKey"); + const activeKid = assertPayloadKeyIdV1(opts.activeKid ?? randomPayloadKeyIdV1(), "activeKid"); + return { + activeKid, + keys: { + [activeKid]: new Uint8Array(opts.payloadKey), + }, + }; +} - const alg = mapGet(map, "alg"); - if (alg !== "A256GCM") return null; +export function upsertTreecrdtPayloadKeyringKeyV1(opts: { + keyring: TreecrdtPayloadKeyringV1; + kid: string; + payloadKey: Uint8Array; + makeActive?: boolean; +}): TreecrdtPayloadKeyringV1 { + const keyring = clonePayloadKeyringV1(opts.keyring); + const kid = assertPayloadKeyIdV1(opts.kid, "kid"); + assertLen(opts.payloadKey, DOC_PAYLOAD_KEY_LEN, "payloadKey"); - const nonce = mapGet(map, "nonce"); - if (!(nonce instanceof Uint8Array)) return null; - if (nonce.length !== AES_GCM_NONCE_LEN) return null; + keyring.keys[kid] = new Uint8Array(opts.payloadKey); + if (opts.makeActive ?? false) keyring.activeKid = kid; + + return keyring; +} - const ct = mapGet(map, "ct"); - if (!(ct instanceof Uint8Array)) return null; +export function rotateTreecrdtPayloadKeyringV1(opts: { + keyring: TreecrdtPayloadKeyringV1; + nextKid?: string; + nextPayloadKey?: Uint8Array; +}): { + keyring: TreecrdtPayloadKeyringV1; + rotatedKid: string; + rotatedPayloadKey: Uint8Array; +} { + const rotatedKid = assertPayloadKeyIdV1(opts.nextKid ?? randomPayloadKeyIdV1(), "nextKid"); + const rotatedPayloadKey = opts.nextPayloadKey ? new Uint8Array(opts.nextPayloadKey) : randomBytes(DOC_PAYLOAD_KEY_LEN); + assertLen(rotatedPayloadKey, DOC_PAYLOAD_KEY_LEN, "nextPayloadKey"); - return { nonce, ct }; + const keyring = upsertTreecrdtPayloadKeyringKeyV1({ + keyring: opts.keyring, + kid: rotatedKid, + payloadKey: rotatedPayloadKey, + makeActive: true, + }); + + return { keyring, rotatedKid, rotatedPayloadKey }; } export function isTreecrdtEncryptedPayloadV1(bytes: Uint8Array): boolean { return tryDecodeEncryptedPayloadV1(bytes) !== null; } +export function getTreecrdtEncryptedPayloadKeyIdV1(bytes: Uint8Array): string | null { + const decoded = tryDecodeEncryptedPayloadV1(bytes); + if (!decoded) return null; + return decoded.kid; +} + export async function encryptTreecrdtPayloadV1(opts: { docId: string; payloadKey: Uint8Array; plaintext: Uint8Array; + keyId?: string; }): Promise { if (!opts.docId || opts.docId.trim().length === 0) throw new Error("docId must not be empty"); assertLen(opts.payloadKey, DOC_PAYLOAD_KEY_LEN, "payloadKey"); + const keyId = assertPayloadKeyIdV1(opts.keyId ?? "k0", "keyId"); const nonce = randomBytes(AES_GCM_NONCE_LEN); const ciphertext = await aesGcmEncrypt({ key: opts.payloadKey, @@ -78,9 +195,27 @@ export async function encryptTreecrdtPayloadV1(opts: { envelope.set("alg", "A256GCM"); envelope.set("nonce", nonce); envelope.set("ct", ciphertext); + envelope.set("kid", keyId); return encodeCbor(envelope); } +export async function encryptTreecrdtPayloadWithKeyringV1(opts: { + docId: string; + keyring: TreecrdtPayloadKeyringV1; + plaintext: Uint8Array; +}): Promise { + const keyring = clonePayloadKeyringV1(opts.keyring); + const payloadKey = keyring.keys[keyring.activeKid]; + if (!payloadKey) throw new Error("active payload key is missing"); + + return await encryptTreecrdtPayloadV1({ + docId: opts.docId, + payloadKey, + plaintext: opts.plaintext, + keyId: keyring.activeKid, + }); +} + export async function decryptTreecrdtPayloadV1(opts: { docId: string; payloadKey: Uint8Array; @@ -89,23 +224,11 @@ export async function decryptTreecrdtPayloadV1(opts: { if (!opts.docId || opts.docId.trim().length === 0) throw new Error("docId must not be empty"); assertLen(opts.payloadKey, DOC_PAYLOAD_KEY_LEN, "payloadKey"); - const decoded = decodeCbor(opts.ciphertext); - const map = assertMap(decoded, "EncryptedPayloadV1"); - - const v = mapGet(map, "v"); - if (v !== 1) throw new Error("EncryptedPayloadV1.v must be 1"); - const t = assertString(mapGet(map, "t"), "EncryptedPayloadV1.t"); - if (t !== ENCRYPTED_PAYLOAD_V1_TAG) throw new Error("EncryptedPayloadV1.t mismatch"); - const alg = assertString(mapGet(map, "alg"), "EncryptedPayloadV1.alg"); - if (alg !== "A256GCM") throw new Error("EncryptedPayloadV1.alg unsupported"); - - const nonce = assertLen(assertBytes(mapGet(map, "nonce"), "EncryptedPayloadV1.nonce"), AES_GCM_NONCE_LEN, "nonce"); - const ct = assertBytes(mapGet(map, "ct"), "EncryptedPayloadV1.ct"); - + const decoded = decodeEncryptedPayloadV1Strict(opts.ciphertext); return await aesGcmDecrypt({ key: opts.payloadKey, - nonce, - ciphertext: ct, + nonce: decoded.nonce, + ciphertext: decoded.ct, aad: payloadAadV1(opts.docId), }); } @@ -127,3 +250,74 @@ export async function maybeDecryptTreecrdtPayloadV1(opts: { return { plaintext, encrypted: true }; } + +export type TreecrdtMaybeDecryptWithKeyringV1Result = + | { + encrypted: false; + keyMissing: false; + keyId: null; + plaintext: Uint8Array; + } + | { + encrypted: true; + keyMissing: false; + keyId: string | null; + plaintext: Uint8Array; + } + | { + encrypted: true; + keyMissing: true; + keyId: string | null; + plaintext: null; + }; + +export async function maybeDecryptTreecrdtPayloadWithKeyringV1(opts: { + docId: string; + keyring: TreecrdtPayloadKeyringV1; + bytes: Uint8Array; +}): Promise { + const parsed = tryDecodeEncryptedPayloadV1(opts.bytes); + if (!parsed) { + return { + encrypted: false, + keyMissing: false, + keyId: null, + plaintext: opts.bytes, + }; + } + + const keyring = clonePayloadKeyringV1(opts.keyring); + + if (parsed.kid === null) { + return { + encrypted: true, + keyMissing: true, + keyId: null, + plaintext: null, + }; + } + + const payloadKey = keyring.keys[parsed.kid]; + if (!payloadKey) { + return { + encrypted: true, + keyMissing: true, + keyId: parsed.kid, + plaintext: null, + }; + } + + const plaintext = await aesGcmDecrypt({ + key: payloadKey, + nonce: parsed.nonce, + ciphertext: parsed.ct, + aad: payloadAadV1(opts.docId), + }); + + return { + encrypted: true, + keyMissing: false, + keyId: parsed.kid, + plaintext, + }; +} diff --git a/packages/treecrdt-crypto/tests/e2ee.test.ts b/packages/treecrdt-crypto/tests/e2ee.test.ts index 3eb5c1d4..1b441f0d 100644 --- a/packages/treecrdt-crypto/tests/e2ee.test.ts +++ b/packages/treecrdt-crypto/tests/e2ee.test.ts @@ -1,7 +1,15 @@ import { expect, test } from "vitest"; import { generateTreecrdtDocPayloadKeyV1 } from "../dist/keystore.js"; -import { encryptTreecrdtPayloadV1, maybeDecryptTreecrdtPayloadV1 } from "../dist/e2ee.js"; +import { + createTreecrdtPayloadKeyringV1, + encryptTreecrdtPayloadV1, + encryptTreecrdtPayloadWithKeyringV1, + getTreecrdtEncryptedPayloadKeyIdV1, + maybeDecryptTreecrdtPayloadV1, + maybeDecryptTreecrdtPayloadWithKeyringV1, + rotateTreecrdtPayloadKeyringV1, +} from "../dist/e2ee.js"; function bytesToHex(bytes: Uint8Array): string { return Buffer.from(bytes).toString("hex"); @@ -38,3 +46,93 @@ test("e2ee v1: maybeDecrypt returns bytes unchanged if not encrypted", async () expect(bytesToHex(res.plaintext)).toBe(bytesToHex(bytes)); }); +test("e2ee v1 keyring: encrypt tags ciphertext with active key id", async () => { + const docId = "doc-e2ee-kid"; + const { payloadKey } = generateTreecrdtDocPayloadKeyV1({ docId }); + const keyring = createTreecrdtPayloadKeyringV1({ payloadKey, activeKid: "epoch-1" }); + + const plaintext = new TextEncoder().encode("payload with kid"); + const encrypted = await encryptTreecrdtPayloadWithKeyringV1({ docId, keyring, plaintext }); + + expect(getTreecrdtEncryptedPayloadKeyIdV1(encrypted)).toBe("epoch-1"); + + const decrypted = await maybeDecryptTreecrdtPayloadWithKeyringV1({ docId, keyring, bytes: encrypted }); + expect(decrypted.encrypted).toBe(true); + expect(decrypted.keyMissing).toBe(false); + expect(decrypted.keyId).toBe("epoch-1"); + if (decrypted.plaintext === null) throw new Error("plaintext is null"); + expect(new TextDecoder().decode(decrypted.plaintext)).toBe("payload with kid"); +}); + +test("e2ee v1 keyring: keyMissing=true when ciphertext key id is unavailable", async () => { + const docId = "doc-e2ee-missing-key"; + const { payloadKey } = generateTreecrdtDocPayloadKeyV1({ docId }); + const sender = createTreecrdtPayloadKeyringV1({ payloadKey, activeKid: "epoch-1" }); + + const encrypted = await encryptTreecrdtPayloadWithKeyringV1({ + docId, + keyring: sender, + plaintext: new TextEncoder().encode("secret"), + }); + + const { payloadKey: otherKey } = generateTreecrdtDocPayloadKeyV1({ docId }); + const receiver = createTreecrdtPayloadKeyringV1({ payloadKey: otherKey, activeKid: "epoch-2" }); + + const decrypted = await maybeDecryptTreecrdtPayloadWithKeyringV1({ docId, keyring: receiver, bytes: encrypted }); + expect(decrypted.encrypted).toBe(true); + expect(decrypted.keyMissing).toBe(true); + expect(decrypted.keyId).toBe("epoch-1"); + expect(decrypted.plaintext).toBeNull(); +}); + +test("e2ee v1 keyring: rotated keyring decrypts both old and new payloads", async () => { + const docId = "doc-e2ee-rotate"; + const { payloadKey } = generateTreecrdtDocPayloadKeyV1({ docId }); + + const keyringV1 = createTreecrdtPayloadKeyringV1({ payloadKey, activeKid: "epoch-1" }); + const oldEncrypted = await encryptTreecrdtPayloadWithKeyringV1({ + docId, + keyring: keyringV1, + plaintext: new TextEncoder().encode("before rotate"), + }); + + const { keyring: keyringV2 } = rotateTreecrdtPayloadKeyringV1({ keyring: keyringV1, nextKid: "epoch-2" }); + const newEncrypted = await encryptTreecrdtPayloadWithKeyringV1({ + docId, + keyring: keyringV2, + plaintext: new TextEncoder().encode("after rotate"), + }); + + const oldDecrypted = await maybeDecryptTreecrdtPayloadWithKeyringV1({ docId, keyring: keyringV2, bytes: oldEncrypted }); + const newDecrypted = await maybeDecryptTreecrdtPayloadWithKeyringV1({ docId, keyring: keyringV2, bytes: newEncrypted }); + + expect(oldDecrypted.keyMissing).toBe(false); + expect(oldDecrypted.keyId).toBe("epoch-1"); + if (oldDecrypted.plaintext === null) throw new Error("old plaintext is null"); + expect(new TextDecoder().decode(oldDecrypted.plaintext)).toBe("before rotate"); + + expect(newDecrypted.keyMissing).toBe(false); + expect(newDecrypted.keyId).toBe("epoch-2"); + if (newDecrypted.plaintext === null) throw new Error("new plaintext is null"); + expect(new TextDecoder().decode(newDecrypted.plaintext)).toBe("after rotate"); +}); + +test("e2ee v1 keyring: decrypt requires matching key id", async () => { + const docId = "doc-e2ee-kid-required"; + const { payloadKey } = generateTreecrdtDocPayloadKeyV1({ docId }); + const keyring = createTreecrdtPayloadKeyringV1({ payloadKey, activeKid: "epoch-legacy" }); + + const encrypted = await encryptTreecrdtPayloadV1({ + docId, + payloadKey, + plaintext: new TextEncoder().encode("legacy format"), + }); + + expect(getTreecrdtEncryptedPayloadKeyIdV1(encrypted)).toBe("k0"); + + const decrypted = await maybeDecryptTreecrdtPayloadWithKeyringV1({ docId, keyring, bytes: encrypted }); + expect(decrypted.encrypted).toBe(true); + expect(decrypted.keyMissing).toBe(true); + expect(decrypted.keyId).toBe("k0"); + expect(decrypted.plaintext).toBeNull(); +});