Skip to content

Commit 87b2ba2

Browse files
Marcus PousetteMarcus Pousette
authored andcommitted
fix(auth): bind defensive delete state in v2 signatures
1 parent 739bf48 commit 87b2ba2

7 files changed

Lines changed: 134 additions & 12 deletions

File tree

.changeset/authored-at-op-auth-claims.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,7 @@
55
'@treecrdt/sync-postgres': minor
66
---
77

8-
Add signed `authoredAtMs` claims to op auth and persist them through sync proof material stores.
8+
Add opt-in signed `authoredAtMs` claims to op auth and persist them through sync proof material
9+
stores. Version 2 signatures also bind the operation's defensive-delete `knownState`; the auth
10+
layer refuses to sign or accept `knownState` under legacy v1 signatures because v1 does not cover
11+
those causally meaningful bytes.

docs/sync/v0/auth.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ Each op may carry:
6060
- `sig`: Ed25519 signature bytes (64 bytes)
6161
- `proof_ref` (optional but RECOMMENDED): 16-byte reference to the capability token used
6262
to authorize this op
63+
- `claims.authored_at_ms` (optional): author-claimed wall-clock time covered by a v2 signature
6364

6465
Rationale:
6566

@@ -177,8 +178,10 @@ Ops are signed with the doc-scoped Ed25519 key. The signature covers:
177178
- `doc_id`
178179
- `op_id` (`replica_id` + `counter`)
179180
- `lamport`
181+
- defensive-delete `known_state` in signature v2
180182
- op kind + fields
181183
- payload bytes (ciphertext bytes if payloads are encrypted)
184+
- v2 auth claims
182185

183186
### Canonical signing bytes
184187

@@ -213,6 +216,26 @@ Kind tags and fields:
213216
- value_tag(u8): 0=clear, 1=payload
214217
- if value_tag=1: u32_be(len(payload)) || payload_bytes
215218

219+
Signature v1 predates defensive deletion and does **not** cover `known_state`. It remains supported
220+
for operations without `known_state`, but MUST NOT be used for a causally aware delete or tombstone.
221+
222+
Signature v2 changes the domain to `"treecrdt/op-sig/v2"` and appends these fields after the v1 op
223+
fields (without the v1 domain):
224+
225+
```
226+
known_state = absent: u8(0)
227+
present: u8(1) || u32_be(len(bytes)) || bytes
228+
229+
authored_at = absent: u8(0)
230+
present: u8(1) || u64_be(authored_at_ms)
231+
```
232+
233+
The reference COSE+CWT auth layer uses the presence of signed claims to distinguish v2 auth. New
234+
signers therefore keep v1 as the default for rolling-upgrade compatibility. Set
235+
`includeAuthoredAt: true` only after all verifiers that may receive those operations support v2.
236+
Until then, signing or verifying an operation with `known_state` fails closed; accepting it as v1
237+
would allow an untrusted relay to change deletion causality without invalidating the signature.
238+
216239
## Subtree scope enforcement and `pending_context`
217240

218241
Subtree ACLs require the verifier to answer: “is the node touched by this op within the granted subtree?”

examples/playground/src/playground/hooks/usePlaygroundAuthSession.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ export function usePlaygroundAuthSession(
128128
},
129129
revokedCapabilityTokenIds: hardRevokedTokenIdBytes,
130130
requireProofRef: true,
131+
// Playground peers ship verifier and signer together, so v2 can be enabled immediately.
132+
includeAuthoredAt: true,
131133
identity: {
132134
onPeer: onPeerIdentityChain,
133135
local: getLocalIdentityChain,

packages/treecrdt-auth/src/internal/cose-cwt-auth.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,13 @@ export type TreecrdtCoseCwtAuthOptions = {
9393
) => boolean | Promise<boolean>;
9494
allowUnsigned?: boolean;
9595
requireProofRef?: boolean;
96-
/** Include a signed `claims.authoredAtMs` value on locally authored ops. Defaults to true. */
96+
/**
97+
* Opt into v2 op signatures and include a signed `claims.authoredAtMs` value.
98+
*
99+
* Defaults to false so a signer upgrade does not make its operations unreadable to v1-only
100+
* verifiers. Upgrade verifiers before enabling this option. Operations with `meta.knownState`
101+
* require v2 and fail closed while this option is disabled.
102+
*/
97103
includeAuthoredAt?: boolean;
98104
now?: () => number;
99105
/** Wall-clock source for authoredAtMs claims. */
@@ -121,7 +127,7 @@ export function createTreecrdtCoseCwtAuth(opts: TreecrdtCoseCwtAuthOptions): Syn
121127
const nowMs = opts.nowMs ?? (() => Date.now());
122128
const allowUnsigned = opts.allowUnsigned ?? false;
123129
const requireProofRef = opts.requireProofRef ?? false;
124-
const includeAuthoredAt = opts.includeAuthoredAt ?? true;
130+
const includeAuthoredAt = opts.includeAuthoredAt ?? false;
125131

126132
const localTokens = opts.localCapabilityTokens ?? [];
127133
const localTokenIds = localTokens.map((t) => deriveTokenIdV1(t));
@@ -661,6 +667,11 @@ export function createTreecrdtCoseCwtAuth(opts: TreecrdtCoseCwtAuthOptions): Syn
661667
});
662668
proofRef = selected.tokenId;
663669
}
670+
if (op.meta.knownState !== undefined && !includeAuthoredAt) {
671+
throw new Error(
672+
'refusing to sign meta.knownState with legacy op signature v1; enable includeAuthoredAt only after upgrading verifiers to v2',
673+
);
674+
}
664675
const claims = includeAuthoredAt ? { authoredAtMs: nowMs() } : undefined;
665676
const sig = claims
666677
? await signTreecrdtOpV2({
@@ -778,6 +789,10 @@ export function createTreecrdtCoseCwtAuth(opts: TreecrdtCoseCwtAuthOptions): Syn
778789
});
779790
if (scopeRes === 'deny') throw new Error('capability does not allow op');
780791

