Skip to content
Open
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
8 changes: 8 additions & 0 deletions .changeset/side-effect-free-local-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@treecrdt/auth': patch
---

Keep local operation authorization side-effect-free by deferring proof-store publication from
`verifyOps` to `onVerifiedOps`. Remote sync still invokes `onVerifiedOps` before backend apply,
preserving proof-first ingestion, while optimistic local backends can verify proposals without
publishing proof material for a write that has not committed.
17 changes: 13 additions & 4 deletions packages/treecrdt-auth/src/internal/cose-cwt-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -666,7 +666,6 @@ export function createTreecrdtCoseCwtAuth(opts: TreecrdtCoseCwtAuthOptions): Syn
throw new Error(`op auth length ${auth.length} does not match ops length ${ops.length}`);
}

const toPersist: Array<{ opRef: OpRef; auth: OpAuth }> = [];
for (let i = 0; i < ops.length; i += 1) {
const op = ops[i]!;
const a = auth[i]!;
Expand Down Expand Up @@ -719,11 +718,21 @@ export function createTreecrdtCoseCwtAuth(opts: TreecrdtCoseCwtAuthOptions): Syn
publicKey: replica,
});
if (!ok) throw new Error('invalid op signature');
}
},
onVerifiedOps: async (ops, auth, ctx) => {
if (ops.length !== auth.length) {
throw new Error(`verified auth has ${auth.length} entries for ${ops.length} ops`);
}
const toPersist: Array<{ opRef: OpRef; auth: OpAuth }> = [];
for (let i = 0; i < ops.length; i += 1) {
const op = ops[i]!;
const entry = auth[i]!;
const replica = replicaIdToBytes(op.meta.id.replica);
const opRef = deriveOpRefV0(ctx.docId, { replica, counter: op.meta.id.counter });
opAuthByOpRefHex.set(bytesToHex(opRef), a);
if (opts.opAuthStore) toPersist.push({ opRef, auth: a });
opAuthByOpRefHex.set(bytesToHex(opRef), entry);
if (opts.opAuthStore) toPersist.push({ opRef, auth: entry });
}

if (opts.opAuthStore && toPersist.length > 0) {
await ensureOpAuthStoreReady();
await opts.opAuthStore.storeOpAuth(toPersist);
Expand Down
62 changes: 61 additions & 1 deletion packages/treecrdt-auth/tests/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,14 @@ import {
signTreecrdtOp,
verifyTreecrdtRevocationRecordV1,
} from '../dist/treecrdt-auth.js';
import type { Capability, Filter, Hello, OpRef, SyncBackend } from '@treecrdt/sync-protocol';
import type {
Capability,
Filter,
Hello,
OpAuth,
OpRef,
SyncBackend,
} from '@treecrdt/sync-protocol';

ed25519Hashes.sha512 = sha512;

Expand Down Expand Up @@ -427,6 +434,59 @@ test('auth: signOps selects proof_ref per op when multiple tokens exist', async
);
});

test('auth: verifyOps is side-effect-free and onVerifiedOps publishes proof material', async () => {
const docId = 'doc-auth-local-proof-staging';
const root = '0'.repeat(32);
const issuerSk = ed25519Utils.randomSecretKey();
const issuerPk = await getPublicKey(issuerSk);
const writerSk = ed25519Utils.randomSecretKey();
const writerPk = await getPublicKey(writerSk);
const token = issueTreecrdtCapabilityTokenV1({
issuerPrivateKey: issuerSk,
subjectPublicKey: writerPk,
docId,
actions: ['write_structure'],
});
const persisted: Array<{ opRef: OpRef; auth: OpAuth }> = [];
const auth = createTreecrdtCoseCwtAuth({
issuerPublicKeys: [issuerPk],
localPrivateKey: writerSk,
localPublicKey: writerPk,
localCapabilityTokens: [token],
opAuthStore: {
storeOpAuth: async (entries) => {
persisted.push(...entries);
},
getOpAuthByOpRefs: async (opRefs) => opRefs.map(() => null),
},
});
const signCtx = { docId, purpose: 'local_write' as const, filterId: '__local__' };
const ctx = { docId, purpose: 'reconcile' as const, filterId: '__local__' };
const firstProposal = makeOp(writerPk, 1, 1, {
type: 'insert',
parent: root,
node: nodeIdFromInt(1),
orderKey: orderKeyFromPosition(0),
});
const retriedProposal = makeOp(writerPk, 1, 2, {
type: 'insert',
parent: root,
node: nodeIdFromInt(2),
orderKey: orderKeyFromPosition(1),
});

const firstAuth = (await auth.signOps?.([firstProposal], signCtx))!;
await auth.verifyOps?.([firstProposal], firstAuth, ctx);
const retriedAuth = (await auth.signOps?.([retriedProposal], signCtx))!;
await auth.verifyOps?.([retriedProposal], retriedAuth, ctx);
expect(persisted).toHaveLength(0);

await auth.onVerifiedOps?.([retriedProposal], retriedAuth, ctx);
expect(persisted).toHaveLength(1);
expect(persisted[0]!.auth.sig).toEqual(retriedAuth[0]!.sig);
expect(persisted[0]!.auth.sig).not.toEqual(firstAuth[0]!.sig);
});

test('auth: restart reuses only a retained proof for the exact local operation', async () => {
const docId = 'doc-auth-retained-local-proof';
const issuerSk = ed25519Utils.randomSecretKey();
Expand Down
Loading