@orangecheck/stamp-ots 0.1.1 → 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/src/anchor.ts CHANGED
@@ -1,28 +1,19 @@
1
- // Anchor-verification helper. See SPEC.md §6.3.
1
+ // Anchor verification. See SPEC.md §6.3.
2
2
  //
3
- // Full OTS proof verification requires:
4
- // 1. Parsing the proof's Merkle path from digest to calendar root.
5
- // 2. Walking that path to the declared Bitcoin Merkle root.
6
- // 3. Fetching the Bitcoin block header at the declared height and comparing
7
- // its Merkle root.
3
+ // A confirmed anchor holds when all three are true:
4
+ // 1. the proof commits to the envelope id and walks to a Bitcoin
5
+ // attestation at the declared height;
6
+ // 2. the message at that attestation equals the Merkle root in the block
7
+ // header at that height;
8
+ // 3. that header double-SHA256es to the declared block hash.
8
9
  //
9
- // Step 1 needs a real OTS proof parser. We expose two composition points:
10
- //
11
- // A) `makeAnchorVerifier({ walkProof, headerSource })` — you bring the
12
- // parser, we provide the glue that compares Merkle roots against the
13
- // block header.
14
- //
15
- // B) `makeDefaultAnchorVerifier({ headerSource })` — dynamic-imports the
16
- // `opentimestamps` npm package (optional peer dependency) and wires
17
- // it into (A). One import, full verification.
18
- //
19
- // In both cases the resulting function is what `@orangecheck/stamp-core`
20
- // expects at the `verifyOtsAnchor` input of `verify()` — no further adaptation
21
- // needed. The envelope id is passed directly into the hook by stamp-core; we
22
- // drop the old broken `adaptAnchorVerifier` that tried to derive the digest
23
- // from the block hash.
10
+ // The resulting function is what `@orangecheck/stamp-core` expects at the
11
+ // `verifyOtsAnchor` input of `verify()`.
12
+
13
+ import { sha256 } from '@noble/hashes/sha2';
24
14
 
25
15
  import { base64Decode, hexDecode, hexEncode } from './base64.js';