792+
if (!a.claims && op.meta.knownState !== undefined) {
793+
throw new Error('legacy op signature v1 does not authenticate meta.knownState');
794+
}
795+
781796
const ok = a.claims
782797
? await verifyTreecrdtOpV2({
783798
docId: ctx.docId,

packages/treecrdt-auth/src/internal/op-sig.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,12 @@ function encodeTreecrdtOpAuthClaimsV1(claims: TreecrdtOpAuthClaimsV1): Uint8Arra
109109
return concatBytes(...parts);
110110
}
111111

112+
function encodeTreecrdtOpKnownStateV2(op: Operation): Uint8Array {
113+
const knownState = op.meta.knownState;
114+
if (knownState === undefined) return u8(0);
115+
return concatBytes(u8(1), u32be(knownState.length), knownState);
116+
}
117+
112118
export function encodeTreecrdtOpSigInputV1(opts: { docId: string; op: Operation }): Uint8Array {
113119
return concatBytes(OP_SIG_V1_DOMAIN, u8(0), encodeTreecrdtOpFields(opts));
114120
}
@@ -122,6 +128,7 @@ export function encodeTreecrdtOpSigInputV2(opts: {
122128
OP_SIG_V2_DOMAIN,
123129
u8(0),
124130
encodeTreecrdtOpFields(opts),
131+
encodeTreecrdtOpKnownStateV2(opts.op),
125132
encodeTreecrdtOpAuthClaimsV1(opts.claims),
126133
);
127134
}

packages/treecrdt-auth/tests/auth.test.ts

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
issueTreecrdtCapabilityTokenV1,
2525
issueTreecrdtDelegatedCapabilityTokenV1,
2626
issueTreecrdtRevocationRecordV1,
27+
signTreecrdtOpV1,
2728
verifyTreecrdtRevocationRecordV1,
2829
} from '../dist/treecrdt-auth.js';
2930
import type { Capability, Filter, OpRef, SyncBackend } from '@treecrdt/sync-protocol';
@@ -320,6 +321,8 @@ test('auth: signOps selects proof_ref per op when multiple tokens exist', async
320321
expect(auth?.length).toBe(2);
321322
expect(auth?.[0]?.proofRef).toBeTruthy();
322323
expect(auth?.[1]?.proofRef).toBeTruthy();
324+
expect(auth?.[0]?.claims).toBeUndefined();
325+
expect(auth?.[1]?.claims).toBeUndefined();
323326

324327
const tokenStructureId = deriveTokenIdV1(tokenStructure);
325328
const tokenDeleteId = deriveTokenIdV1(tokenDelete);
@@ -335,9 +338,8 @@ test('auth: signOps selects proof_ref per op when multiple tokens exist', async
335338
);
336339
});
337340

