diff --git a/.changeset/side-effect-free-local-auth.md b/.changeset/side-effect-free-local-auth.md new file mode 100644 index 00000000..4309acd5 --- /dev/null +++ b/.changeset/side-effect-free-local-auth.md @@ -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. diff --git a/packages/treecrdt-auth/src/internal/cose-cwt-auth.ts b/packages/treecrdt-auth/src/internal/cose-cwt-auth.ts index d89c94a7..3e9ee853 100644 --- a/packages/treecrdt-auth/src/internal/cose-cwt-auth.ts +++ b/packages/treecrdt-auth/src/internal/cose-cwt-auth.ts @@ -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]!; @@ -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); diff --git a/packages/treecrdt-auth/tests/auth.test.ts b/packages/treecrdt-auth/tests/auth.test.ts index 52e63864..a404e16c 100644 --- a/packages/treecrdt-auth/tests/auth.test.ts +++ b/packages/treecrdt-auth/tests/auth.test.ts @@ -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; @@ -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();