16
+ import { bitcoinAnchors, bytesEqual, parseProof } from './ots.js';
26
17
  import type {
27
18
  AnchorVerificationInput,
28
19
  AnchorVerificationResult,
@@ -33,39 +24,53 @@ import type {
33
24
  export interface AnchorVerifierConfig {
34
25
  /**
35
26
  * Parse the proof bytes and return the Merkle root the proof walks to,
36
- * plus the declared block height. Returns null if parsing failed or the
37
- * proof isn't anchored.
27
+ * plus its block height. Optional: the built-in OTS parser is used when
28
+ * omitted. `blockHeight` is the declared height, for proofs that carry
29
+ * attestations at more than one height.
38
30
  */
39
- walkProof: (input: {
31
+ walkProof?: (input: {
40
32
  proofBytes: Uint8Array;
41
33
  digest: Uint8Array;
34
+ blockHeight?: number;
42
35
  }) => Promise<{ merkleRoot: Uint8Array; blockHeight: number } | null>;
43
36
  /**
44
- * Source of Bitcoin block headers. Can be a full node, SPV, or a
45
- * pre-computed headers snapshot.
37
+ * Source of Bitcoin block headers: a full node, SPV, a headers snapshot,
38
+ * or `mempoolHeaderSource()`. This is the verifier's trust anchor.
46
39
  */
47
40
  headerSource: BlockHeaderSource;
48
41
  }
49
42
 
50
- /**
51
- * Build a concrete anchor verifier from a proof walker plus a header source.
52
- *
53
- * The returned function matches the `verifyOtsAnchor` shape expected by
54
- * `@orangecheck/stamp-core`'s `verify()`:
55
- *
56
- * (proofB64, blockHeight, blockHash, envelopeId) => Promise<boolean>
57
- *
58
- * Wire it in as:
59
- *
60
- * verify({ envelope, verifyOtsAnchor: makeAnchorVerifier({...}) })
61
- */
43
+ /** Built-in walker: parse the OTS proof and pick the Bitcoin attestation. */
44
+ export async function walkOtsProof(input: {
45
+ proofBytes: Uint8Array;
46
+ digest: Uint8Array;
47
+ blockHeight?: number;
48
+ }): Promise<{ merkleRoot: Uint8Array; blockHeight: number } | null> {
49
+ try {
50
+ const anchors = bitcoinAnchors(parseProof(input.proofBytes, input.digest));
51
+ return anchors.find((a) => a.blockHeight === input.blockHeight) ?? anchors[0] ?? null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /** Block hash (display hex) of an 80-byte header. */
58
+ export function blockHashOf(header: Uint8Array): string {
59
+ return hexEncode(sha256(sha256(header)).reverse());
60
+ }
61
+
62
62
  export function makeAnchorVerifier(
63
63
  cfg: AnchorVerifierConfig
64
64
  ): (proofB64: string, blockHeight: number, blockHash: string, envelopeId: string) => Promise<boolean> {
65
+ const walkProof = cfg.walkProof ?? walkOtsProof;
65
66
  const verifier: AnchorVerifier = async (
66
67
  input: AnchorVerificationInput
67
68
  ): Promise<AnchorVerificationResult> => {
68
- const walked = await cfg.walkProof({ proofBytes: input.proofBytes, digest: input.digest });
69
+ const walked = await walkProof({
70
+ proofBytes: input.proofBytes,
71
+ digest: input.digest,
72
+ blockHeight: input.blockHeight,
73
+ });
69
74
  if (!walked) {
70
75
  return { ok: false, reason: 'proof could not be walked to a Bitcoin commitment' };
71
76
  }
@@ -76,23 +81,18 @@ export function makeAnchorVerifier(
76
81
  };
77
82
  }
78
83
  const header = await cfg.headerSource.getHeaderAt(input.blockHeight);
79
- if (!header) {
84
+ if (!header || header.byteLength !== 80) {
80
85
  return { ok: false, reason: `no block header available at height ${input.blockHeight}` };
81
86
  }
82
- // Bitcoin block header is 80 bytes; merkle root at offset 36, 32 bytes,
83
- // little-endian. Proof walkers vary in convention — compare both
84
- // endiannesses to stay compatible across implementations.
85
- if (header.byteLength < 80) {
86
- return { ok: false, reason: 'malformed Bitcoin header (too short)' };
87
+ if (blockHashOf(header) !== input.blockHash.toLowerCase()) {
88
+ return { ok: false, reason: 'block header does not hash to the declared block hash' };
87
89
  }
88
- const headerMerkleLE = header.subarray(36, 68);
89
- const headerMerkleBE = new Uint8Array(headerMerkleLE).reverse();
90
- const matchesLE = bytesEqual(headerMerkleLE, walked.merkleRoot);
91
- const matchesBE = bytesEqual(headerMerkleBE, walked.merkleRoot);
92
- if (!matchesLE && !matchesBE) {
90
+ // OTS attests to the Merkle root in header byte order (offset 36).
91
+ const headerMerkle = header.subarray(36, 68);
92
+ if (!bytesEqual(headerMerkle, walked.merkleRoot)) {
93
93
  return { ok: false, reason: 'block header Merkle root does not match walked proof root' };
94
94
  }
95
- return { ok: true, merkleRoot: hexEncode(matchesBE ? headerMerkleBE : headerMerkleLE) };
95
+ return { ok: true, merkleRoot: hexEncode(headerMerkle) };
96
96
  };
97
97
 
98
98
  return async (proofB64, blockHeight, blockHash, envelopeId) => {
@@ -107,98 +107,41 @@ export function makeAnchorVerifier(
107
107
  }
108
108
 
109
109
  /**
110
- * Default anchor verifier that dynamically imports the `opentimestamps` npm
111
- * package and wires it into `makeAnchorVerifier`. The import is lazy so
112
- * consumers who only need calendar submission don't pay the dep cost.
113
- *
114
- * Install `opentimestamps` alongside `@orangecheck/stamp-ots` to use this:
115
- *
116
- * npm i @orangecheck/stamp-ots opentimestamps
117
- *
118
- * Then:
119
- *
120
- * const verifyOtsAnchor = await makeDefaultAnchorVerifier({ headerSource });
121
- * await verify({ envelope, verifyOtsAnchor });
122
- *
123
- * Throws if `opentimestamps` isn't resolvable. That's intentional — callers
124
- * who haven't installed it should use `makeAnchorVerifier` with their own
125
- * walker instead, so the failure surfaces immediately rather than silently
126
- * reporting every stamp as unverified.
110
+ * Anchor verifier using the built-in OTS parser. Kept async for callers of the
111
+ * 0.1 API, which lazily loaded an optional parser here.
127
112
  */
128
113
  export async function makeDefaultAnchorVerifier(opts: {
129
114
  headerSource: BlockHeaderSource;
130
115
  }): Promise<
131
116
  (proofB64: string, blockHeight: number, blockHash: string, envelopeId: string) => Promise<boolean>
132
117
  > {
133
- const walkProof = await loadOpenTimestampsWalker();
134
- return makeAnchorVerifier({ walkProof, headerSource: opts.headerSource });
118
+ return makeAnchorVerifier({ headerSource: opts.headerSource });
135
119
  }
136
120
 
137
- type OtsWalker = AnchorVerifierConfig['walkProof'];
138
-
139
- async function loadOpenTimestampsWalker(): Promise<OtsWalker> {
140
- // Dynamic import so the dep is optional. If the package isn't installed,
141
- // the error is explicit and actionable.
142
- let ots: unknown;
143
- try {
144
- ots = await import(/* webpackIgnore: true */ 'opentimestamps' as string);
145
- } catch (e) {
146
- throw new Error(
147
- '@orangecheck/stamp-ots: makeDefaultAnchorVerifier requires the optional peer dependency `opentimestamps`. ' +
148
- `Install it with \`npm i opentimestamps\` or supply your own walkProof via makeAnchorVerifier. ` +
149
- `Underlying error: ${e instanceof Error ? e.message : String(e)}`
150
- );
151
- }
152
-
153
- // Normalize the module shape. `opentimestamps` publishes both CJS-default
154
- // and named exports across its versions.
155
- const mod = (ots as { default?: unknown }).default ?? ots;
156
- const modAny = mod as Record<string, unknown>;
157
-
158
- type DetachedLike = { deserialize(bytes: Uint8Array): unknown; timestamp: unknown };
159
- type TimestampLike = {
160
- verify(): Promise<Map<string, { height: number; timestamp?: unknown }>>;
161
- msg: Uint8Array;
162
- };
163
-
164
- const DetachedCtor =
165
- (modAny.DetachedTimestampFile as { deserialize(b: Uint8Array): DetachedLike } | undefined) ??
166
- (modAny.default as Record<string, unknown> | undefined)?.DetachedTimestampFile as
167
- | { deserialize(b: Uint8Array): DetachedLike }
168
- | undefined;
169
-
170
- if (!DetachedCtor || typeof DetachedCtor.deserialize !== 'function') {
171
- throw new Error(
172
- '@orangecheck/stamp-ots: unexpected shape from `opentimestamps` package — ' +
173
- 'expected DetachedTimestampFile.deserialize. If you are using a non-standard build, ' +
174
- 'pass your own walkProof via makeAnchorVerifier instead.'
175
- );
176
- }
177
-
178
- return async ({ proofBytes }) => {
179
- try {
180
- const detached = DetachedCtor.deserialize(proofBytes);
181
- const ts = detached.timestamp as TimestampLike;
182
- const attestations = await ts.verify();
183
- const first = attestations.values().next();
184
- if (first.done) return null;
185
- const { height, timestamp } = first.value;
186
- if (typeof height !== 'number' || !timestamp) return null;
187
- // The attestation provides the block height; the merkle root the
188
- // walker produces is the timestamp.msg bytes the walker committed
189
- // against. opentimestamps resolves that through its internal
190
- // proof chain; reading ts.msg gives the final digest it committed.
191
- const merkleRoot = (timestamp as { msg?: Uint8Array }).msg;
192
- if (!merkleRoot) return null;
193
- return { merkleRoot, blockHeight: height };
194
- } catch {
195
- return null;
196
- }
197
- };
121
+ export interface MempoolHeaderSourceOptions {
122
+ /** Esplora-compatible API base. Default https://mempool.space/api. */
123
+ baseUrl?: string;
124
+ fetch?: typeof fetch;
198
125
  }
199
126
 
200
- function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
201
- if (a.byteLength !== b.byteLength) return false;
202
- for (let i = 0; i < a.byteLength; i++) if (a[i] !== b[i]) return false;
203
- return true;
127
+ /**
128
+ * Block headers from an Esplora-compatible API. The verifier checks each
129
+ * header against the declared block hash, so this source is trusted only to
130
+ * report which block is at a height.
131
+ */
132
+ export function mempoolHeaderSource(opts: MempoolHeaderSourceOptions = {}): BlockHeaderSource {
133
+ const base = (opts.baseUrl ?? 'https://mempool.space/api').replace(/\/+$/, '');
134
+ const fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
135
+ return {
136
+ async getHeaderAt(height) {
137
+ const h = await fetchImpl(`${base}/block-height/${height}`);
138
+ if (!h.ok) return null;
139
+ const hash = (await h.text()).trim();
140
+ if (!/^[0-9a-f]{64}$/.test(hash)) return null;
141
+ const r = await fetchImpl(`${base}/block/${hash}/header`);
142
+ if (!r.ok) return null;
143
+ const hex = (await r.text()).trim();
144
+ return /^[0-9a-f]{160}$/.test(hex) ? hexDecode(hex) : null;
145
+ },
146
+ };
204
147
  }
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> -> upgraded proof bytes, or 404
5
+ // GET <url>/timestamp/<hex(commitment)> -> continuation, or 404
6
6
  //
7
- // The client is thin by design. It does not parse proof bytes; that is a
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(digest, signal) {
66
- if (digest.byteLength !== 32) {
67
- throw new Error('OTS: digest must be exactly 32 bytes');
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(digest)}`, {
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 { makeAnchorVerifier, makeDefaultAnchorVerifier } from './anchor.js';
10
- export type { AnchorVerifierConfig } from './anchor.js';
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';
@@ -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
+ });