338-
test('auth: signOps signs authoredAt claims', async () => {
341+
test('auth: v2 signs authoredAt and defensive-delete knownState', async () => {
339342
const docId = 'doc-auth-authored-at';
340-
const root = '0'.repeat(32);
341343
const authoredAtMs = 1_700_000_000_123;
342344

343345
const issuerSk = ed25519Utils.randomSecretKey();
@@ -351,7 +353,7 @@ test('auth: signOps signs authoredAt claims', async () => {
351353
issuerPrivateKey: issuerSk,
352354
subjectPublicKey: writerPk,
353355
docId,
354-
actions: ['write_structure'],
356+
actions: ['delete'],
355357
});
356358

357359
const authWriter = createTreecrdtCoseCwtAuth({
@@ -360,6 +362,7 @@ test('auth: signOps signs authoredAt claims', async () => {
360362
localPublicKey: writerPk,
361363
localCapabilityTokens: [token],
362364
requireProofRef: true,
365+
includeAuthoredAt: true,
363366
nowMs: () => authoredAtMs,
364367
});
365368

@@ -376,19 +379,54 @@ test('auth: signOps signs authoredAt claims', async () => {
376379
{ docId },
377380
);
378381

379-
const op = makeOp(writerPk, 1, 1, {
380-
type: 'insert',
381-
parent: root,
382-
node: nodeIdFromInt(1),
383-
orderKey: orderKeyFromPosition(0),
382+
const op: Operation = {
383+
meta: {
384+
id: { replica: writerPk, counter: 1 },
385+
lamport: 1,
386+
knownState: new Uint8Array([0x01, 0x02, 0x03]),
387+
},
388+
kind: { type: 'delete', node: nodeIdFromInt(1) },
389+
};
390+
391+
const legacyWriter = createTreecrdtCoseCwtAuth({
392+
issuerPublicKeys: [issuerPk],
393+
localPrivateKey: writerSk,
394+
localPublicKey: writerPk,
395+
localCapabilityTokens: [token],
396+
requireProofRef: true,
384397
});
385398

386399
const ctx = { docId, purpose: 'reconcile' as const, filterId: 'all' };
400+
await expect(legacyWriter.signOps?.([op], ctx)).rejects.toThrow(
401+
/refusing to sign meta\.knownState.*v1/i,
402+
);
403+
404+
const legacySig = await signTreecrdtOpV1({ docId, op, privateKey: writerSk });
405+
await expect(
406+
authVerifier.verifyOps?.([op], [{ sig: legacySig, proofRef: deriveTokenIdV1(token) }], ctx),
407+
).rejects.toThrow(/v1 does not authenticate meta\.knownState/i);
408+
387409
const auth = await authWriter.signOps?.([op], ctx);
388410
expect(auth?.[0]?.claims).toEqual({ authoredAtMs });
389411

390412
await expect(authVerifier.verifyOps?.([op], auth, ctx)).resolves.toBeUndefined();
391413

414+
const mutatedKnownState: Operation = {
415+
...op,
416+
meta: { ...op.meta, knownState: new Uint8Array([0x01, 0x02, 0x04]) },
417+
};
418+
await expect(authVerifier.verifyOps?.([mutatedKnownState], auth, ctx)).rejects.toThrow(
419+
/invalid op signature/i,
420+
);
421+
422+
const omittedKnownState: Operation = {
423+
...op,
424+
meta: { id: op.meta.id, lamport: op.meta.lamport },
425+
};
426+
await expect(authVerifier.verifyOps?.([omittedKnownState], auth, ctx)).rejects.toThrow(
427+
/invalid op signature/i,
428+
);
429+
392430
await expect(
393431
authVerifier.verifyOps?.(
394432
[op],
@@ -408,7 +446,7 @@ test('auth: signOps signs authoredAt claims', async () => {
408446
],
409447
ctx,
410448
),
411-
).rejects.toThrow(/invalid op signature/i);
449+
).rejects.toThrow(/v1 does not authenticate meta\.knownState/i);
412450
});
413451

414452
test('auth ignores foreign peer capability tokens during hello and still verifies known authors', async () => {
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { expect, test } from 'vitest';
2+
3+
import type { Operation } from '@treecrdt/interface';
4+
import { encodeTreecrdtOpSigInputV1, encodeTreecrdtOpSigInputV2 } from '../dist/treecrdt-auth.js';
5+
6+
const DOC_ID = 'doc-vector';
7+
const CLAIMS = { authoredAtMs: 1_700_000_000_123 };
8+
9+
function hex(value: Uint8Array): string {
10+
return Buffer.from(value).toString('hex');
11+
}
12+
13+
function deleteOp(knownState: Uint8Array | undefined): Operation {
14+
const replica = Uint8Array.from({ length: 32 }, (_, index) => index);
15+
return {
16+
meta: {
17+
id: { replica, counter: 9 },
18+
lamport: 17,
19+
...(knownState === undefined ? {} : { knownState }),
20+
},
21+
kind: { type: 'delete', node: '00112233445566778899aabbccddeeff' },
22+
};
23+
}
24+
25+
test('op signature inputs have stable v1 and v2 vectors', () => {
26+
const op = deleteOp(new TextEncoder().encode('{"a":1}'));
27+
28+
expect(hex(encodeTreecrdtOpSigInputV1({ docId: DOC_ID, op }))).toBe(
29+
'74726565637264742f6f702d7369672f7631000000000a646f632d766563746f7200000020000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f000000000000000900000000000000110300112233445566778899aabbccddeeff',
30+
);
31+
expect(hex(encodeTreecrdtOpSigInputV2({ docId: DOC_ID, op, claims: CLAIMS }))).toBe(
32+
'74726565637264742f6f702d7369672f7632000000000a646f632d766563746f7200000020000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f000000000000000900000000000000110300112233445566778899aabbccddeeff01000000077b2261223a317d010000018bcfe5687b',
33+
);
34+
});

0 commit comments

Comments
 (0)