Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/fail-closed-children-redaction.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 30 additions & 4 deletions packages/treecrdt-auth/src/internal/cose-cwt-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`);
Expand Down
50 changes: 49 additions & 1 deletion packages/treecrdt-auth/tests/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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 () => {
Expand Down
Loading