@oxy.so/protocol 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +16 -0
  3. package/dist/cjs/.tsbuildinfo +1 -0
  4. package/dist/cjs/chain/continuity.js +54 -0
  5. package/dist/cjs/chain/engine.js +34 -0
  6. package/dist/cjs/chain/recordStore.js +25 -0
  7. package/dist/cjs/chain/types.js +22 -0
  8. package/dist/cjs/chain/verify.js +82 -0
  9. package/dist/cjs/envelope/canonicalJson.js +107 -0
  10. package/dist/cjs/envelope/recordId.js +60 -0
  11. package/dist/cjs/envelope/sign.js +75 -0
  12. package/dist/cjs/envelope/signingInput.js +32 -0
  13. package/dist/cjs/identity/resolver.js +50 -0
  14. package/dist/cjs/index.js +71 -0
  15. package/dist/cjs/node/constants.js +85 -0
  16. package/dist/cjs/node/didWebResolver.js +126 -0
  17. package/dist/cjs/node/httpFetch.js +61 -0
  18. package/dist/cjs/node/index.js +71 -0
  19. package/dist/cjs/node/nodeApp.js +344 -0
  20. package/dist/cjs/node/nodeClient.js +204 -0
  21. package/dist/cjs/node/rateLimit.js +187 -0
  22. package/dist/cjs/node/verifyRecord.js +51 -0
  23. package/dist/cjs/platform/crypto.js +186 -0
  24. package/dist/cjs/platform/crypto.native.js +204 -0
  25. package/dist/cjs/platform/expoTypes.js +24 -0
  26. package/dist/cjs/platform/platform.js +33 -0
  27. package/dist/cjs/secp256k1.js +148 -0
  28. package/dist/cjs/transparency/checkpoint.js +79 -0
  29. package/dist/cjs/transparency/tree.js +197 -0
  30. package/dist/esm/.tsbuildinfo +1 -0
  31. package/dist/esm/chain/continuity.js +51 -0
  32. package/dist/esm/chain/engine.js +31 -0
  33. package/dist/esm/chain/recordStore.js +24 -0
  34. package/dist/esm/chain/types.js +19 -0
  35. package/dist/esm/chain/verify.js +78 -0
  36. package/dist/esm/envelope/canonicalJson.js +104 -0
  37. package/dist/esm/envelope/recordId.js +56 -0
  38. package/dist/esm/envelope/sign.js +69 -0
  39. package/dist/esm/envelope/signingInput.js +29 -0
  40. package/dist/esm/identity/resolver.js +47 -0
  41. package/dist/esm/index.js +36 -0
  42. package/dist/esm/node/constants.js +82 -0
  43. package/dist/esm/node/didWebResolver.js +122 -0
  44. package/dist/esm/node/httpFetch.js +55 -0
  45. package/dist/esm/node/index.js +28 -0
  46. package/dist/esm/node/nodeApp.js +336 -0
  47. package/dist/esm/node/nodeClient.js +198 -0
  48. package/dist/esm/node/rateLimit.js +182 -0
  49. package/dist/esm/node/verifyRecord.js +48 -0
  50. package/dist/esm/platform/crypto.js +145 -0
  51. package/dist/esm/platform/crypto.native.js +196 -0
  52. package/dist/esm/platform/expoTypes.js +23 -0
  53. package/dist/esm/platform/platform.js +29 -0
  54. package/dist/esm/secp256k1.js +137 -0
  55. package/dist/esm/transparency/checkpoint.js +73 -0
  56. package/dist/esm/transparency/tree.js +189 -0
  57. package/dist/types/.tsbuildinfo +1 -0
  58. package/dist/types/chain/continuity.d.ts +28 -0
  59. package/dist/types/chain/engine.d.ts +27 -0
  60. package/dist/types/chain/recordStore.d.ts +85 -0
  61. package/dist/types/chain/types.d.ts +79 -0
  62. package/dist/types/chain/verify.d.ts +45 -0
  63. package/dist/types/envelope/canonicalJson.d.ts +44 -0
  64. package/dist/types/envelope/recordId.d.ts +30 -0
  65. package/dist/types/envelope/sign.d.ts +47 -0
  66. package/dist/types/envelope/signingInput.d.ts +33 -0
  67. package/dist/types/identity/resolver.d.ts +67 -0
  68. package/dist/types/index.d.ts +32 -0
  69. package/dist/types/node/constants.d.ts +80 -0
  70. package/dist/types/node/didWebResolver.d.ts +47 -0
  71. package/dist/types/node/httpFetch.d.ts +60 -0
  72. package/dist/types/node/index.d.ts +28 -0
  73. package/dist/types/node/nodeApp.d.ts +120 -0
  74. package/dist/types/node/nodeClient.d.ts +135 -0
  75. package/dist/types/node/rateLimit.d.ts +95 -0
  76. package/dist/types/node/verifyRecord.d.ts +41 -0
  77. package/dist/types/platform/crypto.d.ts +93 -0
  78. package/dist/types/platform/crypto.native.d.ts +77 -0
  79. package/dist/types/platform/expoTypes.d.ts +99 -0
  80. package/dist/types/platform/platform.d.ts +25 -0
  81. package/dist/types/secp256k1.d.ts +45 -0
  82. package/dist/types/transparency/checkpoint.d.ts +71 -0
  83. package/dist/types/transparency/tree.d.ts +135 -0
  84. package/package.json +157 -0
  85. package/src/__tests__/canonicalJson.test.ts +116 -0
  86. package/src/__tests__/chain.test.ts +279 -0
  87. package/src/__tests__/didWebResolver.test.ts +132 -0
  88. package/src/__tests__/envelope.test.ts +267 -0
  89. package/src/__tests__/nodeApp.test.ts +410 -0
  90. package/src/__tests__/nodeClient.test.ts +177 -0
  91. package/src/__tests__/nodeHarness.ts +151 -0
  92. package/src/__tests__/optionalNativePeers.test.ts +233 -0
  93. package/src/__tests__/rateLimit.test.ts +268 -0
  94. package/src/__tests__/runnerGuard.test.ts +85 -0
  95. package/src/__tests__/secp256k1.test.ts +118 -0
  96. package/src/__tests__/transparency.test.ts +353 -0
  97. package/src/chain/continuity.ts +59 -0
  98. package/src/chain/engine.ts +43 -0
  99. package/src/chain/recordStore.ts +98 -0
  100. package/src/chain/types.ts +85 -0
  101. package/src/chain/verify.ts +102 -0
  102. package/src/envelope/canonicalJson.ts +120 -0
  103. package/src/envelope/recordId.ts +63 -0
  104. package/src/envelope/sign.ts +86 -0
  105. package/src/envelope/signingInput.ts +48 -0
  106. package/src/identity/resolver.ts +90 -0
  107. package/src/index.ts +101 -0
  108. package/src/node/constants.ts +105 -0
  109. package/src/node/didWebResolver.ts +162 -0
  110. package/src/node/httpFetch.ts +88 -0
  111. package/src/node/index.ts +87 -0
  112. package/src/node/nodeApp.ts +471 -0
  113. package/src/node/nodeClient.ts +322 -0
  114. package/src/node/rateLimit.ts +233 -0
  115. package/src/node/verifyRecord.ts +60 -0
  116. package/src/platform/crypto.native.ts +251 -0
  117. package/src/platform/crypto.ts +172 -0
  118. package/src/platform/expoTypes.ts +99 -0
  119. package/src/platform/platform.ts +31 -0
  120. package/src/secp256k1.ts +207 -0
  121. package/src/transparency/checkpoint.ts +109 -0
  122. package/src/transparency/tree.ts +258 -0
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Transparency-log tests — leaf hashing, the Merkle tree, inclusion proofs, and
3
+ * the co-signable checkpoint.
4
+ *
5
+ * These lock the properties a third party depends on when auditing Oxy without
6
+ * trusting it: a root recomputable from a snapshot in any order, an inclusion
7
+ * proof verifiable from ONLY the verifier's own leaf (never the full leaf set),
8
+ * and a checkpoint whose signing bytes are identical for every independent
9
+ * co-signer (Oxy plus witness nodes).
10
+ */
11
+
12
+ import { generateSecp256k1KeyPair } from '../secp256k1';
13
+ import {
14
+ sha256,
15
+ transparencyLeafHash,
16
+ buildTransparencyTree,
17
+ buildTransparencyTreeFromHeads,
18
+ inclusionProof,
19
+ verifyInclusionProof,
20
+ EMPTY_TRANSPARENCY_ROOT,
21
+ checkpointSigningInput,
22
+ checkpointHash,
23
+ signCheckpoint,
24
+ verifyCheckpointSignature,
25
+ type TransparencyHeadEntry,
26
+ type TransparencyCheckpointFields,
27
+ } from '../index';
28
+
29
+ const HEAD_A: TransparencyHeadEntry = {
30
+ subjectDid: 'did:web:oxy.so:u:aaa',
31
+ seq: 4,
32
+ headRecordId: 'a'.repeat(64),
33
+ };
34
+ const HEAD_B: TransparencyHeadEntry = {
35
+ subjectDid: 'did:web:oxy.so:u:bbb',
36
+ seq: 0,
37
+ headRecordId: 'b'.repeat(64),
38
+ };
39
+ const HEAD_C: TransparencyHeadEntry = {
40
+ subjectDid: 'did:web:oxy.so:u:ccc',
41
+ seq: 17,
42
+ headRecordId: 'c'.repeat(64),
43
+ };
44
+
45
+ /** `n` distinct head entries, deterministic. */
46
+ function heads(n: number): TransparencyHeadEntry[] {
47
+ return Array.from({ length: n }, (_, i) => ({
48
+ subjectDid: `did:web:oxy.so:u:${String(i).padStart(3, '0')}`,
49
+ seq: i,
50
+ headRecordId: String(i).padStart(64, '0'),
51
+ }));
52
+ }
53
+
54
+ /**
55
+ * RFC 6962 §2.1 MTH, transcribed directly from the spec — the independent
56
+ * oracle the production bottom-up build is checked against.
57
+ *
58
+ * `buildTransparencyTree` builds levels iteratively (pair adjacent, carry a
59
+ * trailing odd node up) because that lets it keep the interior nodes for cheap
60
+ * proofs. This recursive split-at-the-largest-power-of-two definition is the
61
+ * normative one, and the equivalence between the two is an assumption the
62
+ * commitment rests on — so it is TESTED, never assumed.
63
+ */
64
+ async function rfc6962MerkleTreeHash(leaves: string[]): Promise<string> {
65
+ if (leaves.length === 0) {
66
+ return EMPTY_TRANSPARENCY_ROOT;
67
+ }
68
+ if (leaves.length === 1) {
69
+ return leaves[0];
70
+ }
71
+ let k = 1;
72
+ while (k * 2 < leaves.length) {
73
+ k *= 2;
74
+ }
75
+ const [left, right] = await Promise.all([
76
+ rfc6962MerkleTreeHash(leaves.slice(0, k)),
77
+ rfc6962MerkleTreeHash(leaves.slice(k)),
78
+ ]);
79
+ return sha256(`oxy.transparency.node.v1:${left}${right}`);
80
+ }
81
+
82
+ describe('transparencyLeafHash', () => {
83
+ it('is deterministic for the same head', async () => {
84
+ expect(await transparencyLeafHash(HEAD_A)).toBe(await transparencyLeafHash(HEAD_A));
85
+ });
86
+
87
+ it('changes when seq advances', async () => {
88
+ const before = await transparencyLeafHash(HEAD_A);
89
+ const after = await transparencyLeafHash({ ...HEAD_A, seq: HEAD_A.seq + 1 });
90
+ expect(after).not.toBe(before);
91
+ });
92
+
93
+ it('changes when the head record id changes', async () => {
94
+ const tampered = await transparencyLeafHash({ ...HEAD_A, headRecordId: 'd'.repeat(64) });
95
+ expect(tampered).not.toBe(await transparencyLeafHash(HEAD_A));
96
+ });
97
+
98
+ it('changes when the subject changes', async () => {
99
+ const other = await transparencyLeafHash({ ...HEAD_A, subjectDid: HEAD_B.subjectDid });
100
+ expect(other).not.toBe(await transparencyLeafHash(HEAD_A));
101
+ });
102
+
103
+ it('is domain-separated, so it is never a bare hash of the concatenated fields', async () => {
104
+ const bare = await sha256(`${HEAD_A.subjectDid}${HEAD_A.seq}${HEAD_A.headRecordId}`);
105
+ expect(await transparencyLeafHash(HEAD_A)).not.toBe(bare);
106
+ });
107
+ });
108
+
109
+ describe('buildTransparencyTree', () => {
110
+ it('gives the empty tree a fixed, domain-separated root', async () => {
111
+ const tree = await buildTransparencyTree([]);
112
+ expect(tree.treeSize).toBe(0);
113
+ expect(tree.root).toBe(EMPTY_TRANSPARENCY_ROOT);
114
+ });
115
+
116
+ it('pins the empty root to the hash of its domain string', async () => {
117
+ // The constant is hard-coded (the hash is async), so this pins it against
118
+ // an accidental edit: a changed empty root changes every empty checkpoint.
119
+ expect(EMPTY_TRANSPARENCY_ROOT).toBe(await sha256('oxy.transparency.empty.v1'));
120
+ });
121
+
122
+ it('uses the leaf itself as the root of a single-leaf tree', async () => {
123
+ const leaf = await transparencyLeafHash(HEAD_A);
124
+ const tree = await buildTransparencyTree([leaf]);
125
+ expect(tree.root).toBe(leaf);
126
+ expect(tree.treeSize).toBe(1);
127
+ });
128
+
129
+ it('hashes a two-leaf tree as one interior node over the ordered pair', async () => {
130
+ const l0 = await transparencyLeafHash(HEAD_A);
131
+ const l1 = await transparencyLeafHash(HEAD_B);
132
+ const tree = await buildTransparencyTree([l0, l1]);
133
+ expect(tree.root).toBe(await sha256(`oxy.transparency.node.v1:${l0}${l1}`));
134
+ });
135
+
136
+ it('is order-sensitive at the leaf level (swapping two leaves changes the root)', async () => {
137
+ const l0 = await transparencyLeafHash(HEAD_A);
138
+ const l1 = await transparencyLeafHash(HEAD_B);
139
+ const forward = await buildTransparencyTree([l0, l1]);
140
+ const reversed = await buildTransparencyTree([l1, l0]);
141
+ expect(reversed.root).not.toBe(forward.root);
142
+ });
143
+
144
+ it('splits an odd tree so the left subtree is the largest power of two below the size', async () => {
145
+ const l = await Promise.all([HEAD_A, HEAD_B, HEAD_C].map(transparencyLeafHash));
146
+ const left = await sha256(`oxy.transparency.node.v1:${l[0]}${l[1]}`);
147
+ const tree = await buildTransparencyTree(l);
148
+ expect(tree.root).toBe(await sha256(`oxy.transparency.node.v1:${left}${l[2]}`));
149
+ });
150
+
151
+ it('matches the RFC 6962 recursive definition at every size from 0 to 40', async () => {
152
+ for (let size = 0; size <= 40; size++) {
153
+ const leaves = await Promise.all(heads(size).map(transparencyLeafHash));
154
+ const tree = await buildTransparencyTree(leaves);
155
+ expect(tree.root).toBe(await rfc6962MerkleTreeHash(leaves));
156
+ expect(tree.levels[0]).toEqual(leaves);
157
+ }
158
+ });
159
+
160
+ it('keeps the leaf level independent of the caller array', async () => {
161
+ const leaves = await Promise.all(heads(3).map(transparencyLeafHash));
162
+ const tree = await buildTransparencyTree(leaves);
163
+ leaves[0] = 'f'.repeat(64);
164
+ expect(tree.levels[0][0]).not.toBe('f'.repeat(64));
165
+ });
166
+ });
167
+
168
+ describe('buildTransparencyTreeFromHeads', () => {
169
+ it('sorts by subject did, so the root does not depend on the input order', async () => {
170
+ const sorted = await buildTransparencyTreeFromHeads([HEAD_A, HEAD_B, HEAD_C]);
171
+ const shuffled = await buildTransparencyTreeFromHeads([HEAD_C, HEAD_A, HEAD_B]);
172
+ expect(shuffled.root).toBe(sorted.root);
173
+ expect(shuffled.treeSize).toBe(3);
174
+ });
175
+
176
+ it('reports the leaf index of each subject so a proof can be served', async () => {
177
+ const tree = await buildTransparencyTreeFromHeads([HEAD_C, HEAD_A, HEAD_B]);
178
+ expect(tree.indexBySubject[HEAD_A.subjectDid]).toBe(0);
179
+ expect(tree.indexBySubject[HEAD_B.subjectDid]).toBe(1);
180
+ expect(tree.indexBySubject[HEAD_C.subjectDid]).toBe(2);
181
+ });
182
+
183
+ it('rejects a duplicate subject rather than silently committing to one of them', async () => {
184
+ await expect(buildTransparencyTreeFromHeads([HEAD_A, HEAD_A])).rejects.toThrow(/duplicate/i);
185
+ });
186
+ });
187
+
188
+ describe('verifyInclusionProof', () => {
189
+ it('verifies every leaf of every tree size from 1 to 40', async () => {
190
+ for (let size = 1; size <= 40; size++) {
191
+ const entries = heads(size);
192
+ const tree = await buildTransparencyTreeFromHeads(entries);
193
+ for (let index = 0; index < size; index++) {
194
+ const leaf = await transparencyLeafHash(entries[index]);
195
+ const proof = inclusionProof(tree, index);
196
+ await expect(
197
+ verifyInclusionProof({ leaf, index, treeSize: size, proof, root: tree.root }),
198
+ ).resolves.toBe(true);
199
+ }
200
+ }
201
+ });
202
+
203
+ it('verifies from the leaf alone, without the rest of the leaf set', async () => {
204
+ const entries = heads(7);
205
+ const tree = await buildTransparencyTreeFromHeads(entries);
206
+ const proof = inclusionProof(tree, 5);
207
+ const leaf = await transparencyLeafHash(entries[5]);
208
+ // Only the verifier's own leaf, its index, the tree size, the path and the
209
+ // signed root — the full leaf set is never needed.
210
+ await expect(
211
+ verifyInclusionProof({ leaf, index: 5, treeSize: 7, proof, root: tree.root }),
212
+ ).resolves.toBe(true);
213
+ });
214
+
215
+ it('fails when the head record id was tampered with after the checkpoint', async () => {
216
+ const entries = heads(5);
217
+ const tree = await buildTransparencyTreeFromHeads(entries);
218
+ const proof = inclusionProof(tree, 2);
219
+ const tamperedLeaf = await transparencyLeafHash({ ...entries[2], headRecordId: 'f'.repeat(64) });
220
+ await expect(
221
+ verifyInclusionProof({ leaf: tamperedLeaf, index: 2, treeSize: 5, proof, root: tree.root }),
222
+ ).resolves.toBe(false);
223
+ });
224
+
225
+ it('fails when a chain was rolled back to an earlier seq', async () => {
226
+ const entries = heads(5);
227
+ const tree = await buildTransparencyTreeFromHeads(entries);
228
+ const proof = inclusionProof(tree, 3);
229
+ const rolledBack = await transparencyLeafHash({ ...entries[3], seq: entries[3].seq - 1 });
230
+ await expect(
231
+ verifyInclusionProof({ leaf: rolledBack, index: 3, treeSize: 5, proof, root: tree.root }),
232
+ ).resolves.toBe(false);
233
+ });
234
+
235
+ it('fails when the proof is replayed at a different index', async () => {
236
+ const entries = heads(6);
237
+ const tree = await buildTransparencyTreeFromHeads(entries);
238
+ const proof = inclusionProof(tree, 1);
239
+ const leaf = await transparencyLeafHash(entries[1]);
240
+ await expect(
241
+ verifyInclusionProof({ leaf, index: 2, treeSize: 6, proof, root: tree.root }),
242
+ ).resolves.toBe(false);
243
+ });
244
+
245
+ it('fails against the root of a different checkpoint', async () => {
246
+ const entries = heads(4);
247
+ const tree = await buildTransparencyTreeFromHeads(entries);
248
+ const otherTree = await buildTransparencyTreeFromHeads(heads(5));
249
+ const proof = inclusionProof(tree, 0);
250
+ const leaf = await transparencyLeafHash(entries[0]);
251
+ await expect(
252
+ verifyInclusionProof({ leaf, index: 0, treeSize: 4, proof, root: otherTree.root }),
253
+ ).resolves.toBe(false);
254
+ });
255
+
256
+ it('fails when the proof path is truncated', async () => {
257
+ const entries = heads(8);
258
+ const tree = await buildTransparencyTreeFromHeads(entries);
259
+ const proof = inclusionProof(tree, 0);
260
+ await expect(
261
+ verifyInclusionProof({
262
+ leaf: await transparencyLeafHash(entries[0]),
263
+ index: 0,
264
+ treeSize: 8,
265
+ proof: proof.slice(0, -1),
266
+ root: tree.root,
267
+ }),
268
+ ).resolves.toBe(false);
269
+ });
270
+
271
+ it('rejects an index outside the tree instead of verifying', async () => {
272
+ const entries = heads(3);
273
+ const tree = await buildTransparencyTreeFromHeads(entries);
274
+ expect(() => inclusionProof(tree, 3)).toThrow(/index/i);
275
+ });
276
+ });
277
+
278
+ describe('checkpoint signing', () => {
279
+ const fields: TransparencyCheckpointFields = {
280
+ index: 42,
281
+ periodEnd: 1_800_000_000_000,
282
+ treeSize: 3,
283
+ root: 'e'.repeat(64),
284
+ prevCheckpointHash: '9'.repeat(64),
285
+ };
286
+
287
+ it('produces stable, domain-separated signing bytes', async () => {
288
+ expect(checkpointSigningInput(fields)).toBe(checkpointSigningInput({ ...fields }));
289
+ expect(checkpointSigningInput(fields)).toContain('oxy.transparency.checkpoint.v1:');
290
+ });
291
+
292
+ it('does not depend on the key order of the fields object', () => {
293
+ const reordered: TransparencyCheckpointFields = {
294
+ prevCheckpointHash: fields.prevCheckpointHash,
295
+ root: fields.root,
296
+ treeSize: fields.treeSize,
297
+ periodEnd: fields.periodEnd,
298
+ index: fields.index,
299
+ };
300
+ expect(checkpointSigningInput(reordered)).toBe(checkpointSigningInput(fields));
301
+ });
302
+
303
+ it('changes the hash when the root changes', async () => {
304
+ const other = await checkpointHash({ ...fields, root: 'f'.repeat(64) });
305
+ expect(other).not.toBe(await checkpointHash(fields));
306
+ });
307
+
308
+ it('changes the hash when the previous checkpoint link changes', async () => {
309
+ const other = await checkpointHash({ ...fields, prevCheckpointHash: '8'.repeat(64) });
310
+ expect(other).not.toBe(await checkpointHash(fields));
311
+ });
312
+
313
+ it('verifies a signature made by the signer', async () => {
314
+ const key = generateSecp256k1KeyPair();
315
+ const signature = await signCheckpoint(fields, key.privateKey);
316
+ expect(signature.alg).toBe('ES256K-DER-SHA256');
317
+ expect(signature.publicKey).toBe(key.publicKey);
318
+ await expect(verifyCheckpointSignature(fields, signature)).resolves.toBe(true);
319
+ });
320
+
321
+ it('rejects a signature once any signed field is altered', async () => {
322
+ const key = generateSecp256k1KeyPair();
323
+ const signature = await signCheckpoint(fields, key.privateKey);
324
+ await expect(
325
+ verifyCheckpointSignature({ ...fields, treeSize: fields.treeSize + 1 }, signature),
326
+ ).resolves.toBe(false);
327
+ });
328
+
329
+ it('lets independent co-signers sign the identical bytes, so witnesses need no coordination', async () => {
330
+ const oxy = generateSecp256k1KeyPair();
331
+ const witnessOne = generateSecp256k1KeyPair();
332
+ const witnessTwo = generateSecp256k1KeyPair();
333
+ const signatures = await Promise.all(
334
+ [oxy, witnessOne, witnessTwo].map((k) => signCheckpoint(fields, k.privateKey)),
335
+ );
336
+ for (const signature of signatures) {
337
+ await expect(verifyCheckpointSignature(fields, signature)).resolves.toBe(true);
338
+ }
339
+ // Distinct signers, distinct signatures, one set of signed bytes.
340
+ expect(new Set(signatures.map((s) => s.signature)).size).toBe(3);
341
+ });
342
+
343
+ it('does not accept a signature from one checkpoint on the next one', async () => {
344
+ const key = generateSecp256k1KeyPair();
345
+ const signature = await signCheckpoint(fields, key.privateKey);
346
+ const next: TransparencyCheckpointFields = {
347
+ ...fields,
348
+ index: fields.index + 1,
349
+ prevCheckpointHash: await checkpointHash(fields),
350
+ };
351
+ await expect(verifyCheckpointSignature(next, signature)).resolves.toBe(false);
352
+ });
353
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Chain continuity — the pure "does this record extend the head by exactly one?"
3
+ * check, with NO storage or crypto dependency.
4
+ *
5
+ * This is the single definition of continuity that used to be duplicated in
6
+ * oxy-api (`verifyChainContinuity`) and the node store (`appendTxn`). The engine
7
+ * calls it with the head it read from the injected store; a store MAY call it
8
+ * again inside its atomic append, but the unique-index backstop (surfaced as
9
+ * `chain_conflict`) is the real race guard.
10
+ *
11
+ * v1 envelopes have no chain coordinates, so they always pass (the caller does
12
+ * not advance a chain for them).
13
+ */
14
+
15
+ import type { SignedRecordEnvelope } from '@oxy.so/contracts';
16
+ import type { ChainHead, VerifyOutcome } from './types';
17
+
18
+ /**
19
+ * True when `head` represents an actual existing chain (as opposed to `null` or
20
+ * the "no chain yet" sentinel head a store may return).
21
+ */
22
+ function hasChain(head: ChainHead | null): head is ChainHead & { headRecordId: string } {
23
+ return head !== null && head.headRecordId !== null && head.seq >= 0;
24
+ }
25
+
26
+ /**
27
+ * Check that `env` validly extends `head`:
28
+ *
29
+ * - **v1** (no chain coordinates): always `{ ok: true }` — v1 records are not
30
+ * chained.
31
+ * - **no head** (genesis position): only a genesis (`seq === 0`, `prev` null)
32
+ * is accepted; anything else is `chain_gap` (it claims to extend a chain that
33
+ * does not exist).
34
+ * - **head exists**: `env.prev` MUST equal `head.headRecordId` (else
35
+ * `chain_fork`, which also covers a re-genesis whose `prev` is `null`), and
36
+ * `env.seq` MUST equal `head.seq + 1` (else `bad_seq`).
37
+ */
38
+ export function checkContinuity(head: ChainHead | null, env: SignedRecordEnvelope): VerifyOutcome {
39
+ if (env.version !== 2) {
40
+ return { ok: true };
41
+ }
42
+
43
+ const isGenesis = env.seq === 0 && (env.prev === null || env.prev === undefined);
44
+
45
+ if (!hasChain(head)) {
46
+ if (!isGenesis) {
47
+ return { ok: false, reason: 'chain_gap' };
48
+ }
49
+ return { ok: true };
50
+ }
51
+
52
+ if (env.prev !== head.headRecordId) {
53
+ return { ok: false, reason: 'chain_fork' };
54
+ }
55
+ if (env.seq !== head.seq + 1) {
56
+ return { ok: false, reason: 'bad_seq' };
57
+ }
58
+ return { ok: true };
59
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Chain engine — verify-then-append orchestration.
3
+ *
4
+ * The one entry point an app's adapter calls to publish a record: it runs the
5
+ * full {@link verifyEnvelope} state machine, computes the content address
6
+ * (`recordId`), and hands the verified envelope to the injected
7
+ * {@link RecordStore} to persist atomically. The store owns the durable
8
+ * concurrency backstop (`chain_conflict` on a unique-index collision); the
9
+ * engine owns the verification + ordering policy.
10
+ *
11
+ * Storage and identity are both injected, so the engine has zero knowledge of
12
+ * Mongo/SQLite, Oxy DIDs, or any app's lexicon — exactly what makes it reusable.
13
+ */
14
+
15
+ import type { SignedRecordEnvelope } from '@oxy.so/contracts';
16
+ import { computeRecordId } from '../envelope/recordId';
17
+ import type { VerificationMethodResolver } from '../identity/resolver';
18
+ import { verifyEnvelope, type VerifyOptions } from './verify';
19
+ import type { RecordStore } from './recordStore';
20
+ import type { AppendOutcome } from './types';
21
+
22
+ /**
23
+ * Verify `env` and, if it passes, append it to the subject's chain.
24
+ *
25
+ * On a verification failure the rejection is returned WITHOUT touching the store.
26
+ * On success the (engine-computed) `recordId` is passed to `store.append`, whose
27
+ * own outcome — including the `chain_conflict` backstop on a concurrent-writer
28
+ * collision — is returned verbatim.
29
+ */
30
+ export async function verifyAndAppend(
31
+ store: RecordStore,
32
+ resolver: VerificationMethodResolver,
33
+ env: SignedRecordEnvelope,
34
+ opts: VerifyOptions = {},
35
+ ): Promise<AppendOutcome> {
36
+ const verification = await verifyEnvelope(store, resolver, env, opts);
37
+ if (!verification.ok) {
38
+ return verification;
39
+ }
40
+
41
+ const recordId = await computeRecordId(env);
42
+ return store.append(env.subject, env, recordId);
43
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Storage interfaces — the injected persistence the chain engine drives.
3
+ *
4
+ * The engine ({@link ./verify}, {@link ./engine}) is storage-agnostic: it owns
5
+ * the verification state machine and continuity logic, and delegates EVERY read
6
+ * and write to an injected {@link RecordStore}. An app supplies a store over its
7
+ * own backend (oxy-api over Mongo `SignedRecord`/`RepoHead`, a node over SQLite,
8
+ * Mention over its own Mongo) without the engine knowing anything Oxy- or
9
+ * app-specific.
10
+ *
11
+ * All methods are **subject-keyed**: `subject` is the chain's subject DID
12
+ * (`env.subject`). A store maps that DID to its own primary key (e.g. an Oxy
13
+ * userId) internally; the engine never sees that mapping.
14
+ *
15
+ * ## Concurrency contract
16
+ *
17
+ * `append` MUST be atomic (record insert + head advance in one unit) and MUST
18
+ * translate a duplicate-key collision on the unique `(subject, seq)` /
19
+ * `recordId` index — i.e. a concurrent writer that already took this `seq` — into
20
+ * `{ ok: false, reason: 'chain_conflict' }` (Mongo E11000 / SQLite
21
+ * `SQLITE_CONSTRAINT`). That is the real multi-writer race guard; the engine's
22
+ * pre-append continuity check is only the fast-path rejection.
23
+ */
24
+
25
+ import type { SignedRecordEnvelope } from '@oxy.so/contracts';
26
+ import type { AppendOutcome, ChainHead } from './types';
27
+
28
+ export interface RecordStore {
29
+ /** The subject's chain head, or `null` when the subject has no chain yet. */
30
+ getHead(subject: string): Promise<ChainHead | null>;
31
+
32
+ /**
33
+ * Atomically persist a verified envelope and advance the subject's chain.
34
+ *
35
+ * `recordId` is the engine-computed content address (`computeRecordId(env)`).
36
+ * Implementations MUST surface a duplicate-key collision as `chain_conflict`
37
+ * (see the concurrency contract above). v1 envelopes (no chain coordinates)
38
+ * are stored without advancing a chain and SHOULD report `seq: -1`.
39
+ */
40
+ append(subject: string, env: SignedRecordEnvelope, recordId: string): Promise<AppendOutcome>;
41
+
42
+ /**
43
+ * The ordered slice of the subject's chain with `seq > sinceSeq`, ascending by
44
+ * `seq`, capped at `limit`. Only chained (v2) records have a `seq`, so v1 rows
45
+ * are naturally excluded. Pass `sinceSeq = -1` to start from genesis.
46
+ */
47
+ getLogSince(subject: string, sinceSeq: number, limit: number): Promise<SignedRecordEnvelope[]>;
48
+
49
+ /**
50
+ * Resolve a `recordId` cursor to its chain `seq` (so a puller resumes from the
51
+ * last record it ingested), or `null` when no such record exists.
52
+ */
53
+ resolveCursorSeq(subject: string, recordId: string): Promise<number | null>;
54
+
55
+ /**
56
+ * The latest verified envelope for an AtProto-style `(collection, rkey)` key —
57
+ * the materialized "current" value (last-writer-wins by chain order), or
58
+ * `null` when no record exists for that key.
59
+ */
60
+ materializeCurrent(
61
+ subject: string,
62
+ collection: string,
63
+ rkey: string,
64
+ ): Promise<SignedRecordEnvelope | null>;
65
+
66
+ /**
67
+ * The `issuedAt` of the latest stored record for the envelope's LOGICAL key —
68
+ * the monotonicity frontier the engine compares against (replay/rollback
69
+ * defence). Scoping is the store's policy:
70
+ * - v2: per record key (`collection`, `rkey`) — last-writer-wins for THAT key.
71
+ * - v1: per `type` (the legacy identity/profile singletons).
72
+ *
73
+ * Returns `null` when there is no prior record (the record is the first of its
74
+ * key, so any `issuedAt` is acceptable).
75
+ */
76
+ latestIssuedAtForKey(subject: string, env: SignedRecordEnvelope): Promise<number | null>;
77
+ }
78
+
79
+ /**
80
+ * Content-addressed blob storage — the bytes a record's blob refs point at,
81
+ * keyed by their SHA-256 (`sha256`) content address.
82
+ *
83
+ * Separate from {@link RecordStore} because not every app stores blobs in the
84
+ * same place a record lives (oxy-api identity records carry no blobs; a node
85
+ * pins them; Mention rehosts to the Oxy CDN). `Uint8Array` rather than Node's
86
+ * `Buffer` so the interface stays platform-agnostic.
87
+ */
88
+ export interface BlobStore {
89
+ /**
90
+ * Pin `bytes` under their content address `hash`. Implementations MUST validate
91
+ * that `bytes` actually hash to `hash`, and SHOULD be idempotent (re-pinning
92
+ * the same hash is a no-op).
93
+ */
94
+ putBlob(hash: string, bytes: Uint8Array): Promise<void>;
95
+
96
+ /** The bytes of a pinned blob, or `null` when absent. */
97
+ getBlob(hash: string): Promise<Uint8Array | null>;
98
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Chain engine types — the per-subject hash-chain vocabulary.
3
+ *
4
+ * A "chain" is a single signer's append-only log of signed-record envelopes
5
+ * ("personal blockchain": one signer, no consensus/mining), ordered by a
6
+ * strictly-increasing `seq` with each record's `prev` pointing at the content
7
+ * address (`recordId`) of the one before it. These types are storage-agnostic:
8
+ * the engine ({@link ./verify}, {@link ./engine}) drives them over an injected
9
+ * {@link ./recordStore.RecordStore}, and any app (Oxy identity/civic/node,
10
+ * Mention posts, …) supplies its own store + resolver.
11
+ *
12
+ * The {@link RejectionReason} union is the SINGLE source of truth for every way
13
+ * an append can fail — it consolidates what used to be three divergent copies
14
+ * (oxy-api's `EnvelopeRejectionReason`, the node store's `AppendOutcome.reason`,
15
+ * and the node verifier's `VerifyRejectionReason`). The exact strings match the
16
+ * ones oxy-api returns today, so API responses are byte-for-byte unchanged.
17
+ */
18
+
19
+ /**
20
+ * The O(1) head pointer of a subject's chain.
21
+ *
22
+ * `headRecordId` is the content address of the latest record (`null` only on the
23
+ * "no chain yet" wire shape); `seq` is its sequence number; `recordCount` is the
24
+ * total appended so far. A store returns `null` (not a `ChainHead`) when the
25
+ * subject has no chain — the engine treats both `null` and a `headRecordId:null`
26
+ * head as "no chain".
27
+ */
28
+ export interface ChainHead {
29
+ headRecordId: string | null;
30
+ seq: number;
31
+ recordCount: number;
32
+ }
33
+
34
+ /**
35
+ * Every way verifying or appending a signed record can be rejected — stable,
36
+ * machine-readable, and the ONE consolidated union across the protocol.
37
+ *
38
+ * - `invalid_envelope` — the envelope failed the base schema shape.
39
+ * - `subject_mismatch` — the envelope's `subject` is not who the caller is
40
+ * authorized to write for (an adapter-policy binding, surfaced here so a
41
+ * store/adapter can report it on the same channel).
42
+ * - `public_key_not_a_current_verification_method` — the issuer is recognized
43
+ * (self or custodial) but the signing key is not its current key.
44
+ * - `bad_signature` — the signature does not verify against the embedded key.
45
+ * - `issued_in_future` — `issuedAt` is beyond the tolerated clock skew.
46
+ * - `stale_issued_at` — `issuedAt` is not strictly newer than the latest record
47
+ * for the same logical key (replay/rollback defence).
48
+ * - `chain_gap` — a non-genesis record claims to extend a chain that has no head.
49
+ * - `chain_fork` — `prev` does not match the current head (or a re-genesis).
50
+ * - `bad_seq` — `seq` is not exactly `head.seq + 1`.
51
+ * - `chain_conflict` — a concurrent writer already took this `seq` (the store's
52
+ * unique-index backstop, surfaced from a duplicate-key error).
53
+ * - `untrusted_issuer` — the `issuer` is neither the subject nor a recognized
54
+ * custodial issuer.
55
+ */
56
+ export type RejectionReason =
57
+ | 'invalid_envelope'
58
+ | 'subject_mismatch'
59
+ | 'public_key_not_a_current_verification_method'
60
+ | 'bad_signature'
61
+ | 'issued_in_future'
62
+ | 'stale_issued_at'
63
+ | 'chain_gap'
64
+ | 'chain_fork'
65
+ | 'bad_seq'
66
+ | 'chain_conflict'
67
+ | 'untrusted_issuer';
68
+
69
+ /** Verdict of verifying an envelope WITHOUT persisting it. */
70
+ export type VerifyOutcome = { ok: true } | { ok: false; reason: RejectionReason };
71
+
72
+ /**
73
+ * Outcome of appending a verified envelope to a chain.
74
+ *
75
+ * On success it carries the record's content address (`recordId`) and its chain
76
+ * `seq` (`-1` for an unchained v1 record, which has no sequence). On failure it
77
+ * carries the {@link RejectionReason} (a continuity violation or the store's
78
+ * `chain_conflict` backstop).
79
+ */
80
+ export type AppendOutcome =
81
+ | { ok: true; recordId: string; seq: number }
82
+ | { ok: false; reason: RejectionReason };
83
+
84
+ /** The `seq` reported for a v1 (unchained) append — it has no sequence. */
85
+ export const UNCHAINED_SEQ = -1;