diff --git a/.changeset/fail-closed-children-redaction.md b/.changeset/fail-closed-children-redaction.md new file mode 100644 index 00000000..0adfd9bf --- /dev/null +++ b/.changeset/fail-closed-children-redaction.md @@ -0,0 +1,5 @@ +--- +"@treecrdt/auth": patch +--- + +Reject scoped `children(parent)` projections when convergence requires an out-of-scope operation that cannot yet be represented without revealing restricted data. diff --git a/packages/treecrdt-auth/src/internal/cose-cwt-auth.ts b/packages/treecrdt-auth/src/internal/cose-cwt-auth.ts index 066c4d21..e970b47e 100644 --- a/packages/treecrdt-auth/src/internal/cose-cwt-auth.ts +++ b/packages/treecrdt-auth/src/internal/cose-cwt-auth.ts @@ -616,13 +616,39 @@ export function createTreecrdtCoseCwtAuth(opts: TreecrdtCoseCwtAuthOptions): Syn // For `children(parent)` we still need to hide ops for nodes outside scope // (e.g. excluded private roots) so peers cannot discover them by syncing the parent's children. switch (op.kind.type) { - case 'insert': - case 'payload': + case 'insert': { + const allowed = await allowNode(nodeIdToBytes16(op.kind.node), requiredStructure); + if ( + !allowed && + 'children' in ctx.filter && + op.kind.parent !== bytesToHex(ctx.filter.children.parent) + ) { + throw new Error( + 'children(parent) projection requires an out-of-scope causal dependency', + ); + } + out.push(allowed); + break; + } case 'move': + case 'payload': case 'delete': - case 'tombstone': - out.push(await allowNode(nodeIdToBytes16(op.kind.node), requiredStructure)); + case 'tombstone': { + const allowed = await allowNode(nodeIdToBytes16(op.kind.node), requiredStructure); + if (!allowed && 'children' in ctx.filter) { + // A move can be indexed by its old parent after the node has already left the + // authorized subtree; deletes/tombstones are also indexed by their pre-change + // parent. Likewise, a descendant payload can defensively restore an ancestor and + // therefore appear in that ancestor's parent filter. Omitting any of these leaves a + // stale projection; sending it reveals out-of-scope data. Until the wire protocol + // has redacted remove/restore records, reject the whole filtered read. + throw new Error( + 'children(parent) projection requires redaction for an out-of-scope dependency', + ); + } + out.push(allowed); break; + } default: { const _exhaustive: never = op.kind; throw new Error(`unknown op kind: ${String((_exhaustive as any)?.type)}`); diff --git a/packages/treecrdt-auth/tests/auth.test.ts b/packages/treecrdt-auth/tests/auth.test.ts index 434fd60e..9e9ab61b 100644 --- a/packages/treecrdt-auth/tests/auth.test.ts +++ b/packages/treecrdt-auth/tests/auth.test.ts @@ -1659,7 +1659,7 @@ test('auth: syncOnce rejects filter(children) when capability scope does not mat void bPk; }); -test('auth: filterOutgoingOps hides move/delete/tombstone for excluded subtrees', async () => { +test('auth: outgoing scope hides all-filter ops and rejects unsafe children projections', async () => { const docId = 'doc-auth-filter-outgoing-exclude'; const root = '0'.repeat(32); @@ -1819,6 +1819,54 @@ test('auth: filterOutgoingOps hides move/delete/tombstone for excluded subtrees' for (const i of [1, 2, 3, 4, 5, 6, 7]) { expect(allowed?.[i]).toBe(false); } + + // A children filter is a convergent operation projection, so silently hiding an indexed + // removal/dependency would leave stale state. Once the public node has crossed into the + // excluded subtree, these operations require a redacted wire representation and must reject the + // whole projection until that representation exists. + parentByNodeHex.set(publicNode, privateRoot); + const childrenContext = { + docId, + purpose: 'reconcile' as const, + filter: { children: { parent: nodeIdToBytes16(root) } }, + capabilities: receiverCaps ?? [], + }; + const boundaryOps: Operation[] = [ + makeOp(senderPk, 11, 11, { + type: 'move', + node: publicNode, + newParent: privateRoot, + orderKey: orderKeyFromPosition(0), + }), + makeOp(senderPk, 12, 12, { type: 'delete', node: publicNode }), + makeOp(senderPk, 13, 13, { type: 'tombstone', node: publicNode }), + makeOp(senderPk, 14, 14, { + type: 'payload', + node: publicNode, + payload: new Uint8Array([4]), + }), + ]; + for (const boundaryOp of boundaryOps) { + await expect(authSender.filterOutgoingOps?.([boundaryOp], childrenContext)).rejects.toThrow( + /requires redaction/i, + ); + } + + const insertUpsertLeavingScope = makeOp(senderPk, 15, 15, { + type: 'insert', + parent: privateRoot, + node: publicNode, + orderKey: orderKeyFromPosition(0), + }); + await expect( + authSender.filterOutgoingOps?.([insertUpsertLeavingScope], childrenContext), + ).rejects.toThrow(/out-of-scope causal dependency/i); + + // A descendant op can be selected by children(root) because it defensively restores a direct + // child. Omitting it is also unsafe, even though its own node is outside the readable scope. + await expect(authSender.filterOutgoingOps?.([ops[3]!], childrenContext)).rejects.toThrow( + /out-of-scope causal dependency/i, + ); }); test('auth: delegated capability token can be verified via issuer-signed proof', async () => {