From 879801d10a3c7b1ef4ed1322287ee61b9546beb7 Mon Sep 17 00:00:00 2001 From: Marcus Pousette Date: Sun, 12 Jul 2026 12:19:01 +0200 Subject: [PATCH] fix(auth): bind defensive delete state in v2 signatures --- .changeset/auth-known-state-signatures.md | 7 + docs/sync/v0/auth.md | 30 ++++ .../src/internal/cose-cwt-auth.ts | 6 +- packages/treecrdt-auth/src/internal/op-sig.ts | 152 +++++++++++++++++- packages/treecrdt-auth/src/treecrdt-auth.ts | 5 + packages/treecrdt-auth/tests/auth.test.ts | 67 +++++++- packages/treecrdt-auth/tests/op-sig.test.ts | 115 +++++++++++++ 7 files changed, 372 insertions(+), 10 deletions(-) create mode 100644 .changeset/auth-known-state-signatures.md create mode 100644 packages/treecrdt-auth/tests/op-sig.test.ts diff --git a/.changeset/auth-known-state-signatures.md b/.changeset/auth-known-state-signatures.md new file mode 100644 index 00000000..be7d5d4a --- /dev/null +++ b/.changeset/auth-known-state-signatures.md @@ -0,0 +1,7 @@ +--- +'@treecrdt/auth': minor +--- + +Bind canonical defensive-delete `knownState` to operation signatures without changing ordinary v1 +writers. Reject storage-unstable `knownState` on non-delete operations and make the deprecated v1 +sign/verify helpers fail closed for operations that require v2. diff --git a/docs/sync/v0/auth.md b/docs/sync/v0/auth.md index d1455b00..a9ca9717 100644 --- a/docs/sync/v0/auth.md +++ b/docs/sync/v0/auth.md @@ -177,6 +177,7 @@ Ops are signed with the doc-scoped Ed25519 key. The signature covers: - `doc_id` - `op_id` (`replica_id` + `counter`) - `lamport` +- defensive-delete `known_state` in signature v2 - op kind + fields - payload bytes (ciphertext bytes if payloads are encrypted) @@ -213,6 +214,35 @@ Kind tags and fields: - value_tag(u8): 0=clear, 1=payload - if value_tag=1: u32_be(len(payload)) || payload_bytes +Signature v1 predates defensive deletion and does **not** cover `known_state`. It remains the format +for ordinary operations. Delete and tombstone operations use signature v2. The operation itself +therefore selects the version; there is no optional version flag for a relay to remove. Policy APIs +require non-empty `known_state` on deletes and reject it on every other operation; tombstones use +v2's explicit absent-state form. + +Signature v2 changes the domain to `"treecrdt/op-sig/v2"` and appends the following field after the +v1 operation fields (without the v1 domain): + +``` +known_state = absent: u8(0) + present: u8(1) || u32_be(len(bytes)) || bytes +``` + +The present form must contain the canonical UTF-8 JSON version-vector encoding used by the +auth-enabled persistent backends: `{"entries":[...]}` without whitespace, with entries sorted +lexicographically by replica bytes and each entry encoded as +`{"replica":[...],"frontier":n,"ranges":[[start,end],...]}`. Signers and verifiers reject other +spellings so persistence cannot change signed bytes. + +Verifiers must reject historical v1 delete and tombstone signatures. They select v2 from the +operation kind even if `known_state` is missing, so stripping that field cannot downgrade a +signature to v1. This intentionally requires upgraded writers and verifiers for defensive-delete +operations; ordinary operations remain compatible with v1-only peers. + +Applications should use `signTreecrdtOp` and `verifyTreecrdtOp`, which select the required version +and enforce delete invariants. The deprecated explicit v1 sign/verify helpers also reject +v2-required operations; only the byte encoder remains permissive for migration tooling and vectors. + ## Subtree scope enforcement and `pending_context` Subtree ACLs require the verifier to answer: “is the node touched by this op within the granted subtree?” diff --git a/packages/treecrdt-auth/src/internal/cose-cwt-auth.ts b/packages/treecrdt-auth/src/internal/cose-cwt-auth.ts index 7ab96a8e..f0956dd3 100644 --- a/packages/treecrdt-auth/src/internal/cose-cwt-auth.ts +++ b/packages/treecrdt-auth/src/internal/cose-cwt-auth.ts @@ -45,7 +45,7 @@ import { type CapabilityGrant, type TreecrdtCapabilityRevocationCheckContext, } from './capability.js'; -import { signTreecrdtOpV1, verifyTreecrdtOpV1 } from './op-sig.js'; +import { signTreecrdtOp, verifyTreecrdtOp } from './op-sig.js'; import { getField } from './claims.js'; import { capAllowsNode, @@ -650,7 +650,7 @@ export function createTreecrdtCoseCwtAuth(opts: TreecrdtCoseCwtAuthOptions): Syn }); proofRef = selected.tokenId; } - const sig = await signTreecrdtOpV1({ + const sig = await signTreecrdtOp({ docId: ctx.docId, op, privateKey: opts.localPrivateKey, @@ -755,7 +755,7 @@ export function createTreecrdtCoseCwtAuth(opts: TreecrdtCoseCwtAuthOptions): Syn }); if (scopeRes === 'deny') throw new Error('capability does not allow op'); - const ok = await verifyTreecrdtOpV1({ + const ok = await verifyTreecrdtOp({ docId: ctx.docId, op, signature: a.sig, diff --git a/packages/treecrdt-auth/src/internal/op-sig.ts b/packages/treecrdt-auth/src/internal/op-sig.ts index 5c947f73..2be32561 100644 --- a/packages/treecrdt-auth/src/internal/op-sig.ts +++ b/packages/treecrdt-auth/src/internal/op-sig.ts @@ -7,8 +7,14 @@ import { signEd25519, verifyEd25519 } from '../ed25519.js'; import { concatBytes, u32be, u64be, u8 } from './bytes.js'; const OP_SIG_V1_DOMAIN = utf8ToBytes('treecrdt/op-sig/v1'); +const OP_SIG_V2_DOMAIN = utf8ToBytes('treecrdt/op-sig/v2'); -export function encodeTreecrdtOpSigInputV1(opts: { docId: string; op: Operation }): Uint8Array { +export function requiresTreecrdtOpSigV2(op: Operation): boolean { + assertPolicyOperation(op); + return op.kind.type === 'delete' || op.kind.type === 'tombstone'; +} + +function encodeTreecrdtOpFields(opts: { docId: string; op: Operation }): Uint8Array { const docIdBytes = utf8ToBytes(opts.docId); const replicaBytes = replicaIdToBytes(opts.op.meta.id.replica); @@ -80,8 +86,6 @@ export function encodeTreecrdtOpSigInputV1(opts: { docId: string; op: Operation } return concatBytes( - OP_SIG_V1_DOMAIN, - u8(0), u32be(docIdBytes.length), docIdBytes, u32be(replicaBytes.length), @@ -93,21 +97,163 @@ export function encodeTreecrdtOpSigInputV1(opts: { docId: string; op: Operation ); } +function encodeKnownStateV2(op: Operation): Uint8Array { + const knownState = op.meta.knownState; + return knownState === undefined || knownState.length === 0 + ? u8(0) + : concatBytes(u8(1), u32be(knownState.length), assertCanonicalKnownState(knownState)); +} + +function invalidKnownState(): never { + throw new Error('knownState must use the canonical TreeCRDT version-vector JSON encoding'); +} + +function assertCanonicalKnownState(bytes: Uint8Array): Uint8Array { + let parsed: any; + try { + parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); + } catch { + return invalidKnownState(); + } + if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) { + return invalidKnownState(); + } + + const replicas = new Set(); + const entries = parsed.entries.map((entry: any) => { + if ( + !entry || + typeof entry !== 'object' || + !Array.isArray(entry.replica) || + !entry.replica.every( + (byte: unknown) => + typeof byte === 'number' && Number.isInteger(byte) && byte >= 0 && byte <= 255, + ) || + !Number.isSafeInteger(entry.frontier) || + entry.frontier < 0 || + !Array.isArray(entry.ranges) || + !entry.ranges.every( + (range: unknown) => + Array.isArray(range) && + range.length === 2 && + range.every( + (value: unknown) => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0, + ), + ) + ) { + return invalidKnownState(); + } + const replicaKey = entry.replica.join(','); + if (replicas.has(replicaKey)) return invalidKnownState(); + replicas.add(replicaKey); + return { + replica: entry.replica, + frontier: entry.frontier, + ranges: entry.ranges, + }; + }); + entries.sort((a: any, b: any) => { + const length = Math.min(a.replica.length, b.replica.length); + for (let i = 0; i < length; i += 1) { + if (a.replica[i] !== b.replica[i]) return a.replica[i] - b.replica[i]; + } + return a.replica.length - b.replica.length; + }); + + const canonical = utf8ToBytes(JSON.stringify({ entries })); + if (canonical.length !== bytes.length || canonical.some((byte, index) => byte !== bytes[index])) { + return invalidKnownState(); + } + return bytes; +} + +function assertPolicyOperation(op: Operation): void { + const hasKnownState = op.meta.knownState !== undefined && op.meta.knownState.length > 0; + if (op.kind.type === 'delete' && !hasKnownState) { + throw new Error('delete operations require non-empty knownState'); + } + if (op.kind.type !== 'delete' && hasKnownState) { + throw new Error('knownState is only allowed on delete operations'); + } +} + +function assertV1Operation(op: Operation): void { + if (requiresTreecrdtOpSigV2(op)) { + throw new Error('operation requires a v2 signature'); + } +} + +export function encodeTreecrdtOpSigInputV1(opts: { docId: string; op: Operation }): Uint8Array { + return concatBytes(OP_SIG_V1_DOMAIN, u8(0), encodeTreecrdtOpFields(opts)); +} + +export function encodeTreecrdtOpSigInputV2(opts: { docId: string; op: Operation }): Uint8Array { + return concatBytes( + OP_SIG_V2_DOMAIN, + u8(0), + encodeTreecrdtOpFields(opts), + encodeKnownStateV2(opts.op), + ); +} + +/** @deprecated Use `signTreecrdtOp`; v1 cannot authenticate defensive-delete state. */ export async function signTreecrdtOpV1(opts: { docId: string; op: Operation; privateKey: Uint8Array; }): Promise { + assertV1Operation(opts.op); const msg = encodeTreecrdtOpSigInputV1({ docId: opts.docId, op: opts.op }); return signEd25519(msg, opts.privateKey); } +export async function signTreecrdtOpV2(opts: { + docId: string; + op: Operation; + privateKey: Uint8Array; +}): Promise { + const msg = encodeTreecrdtOpSigInputV2({ docId: opts.docId, op: opts.op }); + return signEd25519(msg, opts.privateKey); +} + +export async function signTreecrdtOp(opts: { + docId: string; + op: Operation; + privateKey: Uint8Array; +}): Promise { + assertPolicyOperation(opts.op); + return requiresTreecrdtOpSigV2(opts.op) ? signTreecrdtOpV2(opts) : signTreecrdtOpV1(opts); +} + +/** @deprecated Use `verifyTreecrdtOp`; v1 cannot authenticate defensive-delete state. */ export async function verifyTreecrdtOpV1(opts: { docId: string; op: Operation; signature: Uint8Array; publicKey: Uint8Array; }): Promise { + assertV1Operation(opts.op); const msg = encodeTreecrdtOpSigInputV1({ docId: opts.docId, op: opts.op }); return await verifyEd25519(opts.signature, msg, opts.publicKey); } + +export async function verifyTreecrdtOpV2(opts: { + docId: string; + op: Operation; + signature: Uint8Array; + publicKey: Uint8Array; +}): Promise { + const msg = encodeTreecrdtOpSigInputV2({ docId: opts.docId, op: opts.op }); + return await verifyEd25519(opts.signature, msg, opts.publicKey); +} + +export async function verifyTreecrdtOp(opts: { + docId: string; + op: Operation; + signature: Uint8Array; + publicKey: Uint8Array; +}): Promise { + assertPolicyOperation(opts.op); + return requiresTreecrdtOpSigV2(opts.op) ? verifyTreecrdtOpV2(opts) : verifyTreecrdtOpV1(opts); +} diff --git a/packages/treecrdt-auth/src/treecrdt-auth.ts b/packages/treecrdt-auth/src/treecrdt-auth.ts index e003b167..538c86ba 100644 --- a/packages/treecrdt-auth/src/treecrdt-auth.ts +++ b/packages/treecrdt-auth/src/treecrdt-auth.ts @@ -13,8 +13,13 @@ export type { export { encodeTreecrdtOpSigInputV1, + encodeTreecrdtOpSigInputV2, + signTreecrdtOp, signTreecrdtOpV1, + signTreecrdtOpV2, + verifyTreecrdtOp, verifyTreecrdtOpV1, + verifyTreecrdtOpV2, } from './internal/op-sig.js'; export type { TreecrdtScopeEvaluator } from './internal/scope.js'; diff --git a/packages/treecrdt-auth/tests/auth.test.ts b/packages/treecrdt-auth/tests/auth.test.ts index 336f7375..f7f20c98 100644 --- a/packages/treecrdt-auth/tests/auth.test.ts +++ b/packages/treecrdt-auth/tests/auth.test.ts @@ -13,6 +13,7 @@ import { createInMemoryConnectedPeers } from '@treecrdt/sync-protocol/in-memory' import { treecrdtSyncV0ProtobufCodec } from '@treecrdt/sync-protocol/protobuf'; import { base64urlEncode } from '../dist/base64url.js'; import { coseSign1Ed25519, deriveTokenIdV1 } from '../dist/cose.js'; +import { signEd25519, verifyEd25519 } from '../dist/ed25519.js'; import { createTreecrdtIdentityChainCapabilityV1, issueDeviceCertV1, @@ -21,9 +22,13 @@ import { import { createTreecrdtCoseCwtAuth, describeTreecrdtCapabilityTokenV1, + encodeTreecrdtOpSigInputV1, issueTreecrdtCapabilityTokenV1, issueTreecrdtDelegatedCapabilityTokenV1, issueTreecrdtRevocationRecordV1, + signTreecrdtOpV1, + verifyTreecrdtOpV1, + verifyTreecrdtOpV2, verifyTreecrdtRevocationRecordV1, } from '../dist/treecrdt-auth.js'; import type { Capability, Filter, OpRef, SyncBackend } from '@treecrdt/sync-protocol'; @@ -307,10 +312,18 @@ test('auth: signOps selects proof_ref per op when multiple tokens exist', async node: nodeIdFromInt(1), orderKey: orderKeyFromPosition(0), }); - const opDelete = makeOp(aPk, 2, 2, { - type: 'delete', - node: nodeIdFromInt(1), - }); + const knownState = (frontier: number) => + new TextEncoder().encode( + JSON.stringify({ entries: [{ replica: Array.from(aPk), frontier, ranges: [] }] }), + ); + const opDelete: Operation = { + meta: { + id: { replica: aPk, counter: 2 }, + lamport: 2, + knownState: knownState(1), + }, + kind: { type: 'delete', node: nodeIdFromInt(1) }, + }; const ops = [opInsert, opDelete]; const ctx = { docId, purpose: 'reconcile' as const, filterId: 'all' }; @@ -329,6 +342,52 @@ test('auth: signOps selects proof_ref per op when multiple tokens exist', async await authB.verifyOps?.(ops, auth, ctx); + await expect( + verifyTreecrdtOpV1({ + docId, + op: opInsert, + signature: auth![0]!.sig, + publicKey: aPk, + }), + ).resolves.toBe(true); + await expect( + verifyTreecrdtOpV2({ + docId, + op: opDelete, + signature: auth![1]!.sig, + publicKey: aPk, + }), + ).resolves.toBe(true); + + const changedState: Operation = { + ...opDelete, + meta: { ...opDelete.meta, knownState: knownState(2) }, + }; + await expect(authB.verifyOps?.([changedState], [auth![1]!], ctx)).rejects.toThrow( + /invalid op signature/i, + ); + + const strippedState: Operation = { + ...opDelete, + meta: { id: opDelete.meta.id, lamport: opDelete.meta.lamport }, + }; + await expect(authA.signOps?.([strippedState], ctx)).rejects.toThrow(/require.*knownState/i); + await expect(authB.verifyOps?.([strippedState], [auth![1]!], ctx)).rejects.toThrow( + /require.*knownState/i, + ); + + // V1 did not cover knownState. Even after a relay strips it, the delete kind still selects v2. + const legacySig = signEd25519(encodeTreecrdtOpSigInputV1({ docId, op: opDelete }), aSk); + await expect( + verifyEd25519(legacySig, encodeTreecrdtOpSigInputV1({ docId, op: strippedState }), aPk), + ).resolves.toBe(true); + await expect( + verifyTreecrdtOpV1({ docId, op: strippedState, signature: legacySig, publicKey: aPk }), + ).rejects.toThrow(/require.*knownState/i); + await expect( + authB.verifyOps?.([strippedState], [{ sig: legacySig, proofRef: tokenDeleteId }], ctx), + ).rejects.toThrow(/require.*knownState/i); + const badAuth = [{ ...auth?.[0]!, proofRef: tokenDeleteId }, auth?.[1]!]; await expect(authB.verifyOps?.(ops, badAuth, ctx)).rejects.toThrow( /capability does not allow op/i, diff --git a/packages/treecrdt-auth/tests/op-sig.test.ts b/packages/treecrdt-auth/tests/op-sig.test.ts new file mode 100644 index 00000000..672f6ad6 --- /dev/null +++ b/packages/treecrdt-auth/tests/op-sig.test.ts @@ -0,0 +1,115 @@ +import { expect, test } from 'vitest'; +import { hashes as ed25519Hashes, getPublicKey, utils as ed25519Utils } from '@noble/ed25519'; +import { sha512 } from '@noble/hashes/sha512'; + +import type { Operation } from '@treecrdt/interface'; +import { + encodeTreecrdtOpSigInputV1, + encodeTreecrdtOpSigInputV2, + signTreecrdtOp, + signTreecrdtOpV1, + verifyTreecrdtOpV1, +} from '../dist/treecrdt-auth.js'; + +ed25519Hashes.sha512 = sha512; + +function hex(value: Uint8Array): string { + return Buffer.from(value).toString('hex'); +} + +test('op signature inputs have stable v1 and v2 vectors', () => { + const op: Operation = { + meta: { + id: { replica: Uint8Array.from({ length: 32 }, (_, index) => index), counter: 9 }, + lamport: 17, + knownState: new TextEncoder().encode('{"entries":[]}'), + }, + kind: { type: 'delete', node: '00112233445566778899aabbccddeeff' }, + }; + + expect(hex(encodeTreecrdtOpSigInputV1({ docId: 'doc-vector', op }))).toBe( + '74726565637264742f6f702d7369672f7631000000000a646f632d766563746f7200000020000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f000000000000000900000000000000110300112233445566778899aabbccddeeff', + ); + expect(hex(encodeTreecrdtOpSigInputV2({ docId: 'doc-vector', op }))).toBe( + '74726565637264742f6f702d7369672f7632000000000a646f632d766563746f7200000020000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f000000000000000900000000000000110300112233445566778899aabbccddeeff010000000e7b22656e7472696573223a5b5d7d', + ); +}); + +test('v2 rejects storage-unstable knownState spellings', () => { + const op: Operation = { + meta: { + id: { replica: new Uint8Array(32), counter: 1 }, + lamport: 1, + knownState: new TextEncoder().encode('{ "entries": [] }'), + }, + kind: { type: 'delete', node: '00112233445566778899aabbccddeeff' }, + }; + + expect(() => encodeTreecrdtOpSigInputV2({ docId: 'doc-vector', op })).toThrow(/canonical/i); +}); + +test('policy signatures only allow knownState on deletes', async () => { + const privateKey = ed25519Utils.randomSecretKey(); + const publicKey = await getPublicKey(privateKey); + const meta = { + id: { replica: new Uint8Array(32), counter: 1 }, + lamport: 1, + }; + const knownState = new TextEncoder().encode('{"entries":[]}'); + const insert: Operation = { + meta: { ...meta, knownState }, + kind: { + type: 'insert', + parent: '00000000000000000000000000000000', + node: '00112233445566778899aabbccddeeff', + orderKey: new Uint8Array([1]), + }, + }; + const tombstone: Operation = { + meta, + kind: { type: 'tombstone', node: '00112233445566778899aabbccddeeff' }, + }; + const deleteOp: Operation = { + meta: { ...meta, knownState }, + kind: { type: 'delete', node: '00112233445566778899aabbccddeeff' }, + }; + const statefulNonDeletes: Operation[] = [ + insert, + { + meta: { ...meta, knownState }, + kind: { + type: 'move', + node: '00112233445566778899aabbccddeeff', + newParent: '00000000000000000000000000000000', + orderKey: new Uint8Array([1]), + }, + }, + { + meta: { ...meta, knownState }, + kind: { type: 'payload', node: '00112233445566778899aabbccddeeff', payload: null }, + }, + { ...tombstone, meta: { ...meta, knownState } }, + ]; + + for (const op of statefulNonDeletes) { + await expect(signTreecrdtOp({ docId: 'doc-vector', op, privateKey })).rejects.toThrow( + /only allowed on delete/i, + ); + } + for (const op of [deleteOp, tombstone]) { + await expect(signTreecrdtOpV1({ docId: 'doc-vector', op, privateKey })).rejects.toThrow( + /requires a v2 signature/i, + ); + await expect( + verifyTreecrdtOpV1({ + docId: 'doc-vector', + op, + signature: new Uint8Array(64), + publicKey, + }), + ).rejects.toThrow(/requires a v2 signature/i); + } + await expect( + signTreecrdtOp({ docId: 'doc-vector', op: tombstone, privateKey }), + ).resolves.toHaveLength(64); +});