@orangecheck/stamp-ots 0.1.0 → 0.2.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.
- package/README.md +17 -19
- package/dist/index.d.mts +70 -6
- package/dist/index.d.ts +70 -6
- package/dist/index.js +380 -75
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +369 -75
- package/dist/index.mjs.map +1 -1
- package/package.json +60 -56
- package/src/anchor.test.ts +72 -0
- package/src/anchor.ts +100 -56
- package/src/calendar.ts +6 -8
- package/src/index.ts +19 -2
- package/src/ots-fixture.test-helper.ts +24 -0
- package/src/ots.test.ts +34 -0
- package/src/ots.ts +320 -0
- package/src/submit.test.ts +0 -17
- package/src/types.ts +7 -6
- package/src/upgrade.test.ts +110 -0
- package/src/upgrade.ts +72 -67
package/src/calendar.ts
CHANGED
|
@@ -2,11 +2,9 @@
|
|
|
2
2
|
// of the calendar protocol that an OC Stamp implementation needs:
|
|
3
3
|
//
|
|
4
4
|
// POST <url>/digest body = raw digest bytes -> pending proof bytes
|
|
5
|
-
// GET <url>/timestamp/<hex>
|
|
5
|
+
// GET <url>/timestamp/<hex(commitment)> -> continuation, or 404
|
|
6
6
|
//
|
|
7
|
-
// The client
|
|
8
|
-
// separate concern addressed by whichever OTS proof library the consumer
|
|
9
|
-
// plugs in (see verifyOtsAnchor in ./anchor.ts).
|
|
7
|
+
// The client moves bytes only; ./ots.ts parses them.
|
|
10
8
|
|
|
11
9
|
import type { CalendarClient } from './types.js';
|
|
12
10
|
import { hexEncode } from './base64.js';
|
|
@@ -62,12 +60,12 @@ export function createCalendarClient(url: string, opts: HttpCalendarOptions = {}
|
|
|
62
60
|
const buf = await resp.arrayBuffer();
|
|
63
61
|
return new Uint8Array(buf);
|
|
64
62
|
},
|
|
65
|
-
async fetchProof(
|
|
66
|
-
if (
|
|
67
|
-
throw new Error('OTS:
|
|
63
|
+
async fetchProof(commitment, signal) {
|
|
64
|
+
if (commitment.byteLength === 0 || commitment.byteLength > 64) {
|
|
65
|
+
throw new Error('OTS: commitment must be 1-64 bytes');
|
|
68
66
|
}
|
|
69
67
|
const ac = withTimeout(signal, timeoutMs);
|
|
70
|
-
const resp = await fetchImpl(`${base}/timestamp/${hexEncode(
|
|
68
|
+
const resp = await fetchImpl(`${base}/timestamp/${hexEncode(commitment)}`, {
|
|
71
69
|
method: 'GET',
|
|
72
70
|
headers: { Accept: 'application/vnd.opentimestamps.v1' },
|
|
73
71
|
signal: ac.signal,
|
package/src/index.ts
CHANGED
|
@@ -6,5 +6,22 @@ export { submitToCalendars } from './submit.js';
|
|
|
6
6
|
export type { SubmitOptions } from './submit.js';
|
|
7
7
|
export { upgradeProof } from './upgrade.js';
|
|
8
8
|
export type { UpgradeOptions } from './upgrade.js';
|
|
9
|
-
export {
|
|
10
|
-
|
|
9
|
+
export {
|
|
10
|
+
blockHashOf,
|
|
11
|
+
makeAnchorVerifier,
|
|
12
|
+
makeDefaultAnchorVerifier,
|
|
13
|
+
mempoolHeaderSource,
|
|
14
|
+
walkOtsProof,
|
|
15
|
+
} from './anchor.js';
|
|
16
|
+
export type { AnchorVerifierConfig, MempoolHeaderSourceOptions } from './anchor.js';
|
|
17
|
+
export {
|
|
18
|
+
attestations,
|
|
19
|
+
bitcoinAnchors,
|
|
20
|
+
mergeAt,
|
|
21
|
+
parseDetached,
|
|
22
|
+
parseProof,
|
|
23
|
+
parseTimestamp,
|
|
24
|
+
pendingCommitments,
|
|
25
|
+
serializeTimestamp,
|
|
26
|
+
} from './ots.js';
|
|
27
|
+
export type { OtsAttestation, OtsOp, OtsTimestamp } from './ots.js';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// A real OpenTimestamps proof: opentimestamps-client examples/hello-world.txt.ots,
|
|
2
|
+
// committing to sha256("Hello World!\n") and anchored in Bitcoin block 358391.
|
|
3
|
+
|
|
4
|
+
import { base64Decode } from './base64.js';
|
|
5
|
+
|
|
6
|
+
export const HELLO_DIGEST = '03ba204e50d126e4674c005e04d82e84c21366780af1f43bd54a37816b6ab340';
|
|
7
|
+
|
|
8
|
+
export const HELLO_OTS_FILE = base64Decode(
|
|
9
|
+
'AE9wZW5UaW1lc3RhbXBzAABQcm9vZgC/ieLohOiSlAEIA7ogTlDRJuRnTABeBNguhMITZngK8fQ71Uo3gWtqs0AD' +
|
|
10
|
+
'8cgBAQAAAAHkgvnTLsw7ple2nYmAEIV7VEV6kEl5gv9W+XxOxY5vmAEAAABrSDBFAiEAslOt0dHPkIRDOKR1oE/x' +
|
|
11
|
+
'P8nnvSQrB3Yt6gf1YIst42cCIACyaMqcM0KzdpzdBiiRMXzc74eqwxC2hV6dk4mOu+jsASECDY5NEH0rM5sAUO/d' +
|
|
12
|
+
'S0oJJFqgVgSPElOWN06moqsHCcb/////AmUz5gUAAAAAGXapFAvwV9QPu6Z0SGJRX1tVojEN5XcviKyghgEAAAAA' +
|
|
13
|
+
'ABl2qRTwBoisAAAAAAgI8SCph/cWxTORPDFMeONdNYhMrJQ/pCysSdKyxp9AA/hfiAgI8SDexVs0h+Hj9yKkm1Wn' +
|
|
14
|
+
'eDIVhieF9KOss5KEYBn3HcZKnQgI8SCyyhj0heCAR44CXas9RktBbA4ey2Ypya786MghTQQkMggI8CARsOkGYRlv' +
|
|
15
|
+
'9LCBPD7aFBurXpFgSDe996DJ3zfbDjoRmAgI8CDDS8GkoQk//RSMAWseZkdCkU6Tnvq+TT01ZRWRSybZ4ggI8CDD' +
|
|
16
|
+
'5ufDjGn2ryTCvjTrrEglft5h7AohuVNeREMne+MGRggI8SAHmL+GBuAAJOXV1UvwyWD2Kd+52taRV0VbbyZSwOje' +
|
|
17
|
+
'gQgI8CA/mtptYLqiRABrsKrVFEitL6+51LZIegmZz/JrkfD1NggI8SDHAwGelZqN0/rvdIm7MoukhVdHWOcJHwFG' +
|
|
18
|
+
'TrZYcsl1yAgI8CDL/v/1E/+EuRXj/tb515lnZjD4Nk6ipsdVf62UpbXXiAgI8SAL4jcJhZkTur1EYLvd+O0hPnyH' +
|
|
19
|
+
'c6Sx+s4w+Kz98JO3BQgIAAWIlg1z1xkBA/fvFQ==');
|
|
20
|
+
|
|
21
|
+
export const BLOCK_HEIGHT = 358391;
|
|
22
|
+
export const BLOCK_HASH = '000000000000000003e892881a8cdcdc117c06d444057c98b6f04a9ee75a2319';
|
|
23
|
+
export const BLOCK_HEADER_HEX =
|
|
24
|
+
'02000000b96394585a281b7e5f438fd1c9ed492645a1fd61cb3802040000000000000000007ee445d23ad061af4a36b809501fab1ac4f2d7e7a739817dd0cbb7ec661b8a1e376755f58616186272def6';
|
package/src/ots.test.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { hexDecode, hexEncode } from './base64.js';
|
|
4
|
+
import { bitcoinAnchors, parseDetached, parseProof, serializeTimestamp } from './ots.js';
|
|
5
|
+
import { BLOCK_HEADER_HEX, BLOCK_HEIGHT, HELLO_DIGEST, HELLO_OTS_FILE } from './ots-fixture.test-helper.js';
|
|
6
|
+
|
|
7
|
+
describe('OTS proof parsing', () => {
|
|
8
|
+
it('walks a detached proof to the Merkle root in the attested block header', () => {
|
|
9
|
+
const { digest, timestamp } = parseDetached(HELLO_OTS_FILE);
|
|
10
|
+
expect(hexEncode(digest)).toBe(HELLO_DIGEST);
|
|
11
|
+
const [anchor] = bitcoinAnchors(timestamp);
|
|
12
|
+
expect(anchor?.blockHeight).toBe(BLOCK_HEIGHT);
|
|
13
|
+
expect(hexEncode(anchor!.merkleRoot)).toBe(BLOCK_HEADER_HEX.slice(72, 136));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('serializes a parsed timestamp back to the same bytes', () => {
|
|
17
|
+
const { timestamp } = parseDetached(HELLO_OTS_FILE);
|
|
18
|
+
const bare = serializeTimestamp(timestamp);
|
|
19
|
+
expect(hexEncode(bare)).toBe(hexEncode(HELLO_OTS_FILE.subarray(HELLO_OTS_FILE.length - bare.length)));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('accepts the bare timestamp encoding rooted at the digest', () => {
|
|
23
|
+
const bare = serializeTimestamp(parseDetached(HELLO_OTS_FILE).timestamp);
|
|
24
|
+
expect(bitcoinAnchors(parseProof(bare, hexDecode(HELLO_DIGEST)))[0]?.blockHeight).toBe(BLOCK_HEIGHT);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('refuses a detached proof for a different digest', () => {
|
|
28
|
+
expect(() => parseProof(HELLO_OTS_FILE, hexDecode('00'.repeat(32)))).toThrow(/different digest/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('rejects truncated proofs', () => {
|
|
32
|
+
expect(() => parseDetached(HELLO_OTS_FILE.subarray(0, HELLO_OTS_FILE.length - 5))).toThrow();
|
|
33
|
+
});
|
|
34
|
+
});
|
package/src/ots.ts
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// OpenTimestamps proof format: parse, walk, serialize, merge.
|
|
2
|
+
//
|
|
3
|
+
// Follows python-opentimestamps (core/timestamp.py, core/op.py,
|
|
4
|
+
// core/notary.py). Two encodings are accepted:
|
|
5
|
+
//
|
|
6
|
+
// - a bare timestamp rooted at the digest (what an OTS calendar returns from
|
|
7
|
+
// POST /digest, and what OC Stamp envelopes carry in `ots.proof`);
|
|
8
|
+
// - a detached `.ots` file (magic header + file-hash op + digest + timestamp).
|
|
9
|
+
|
|
10
|
+
import { ripemd160, sha1 } from '@noble/hashes/legacy';
|
|
11
|
+
import { sha256 } from '@noble/hashes/sha2';
|
|
12
|
+
import { keccak_256 } from '@noble/hashes/sha3';
|
|
13
|
+
|
|
14
|
+
import { hexEncode } from './base64.js';
|
|
15
|
+
|
|
16
|
+
const HEADER_MAGIC = new Uint8Array([
|
|
17
|
+
0x00, 0x4f, 0x70, 0x65, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x73, 0x00,
|
|
18
|
+
0x00, 0x50, 0x72, 0x6f, 0x6f, 0x66, 0x00, 0xbf, 0x89, 0xe2, 0xe8, 0x84, 0xe8, 0x92, 0x94,
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
const TAG_BITCOIN = '0588960d73d71901';
|
|
22
|
+
const TAG_PENDING = '83dfe30d2ef90c8e';
|
|
23
|
+
|
|
24
|
+
const MAX_DEPTH = 256;
|
|
25
|
+
const MAX_MSG = 4096;
|
|
26
|
+
|
|
27
|
+
export type OtsOp =
|
|
28
|
+
| { kind: 'sha256' | 'ripemd160' | 'sha1' | 'keccak256' | 'reverse' | 'hexlify' }
|
|
29
|
+
| { kind: 'append' | 'prepend'; arg: Uint8Array };
|
|
30
|
+
|
|
31
|
+
export type OtsAttestation =
|
|
32
|
+
| { kind: 'bitcoin'; height: number }
|
|
33
|
+
| { kind: 'pending'; uri: string }
|
|
34
|
+
| { kind: 'unknown'; tag: string; payload: Uint8Array };
|
|
35
|
+
|
|
36
|
+
export interface OtsTimestamp {
|
|
37
|
+
/** The message at this node: the digest after every op on the path to it. */
|
|
38
|
+
msg: Uint8Array;
|
|
39
|
+
attestations: OtsAttestation[];
|
|
40
|
+
ops: { op: OtsOp; child: OtsTimestamp }[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class Reader {
|
|
44
|
+
private pos = 0;
|
|
45
|
+
constructor(private readonly buf: Uint8Array) {}
|
|
46
|
+
byte(): number {
|
|
47
|
+
if (this.pos >= this.buf.length) throw new Error('ots: unexpected end of proof');
|
|
48
|
+
return this.buf[this.pos++]!;
|
|
49
|
+
}
|
|
50
|
+
bytes(n: number): Uint8Array {
|
|
51
|
+
if (n < 0 || this.pos + n > this.buf.length) throw new Error('ots: unexpected end of proof');
|
|
52
|
+
const out = this.buf.subarray(this.pos, this.pos + n);
|
|
53
|
+
this.pos += n;
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
varuint(): number {
|
|
57
|
+
let value = 0;
|
|
58
|
+
let shift = 0;
|
|
59
|
+
for (;;) {
|
|
60
|
+
const b = this.byte();
|
|
61
|
+
value += (b & 0x7f) * 2 ** shift;
|
|
62
|
+
if (!(b & 0x80)) return value;
|
|
63
|
+
shift += 7;
|
|
64
|
+
if (shift > 49) throw new Error('ots: varuint too large');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
varbytes(max: number): Uint8Array {
|
|
68
|
+
const n = this.varuint();
|
|
69
|
+
if (n > max) throw new Error('ots: varbytes too long');
|
|
70
|
+
return this.bytes(n);
|
|
71
|
+
}
|
|
72
|
+
get done(): boolean {
|
|
73
|
+
return this.pos === this.buf.length;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const UNARY: Record<number, OtsOp['kind']> = {
|
|
78
|
+
0x02: 'sha1',
|
|
79
|
+
0x03: 'ripemd160',
|
|
80
|
+
0x08: 'sha256',
|
|
81
|
+
0x67: 'keccak256',
|
|
82
|
+
0xf2: 'reverse',
|
|
83
|
+
0xf3: 'hexlify',
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export function applyOp(op: OtsOp, msg: Uint8Array): Uint8Array {
|
|
87
|
+
switch (op.kind) {
|
|
88
|
+
case 'sha256':
|
|
89
|
+
return sha256(msg);
|
|
90
|
+
case 'ripemd160':
|
|
91
|
+
return ripemd160(msg);
|
|
92
|
+
case 'sha1':
|
|
93
|
+
return sha1(msg);
|
|
94
|
+
case 'keccak256':
|
|
95
|
+
return keccak_256(msg);
|
|
96
|
+
case 'reverse':
|
|
97
|
+
return new Uint8Array(msg).reverse();
|
|
98
|
+
case 'hexlify':
|
|
99
|
+
return new TextEncoder().encode(hexEncode(msg));
|
|
100
|
+
case 'append':
|
|
101
|
+
return concat(msg, op.arg);
|
|
102
|
+
case 'prepend':
|
|
103
|
+
return concat(op.arg, msg);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function readOp(r: Reader, tag: number): OtsOp {
|
|
108
|
+
const unary = UNARY[tag];
|
|
109
|
+
if (unary) return { kind: unary } as OtsOp;
|
|
110
|
+
if (tag === 0xf0) return { kind: 'append', arg: r.varbytes(MAX_MSG) };
|
|
111
|
+
if (tag === 0xf1) return { kind: 'prepend', arg: r.varbytes(MAX_MSG) };
|
|
112
|
+
throw new Error(`ots: unknown op tag 0x${tag.toString(16)}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function readAttestation(r: Reader): OtsAttestation {
|
|
116
|
+
const tag = hexEncode(r.bytes(8));
|
|
117
|
+
const payload = r.varbytes(8192);
|
|
118
|
+
const p = new Reader(payload);
|
|
119
|
+
if (tag === TAG_BITCOIN) return { kind: 'bitcoin', height: p.varuint() };
|
|
120
|
+
if (tag === TAG_PENDING) return { kind: 'pending', uri: new TextDecoder().decode(p.varbytes(1000)) };
|
|
121
|
+
return { kind: 'unknown', tag, payload };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function readTimestamp(r: Reader, msg: Uint8Array, depth: number): OtsTimestamp {
|
|
125
|
+
if (depth > MAX_DEPTH) throw new Error('ots: proof too deep');
|
|
126
|
+
const node: OtsTimestamp = { msg, attestations: [], ops: [] };
|
|
127
|
+
const item = (tag: number) => {
|
|
128
|
+
if (tag === 0x00) {
|
|
129
|
+
node.attestations.push(readAttestation(r));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const op = readOp(r, tag);
|
|
133
|
+
const next = applyOp(op, msg);
|
|
134
|
+
if (next.length > MAX_MSG) throw new Error('ots: message too long');
|
|
135
|
+
node.ops.push({ op, child: readTimestamp(r, next, depth + 1) });
|
|
136
|
+
};
|
|
137
|
+
let tag = r.byte();
|
|
138
|
+
while (tag === 0xff) {
|
|
139
|
+
item(r.byte());
|
|
140
|
+
tag = r.byte();
|
|
141
|
+
}
|
|
142
|
+
item(tag);
|
|
143
|
+
return node;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Parse a bare timestamp whose root message is `digest`. */
|
|
147
|
+
export function parseTimestamp(bytes: Uint8Array, digest: Uint8Array): OtsTimestamp {
|
|
148
|
+
const r = new Reader(bytes);
|
|
149
|
+
const ts = readTimestamp(r, digest, 0);
|
|
150
|
+
if (!r.done) throw new Error('ots: trailing bytes after timestamp');
|
|
151
|
+
return ts;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function isDetached(bytes: Uint8Array): boolean {
|
|
155
|
+
return bytes.length > HEADER_MAGIC.length && bytesEqual(bytes.subarray(0, HEADER_MAGIC.length), HEADER_MAGIC);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Parse a detached `.ots` file. Only sha256 file digests are accepted. */
|
|
159
|
+
export function parseDetached(bytes: Uint8Array): { digest: Uint8Array; timestamp: OtsTimestamp } {
|
|
160
|
+
if (!isDetached(bytes)) throw new Error('ots: missing detached-file header');
|
|
161
|
+
const r = new Reader(bytes.subarray(HEADER_MAGIC.length));
|
|
162
|
+
const major = r.varuint();
|
|
163
|
+
if (major !== 1) throw new Error(`ots: unsupported file version ${major}`);
|
|
164
|
+
if (r.byte() !== 0x08) throw new Error('ots: only sha256 file digests are supported');
|
|
165
|
+
const digest = new Uint8Array(r.bytes(32));
|
|
166
|
+
const timestamp = readTimestamp(r, digest, 0);
|
|
167
|
+
if (!r.done) throw new Error('ots: trailing bytes after timestamp');
|
|
168
|
+
return { digest, timestamp };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Parse either encoding and require that it commits to `digest`. A detached
|
|
173
|
+
* file for some other digest is rejected rather than walked.
|
|
174
|
+
*/
|
|
175
|
+
export function parseProof(bytes: Uint8Array, digest: Uint8Array): OtsTimestamp {
|
|
176
|
+
if (!isDetached(bytes)) return parseTimestamp(bytes, digest);
|
|
177
|
+
const d = parseDetached(bytes);
|
|
178
|
+
if (!bytesEqual(d.digest, digest)) throw new Error('ots: proof commits to a different digest');
|
|
179
|
+
return d.timestamp;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Every attestation in the tree with the message it attests to. */
|
|
183
|
+
export function attestations(ts: OtsTimestamp): { msg: Uint8Array; attestation: OtsAttestation }[] {
|
|
184
|
+
const out: { msg: Uint8Array; attestation: OtsAttestation }[] = [];
|
|
185
|
+
const walk = (n: OtsTimestamp) => {
|
|
186
|
+
for (const a of n.attestations) out.push({ msg: n.msg, attestation: a });
|
|
187
|
+
for (const { child } of n.ops) walk(child);
|
|
188
|
+
};
|
|
189
|
+
walk(ts);
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Bitcoin attestations, lowest height first. `merkleRoot` is the message at
|
|
195
|
+
* the attestation, which equals the block header's Merkle root in header
|
|
196
|
+
* byte order.
|
|
197
|
+
*/
|
|
198
|
+
export function bitcoinAnchors(ts: OtsTimestamp): { blockHeight: number; merkleRoot: Uint8Array }[] {
|
|
199
|
+
return attestations(ts)
|
|
200
|
+
.flatMap(({ msg, attestation }) =>
|
|
201
|
+
attestation.kind === 'bitcoin' ? [{ blockHeight: attestation.height, merkleRoot: msg }] : []
|
|
202
|
+
)
|
|
203
|
+
.sort((a, b) => a.blockHeight - b.blockHeight);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Pending calendar attestations: where to ask, and the commitment to ask for. */
|
|
207
|
+
export function pendingCommitments(ts: OtsTimestamp): { uri: string; commitment: Uint8Array }[] {
|
|
208
|
+
return attestations(ts).flatMap(({ msg, attestation }) =>
|
|
209
|
+
attestation.kind === 'pending' ? [{ uri: attestation.uri, commitment: msg }] : []
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Graft `upgrade` (a timestamp rooted at some node's message) onto the node
|
|
215
|
+
* with that message, as python-opentimestamps' Timestamp.merge does. Returns
|
|
216
|
+
* false when no node carries that message.
|
|
217
|
+
*/
|
|
218
|
+
export function mergeAt(ts: OtsTimestamp, upgrade: OtsTimestamp): boolean {
|
|
219
|
+
if (bytesEqual(ts.msg, upgrade.msg)) {
|
|
220
|
+
for (const a of upgrade.attestations) {
|
|
221
|
+
if (!ts.attestations.some((b) => attestationKey(b) === attestationKey(a))) ts.attestations.push(a);
|
|
222
|
+
}
|
|
223
|
+
for (const { op, child } of upgrade.ops) {
|
|
224
|
+
const same = ts.ops.find((o) => opKey(o.op) === opKey(op));
|
|
225
|
+
if (same) mergeAt(same.child, child);
|
|
226
|
+
else ts.ops.push({ op, child });
|
|
227
|
+
}
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
return ts.ops.some(({ child }) => mergeAt(child, upgrade));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function writeVaruint(out: number[], n: number) {
|
|
234
|
+
do {
|
|
235
|
+
let b = n % 128;
|
|
236
|
+
n = Math.floor(n / 128);
|
|
237
|
+
if (n > 0) b |= 0x80;
|
|
238
|
+
out.push(b);
|
|
239
|
+
} while (n > 0);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function writeVarbytes(out: number[], bytes: Uint8Array) {
|
|
243
|
+
writeVaruint(out, bytes.length);
|
|
244
|
+
out.push(...bytes);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const OP_TAG: Record<OtsOp['kind'], number> = {
|
|
248
|
+
sha1: 0x02,
|
|
249
|
+
ripemd160: 0x03,
|
|
250
|
+
sha256: 0x08,
|
|
251
|
+
keccak256: 0x67,
|
|
252
|
+
append: 0xf0,
|
|
253
|
+
prepend: 0xf1,
|
|
254
|
+
reverse: 0xf2,
|
|
255
|
+
hexlify: 0xf3,
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
function writeAttestation(out: number[], a: OtsAttestation) {
|
|
259
|
+
const payload: number[] = [];
|
|
260
|
+
let tag: string;
|
|
261
|
+
if (a.kind === 'bitcoin') {
|
|
262
|
+
tag = TAG_BITCOIN;
|
|
263
|
+
writeVaruint(payload, a.height);
|
|
264
|
+
} else if (a.kind === 'pending') {
|
|
265
|
+
tag = TAG_PENDING;
|
|
266
|
+
writeVarbytes(payload, new TextEncoder().encode(a.uri));
|
|
267
|
+
} else {
|
|
268
|
+
tag = a.tag;
|
|
269
|
+
payload.push(...a.payload);
|
|
270
|
+
}
|
|
271
|
+
out.push(0x00);
|
|
272
|
+
for (let i = 0; i < 16; i += 2) out.push(parseInt(tag.slice(i, i + 2), 16));
|
|
273
|
+
writeVarbytes(out, new Uint8Array(payload));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function writeTimestamp(out: number[], ts: OtsTimestamp) {
|
|
277
|
+
const items: (() => void)[] = [
|
|
278
|
+
...ts.attestations.map((a) => () => writeAttestation(out, a)),
|
|
279
|
+
...ts.ops.map(({ op, child }) => () => {
|
|
280
|
+
out.push(OP_TAG[op.kind]);
|
|
281
|
+
if (op.kind === 'append' || op.kind === 'prepend') writeVarbytes(out, op.arg);
|
|
282
|
+
writeTimestamp(out, child);
|
|
283
|
+
}),
|
|
284
|
+
];
|
|
285
|
+
if (items.length === 0) throw new Error('ots: cannot serialize an empty timestamp node');
|
|
286
|
+
items.forEach((write, i) => {
|
|
287
|
+
if (i < items.length - 1) out.push(0xff);
|
|
288
|
+
write();
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Serialize as a bare timestamp (the encoding carried in `ots.proof`). */
|
|
293
|
+
export function serializeTimestamp(ts: OtsTimestamp): Uint8Array {
|
|
294
|
+
const out: number[] = [];
|
|
295
|
+
writeTimestamp(out, ts);
|
|
296
|
+
return new Uint8Array(out);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function attestationKey(a: OtsAttestation): string {
|
|
300
|
+
if (a.kind === 'bitcoin') return `b:${a.height}`;
|
|
301
|
+
if (a.kind === 'pending') return `p:${a.uri}`;
|
|
302
|
+
return `u:${a.tag}:${hexEncode(a.payload)}`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function opKey(op: OtsOp): string {
|
|
306
|
+
return 'arg' in op ? `${op.kind}:${hexEncode(op.arg)}` : op.kind;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
310
|
+
const out = new Uint8Array(a.length + b.length);
|
|
311
|
+
out.set(a, 0);
|
|
312
|
+
out.set(b, a.length);
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
317
|
+
if (a.byteLength !== b.byteLength) return false;
|
|
318
|
+
for (let i = 0; i < a.byteLength; i++) if (a[i] !== b[i]) return false;
|
|
319
|
+
return true;
|
|
320
|
+
}
|
package/src/submit.test.ts
CHANGED
|
@@ -73,23 +73,6 @@ describe('upgradeProof', () => {
|
|
|
73
73
|
upgradedAt: null,
|
|
74
74
|
};
|
|
75
75
|
|
|
76
|
-
it('returns current proof unchanged when parseAnchor returns null from all calendars', async () => {
|
|
77
|
-
// Fake the fetch so calendars return same-length pending proofs.
|
|
78
|
-
// We have to monkey-patch createCalendarClient indirectly — instead,
|
|
79
|
-
// use a minimal harness: submit() isn't used here, upgradeProof reads
|
|
80
|
-
// calendars from the proof and creates new clients. Rather than
|
|
81
|
-
// rewire the module, we test the no-op path by making parseAnchor
|
|
82
|
-
// return null and ensure the function returns the input.
|
|
83
|
-
// The real network fetches will fail in test; we swallow via opts.fetch.
|
|
84
|
-
const fakeFetch = async () => new Response(null, { status: 404 });
|
|
85
|
-
const out = await upgradeProof(basePending, FIXED_ID, {
|
|
86
|
-
parseAnchor: async () => null,
|
|
87
|
-
fetch: fakeFetch as unknown as typeof fetch,
|
|
88
|
-
});
|
|
89
|
-
// Both calendars returned 404 => nothing to update.
|
|
90
|
-
expect(out).toEqual(basePending);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
76
|
it('is idempotent when given a confirmed proof', async () => {
|
|
94
77
|
const confirmed: OtsProof = {
|
|
95
78
|
...basePending,
|
package/src/types.ts
CHANGED
|
@@ -6,7 +6,7 @@ export type OtsProofStatus = 'pending' | 'confirmed';
|
|
|
6
6
|
|
|
7
7
|
export interface OtsProof {
|
|
8
8
|
status: OtsProofStatus;
|
|
9
|
-
/**
|
|
9
|
+
/** OTS timestamp rooted at the envelope id (or a detached .ots file), base64. */
|
|
10
10
|
proof: string;
|
|
11
11
|
/** Calendar URLs that contain this proof, in submission order. */
|
|
12
12
|
calendars: string[];
|
|
@@ -33,13 +33,14 @@ export interface CalendarClient {
|
|
|
33
33
|
submit(digest: Uint8Array, signal?: AbortSignal): Promise<Uint8Array>;
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* Fetch the
|
|
37
|
-
*
|
|
36
|
+
* Fetch the continuation of a pending proof. `commitment` is the message
|
|
37
|
+
* at the calendar's pending attestation (see `pendingCommitments`), not
|
|
38
|
+
* the submitted digest. Returns null if the calendar has nothing yet.
|
|
38
39
|
*
|
|
39
|
-
* OTS calendar HTTP API: GET <url>/timestamp/<hex(
|
|
40
|
-
*
|
|
40
|
+
* OTS calendar HTTP API: GET <url>/timestamp/<hex(commitment)>, returns a
|
|
41
|
+
* timestamp rooted at the commitment, or 404.
|
|
41
42
|
*/
|
|
42
|
-
fetchProof(
|
|
43
|
+
fetchProof(commitment: Uint8Array, signal?: AbortSignal): Promise<Uint8Array | null>;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
export interface AnchorVerificationInput {
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { makeAnchorVerifier } from './anchor.js';
|
|
4
|
+
import { base64Encode, hexDecode, hexEncode } from './base64.js';
|
|
5
|
+
import { parseDetached, serializeTimestamp, type OtsTimestamp } from './ots.js';
|
|
6
|
+
import {
|
|
7
|
+
BLOCK_HASH,
|
|
8
|
+
BLOCK_HEADER_HEX,
|
|
9
|
+
BLOCK_HEIGHT,
|
|
10
|
+
HELLO_DIGEST,
|
|
11
|
+
HELLO_OTS_FILE,
|
|
12
|
+
} from './ots-fixture.test-helper.js';
|
|
13
|
+
import type { BlockHeaderSource, OtsProof } from './types.js';
|
|
14
|
+
import { upgradeProof } from './upgrade.js';
|
|
15
|
+
|
|
16
|
+
const CAL = 'https://cal.test';
|
|
17
|
+
const chain: BlockHeaderSource = {
|
|
18
|
+
async getHeaderAt(h) {
|
|
19
|
+
return h === BLOCK_HEIGHT ? hexDecode(BLOCK_HEADER_HEX) : null;
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Split the real proof the way a calendar does: the client holds the path to
|
|
24
|
+
// a pending attestation, the calendar later serves the rest from that point.
|
|
25
|
+
function split(depth: number): { pending: OtsTimestamp; rest: OtsTimestamp } {
|
|
26
|
+
const full = parseDetached(HELLO_OTS_FILE).timestamp;
|
|
27
|
+
const toBitcoin = (n: OtsTimestamp): boolean =>
|
|
28
|
+
n.attestations.some((a) => a.kind === 'bitcoin') || n.ops.some((o) => toBitcoin(o.child));
|
|
29
|
+
const copy: OtsTimestamp = { msg: full.msg, attestations: [], ops: [] };
|
|
30
|
+
let src = full;
|
|
31
|
+
let dst = copy;
|
|
32
|
+
for (let i = 0; i < depth; i++) {
|
|
33
|
+
const next = src.ops.find((o) => toBitcoin(o.child))!;
|
|
34
|
+
const child: OtsTimestamp = { msg: next.child.msg, attestations: [], ops: [] };
|
|
35
|
+
dst.ops.push({ op: next.op, child });
|
|
36
|
+
src = next.child;
|
|
37
|
+
dst = child;
|
|
38
|
+
}
|
|
39
|
+
dst.attestations.push({ kind: 'pending', uri: CAL });
|
|
40
|
+
return { pending: copy, rest: src };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function pendingProof(tree: OtsTimestamp, calendars = [CAL]): OtsProof {
|
|
44
|
+
return {
|
|
45
|
+
status: 'pending',
|
|
46
|
+
proof: base64Encode(serializeTimestamp(tree)),
|
|
47
|
+
calendars,
|
|
48
|
+
blockHeight: null,
|
|
49
|
+
blockHash: null,
|
|
50
|
+
upgradedAt: null,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function calendarServing(commitment: Uint8Array, body: Uint8Array, seen: string[]) {
|
|
55
|
+
return (async (url: string) => {
|
|
56
|
+
seen.push(url);
|
|
57
|
+
return url === `${CAL}/timestamp/${hexEncode(commitment)}`
|
|
58
|
+
? new Response(body)
|
|
59
|
+
: new Response(null, { status: 404 });
|
|
60
|
+
}) as unknown as typeof fetch;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('upgradeProof', () => {
|
|
64
|
+
it('asks the calendar for its commitment and returns a proof that verifies', async () => {
|
|
65
|
+
const { pending, rest } = split(6);
|
|
66
|
+
const seen: string[] = [];
|
|
67
|
+
const out = await upgradeProof(pendingProof(pending), HELLO_DIGEST, {
|
|
68
|
+
headerSource: chain,
|
|
69
|
+
fetch: calendarServing(rest.msg, serializeTimestamp(rest), seen),
|
|
70
|
+
});
|
|
71
|
+
expect(seen).toEqual([`${CAL}/timestamp/${hexEncode(rest.msg)}`]);
|
|
72
|
+
expect(out.status).toBe('confirmed');
|
|
73
|
+
expect(out.blockHeight).toBe(BLOCK_HEIGHT);
|
|
74
|
+
expect(out.blockHash).toBe(BLOCK_HASH);
|
|
75
|
+
const verify = makeAnchorVerifier({ headerSource: chain });
|
|
76
|
+
expect(await verify(out.proof, out.blockHeight!, out.blockHash!, HELLO_DIGEST)).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('stays pending when the calendar answer does not reach the block header', async () => {
|
|
80
|
+
const { pending, rest } = split(6);
|
|
81
|
+
const bogus: OtsTimestamp = { msg: rest.msg, attestations: [{ kind: 'bitcoin', height: BLOCK_HEIGHT }], ops: [] };
|
|
82
|
+
const out = await upgradeProof(pendingProof(pending), HELLO_DIGEST, {
|
|
83
|
+
headerSource: chain,
|
|
84
|
+
fetch: calendarServing(rest.msg, serializeTimestamp(bogus), []),
|
|
85
|
+
});
|
|
86
|
+
expect(out.status).toBe('pending');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('does not contact calendars the proof was not submitted to', async () => {
|
|
90
|
+
const { pending, rest } = split(6);
|
|
91
|
+
const seen: string[] = [];
|
|
92
|
+
await expect(
|
|
93
|
+
upgradeProof(pendingProof(pending, ['https://other.test']), HELLO_DIGEST, {
|
|
94
|
+
headerSource: chain,
|
|
95
|
+
fetch: calendarServing(rest.msg, serializeTimestamp(rest), seen),
|
|
96
|
+
})
|
|
97
|
+
).rejects.toThrow(/no pending attestation/);
|
|
98
|
+
expect(seen).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('returns the proof unchanged while the calendar has nothing new', async () => {
|
|
102
|
+
const { pending } = split(6);
|
|
103
|
+
const current = pendingProof(pending);
|
|
104
|
+
const out = await upgradeProof(current, HELLO_DIGEST, {
|
|
105
|
+
headerSource: chain,
|
|
106
|
+
fetch: (async () => new Response(null, { status: 404 })) as unknown as typeof fetch,
|
|
107
|
+
});
|
|
108
|
+
expect(out).toEqual(current);
|
|
109
|
+
});
|
|
110
|
+
});
|