Skip to content

Commit fd6152f

Browse files
committed
v2.2.0: OpenTimestamps .ots receipt verifier (zero-deps, browser+Node)
Parses the .ots binary file format (DetachedTimestampFile per opentimestamps-server's `core/timestamp.py:267-341`) and replays the attestation chain to extract Bitcoin block-header attestations. Public API: - parseOtsFile(bytes) -> ParsedOtsFile (full tree, async) - collectBitcoinAttestations(file) -> {blockheight, expectedMerkleRoot}[] - parseAndCollectBitcoinAttestations(bytes) Architecture: - Async because SHA-1 / SHA-256 use WebCrypto's crypto.subtle.digest (the only zero-dep hash available in both Node 19+ and modern browsers). Inlining SHA-256 was rejected to keep the diff focused. - RIPEMD-160 is NOT in WebCrypto, so it's inlined in src/lib/ripemd160.ts. ~150 lines, 9 test vectors verified against the original Dobbertin/Bosselaers/Preneel paper (incl. the 1M-byte stress vector). - KECCAK-256 (Ethereum chain) is parsed but not replayed -- attempts to evaluate KECCAK-throw a clear error. Bitcoin proofs don't use it. Real-data verification: 21 tests against canonical fixtures from the opentimestamps-client examples directory: - hello-world.txt.ots: SHA-256 + RIPEMD-160 + append + prepend chain. fileHash matches Node-stdlib SHA-256("Hello World!\n") to the byte. Extracted block-358,391 merkleroot, byte-reversed, matches the LIVE mainnet block header from mempool.space byte-for-byte. - bitcoin.pdf.ots: SHA-1 file-hash root (legacy default). Extracted block-465,751 merkleroot likewise byte-matches mempool.space. - For both fixtures, every internal tree node's msg is independently replayed against Node's stdlib `crypto` and asserted equal -- catches any drift between this verifier and the reference implementation. - incomplete.txt.ots / merkle1.txt.ots / two-calendars.txt.ots: pending-only proofs, asserts the calendar URIs and that no Bitcoin attestation is yet present. - Error paths: bad magic, truncated input, unsupported major version, trailing bytes after the tree. jest.config.browser.setup.js: added a WebCrypto polyfill so jsdom-based browser tests can also exercise the verifier (jsdom ships a partial crypto with no .subtle; we bind Node's webcrypto via Object.defineProperty). Total: 30 new tests (21 OTS verifier + 9 RIPEMD-160 vectors); full parser suite at 805 passed / 8 skipped.
1 parent ccd8b30 commit fd6152f

19 files changed

Lines changed: 866 additions & 2 deletions

jest.config.browser.setup.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,14 @@ global.Uint8Array = Uint8Array;
1616

1717
const { DecompressionStream } = require('stream/web');
1818
global.DecompressionStream = DecompressionStream;
19+
20+
// WebCrypto polyfill for jsdom -- the OTS verifier uses crypto.subtle.digest
21+
// for SHA-1 / SHA-256. Real browsers expose this; jsdom doesn't, so we
22+
// borrow Node's webcrypto and bind it to globalThis.crypto.
23+
//
24+
// jsdom installs its own `globalThis.crypto` that's a partial polyfill
25+
// (e.g. randomUUID) but lacks .subtle. We replace it wholesale via
26+
// Object.defineProperty because it's a getter on jsdom's window/global.
27+
const { webcrypto } = require('crypto');
28+
Object.defineProperty(global, 'crypto', { value: webcrypto, configurable: true, writable: true });
29+
Object.defineProperty(globalThis, 'crypto', { value: webcrypto, configurable: true, writable: true });

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "ordpool-parser",
3-
"version": "2.1.6",
4-
"description": "Zero-dependency TypeScript parser for Bitcoin digital artifacts: Inscriptions, Runes, BRC-20, SRC-20, CAT-21, Atomicals, and Labitbu. Works in Node.js and browsers.",
3+
"version": "2.2.0",
4+
"description": "Zero-dependency TypeScript parser for Bitcoin digital artifacts: Inscriptions, Runes, BRC-20, SRC-20, CAT-21, Atomicals, Labitbu, OpenTimestamps. Works in Node.js and browsers.",
55
"repository": {
66
"type": "git",
77
"url": "git+https://github.com/ordpool-space/ordpool-parser.git"

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,5 @@ export * from './types/parsed-src101';
5151
export * from './types/parser-options';
5252
export * from './types/bitcoin-rpc';
5353
export * from './types/transaction-simple';
54+
export * from './ots/parsed-ots';
55+
export * from './ots/ots-parser.service';

src/lib/ripemd160.spec.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { ripemd160 } from './ripemd160';
2+
3+
const enc = new TextEncoder();
4+
const hex = (b: Uint8Array) => Buffer.from(b).toString('hex');
5+
6+
/**
7+
* Test vectors from the original RIPEMD-160 paper
8+
* (Dobbertin / Bosselaers / Preneel, "RIPEMD-160: A Strengthened Version
9+
* of RIPEMD", 1996). Every implementation in the wild uses these.
10+
*/
11+
describe('ripemd160', () => {
12+
13+
it('hashes the empty string', () => {
14+
expect(hex(ripemd160(new Uint8Array()))).toBe('9c1185a5c5e9fc54612808977ee8f548b2258d31');
15+
});
16+
17+
it('hashes "a"', () => {
18+
expect(hex(ripemd160(enc.encode('a')))).toBe('0bdc9d2d256b3ee9daae347be6f4dc835a467ffe');
19+
});
20+
21+
it('hashes "abc"', () => {
22+
expect(hex(ripemd160(enc.encode('abc')))).toBe('8eb208f7e05d987a9b044a8e98c6b087f15a0bfc');
23+
});
24+
25+
it('hashes "message digest"', () => {
26+
expect(hex(ripemd160(enc.encode('message digest')))).toBe('5d0689ef49d2fae572b881b123a85ffa21595f36');
27+
});
28+
29+
it('hashes the lowercase alphabet', () => {
30+
expect(hex(ripemd160(enc.encode('abcdefghijklmnopqrstuvwxyz')))).toBe('f71c27109c692c1b56bbdceb5b9d2865b3708dbc');
31+
});
32+
33+
it('hashes 56 bytes (boundary near the 64-byte block size)', () => {
34+
expect(hex(ripemd160(enc.encode('abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq'))))
35+
.toBe('12a053384a9c0c88e405a06c27dcf49ada62eb2b');
36+
});
37+
38+
it('hashes a long input (alphanumeric mix)', () => {
39+
const input = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
40+
expect(hex(ripemd160(enc.encode(input)))).toBe('b0e20b6e3116640286ed3a87a5713079b21f5189');
41+
});
42+
43+
it('hashes 8 × "1234567890" = 80 bytes (multi-block)', () => {
44+
expect(hex(ripemd160(enc.encode('1234567890'.repeat(8)))))
45+
.toBe('9b752e45573d4b39f4dbd3323cab82bf63326bfb');
46+
});
47+
48+
it('hashes 1,000,000 × "a"', () => {
49+
const big = new Uint8Array(1_000_000).fill('a'.charCodeAt(0));
50+
expect(hex(ripemd160(big))).toBe('52783243c1697bdbe16d37f97f68f08325dc1528');
51+
});
52+
});

src/lib/ripemd160.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
* Inline RIPEMD-160 implementation. Zero deps, browser + Node.
3+
*
4+
* Used by the OTS verifier (some attestation chains include OP_RIPEMD160
5+
* — e.g. the canonical hello-world.txt.ots example). RIPEMD-160 is NOT
6+
* available via WebCrypto's SubtleCrypto, so we inline it.
7+
*
8+
* Algorithm: RFC 5802 / Dobbertin-Bosselaers-Preneel "RIPEMD-160: A
9+
* Strengthened Version of RIPEMD" (1996). 5 rounds, 16 ops per round,
10+
* two parallel chains XORed at the end. Standard, public-domain
11+
* algorithm; this is a port of the canonical reference implementation
12+
* with no behavioural deviations.
13+
*
14+
* Verified against the published test vectors:
15+
* "" -> 9c1185a5c5e9fc54612808977ee8f548b2258d31
16+
* "a" -> 0bdc9d2d256b3ee9daae347be6f4dc835a467ffe
17+
* "abc" -> 8eb208f7e05d987a9b044a8e98c6b087f15a0bfc
18+
* "message digest" -> 5d0689ef49d2fae572b881b123a85ffa21595f36
19+
* "a..z" -> f71c27109c692c1b56bbdceb5b9d2865b3708dbc
20+
* 1M × "a" -> 52783243c1697bdbe16d37f97f68f08325dc1528
21+
*/
22+
23+
function rotl32(x: number, n: number): number {
24+
return ((x << n) | (x >>> (32 - n))) >>> 0;
25+
}
26+
27+
// Five non-linear bit-mixing functions.
28+
function f(j: number, x: number, y: number, z: number): number {
29+
if (j < 16) return (x ^ y ^ z) >>> 0;
30+
if (j < 32) return (((x & y) | (~x & z)) >>> 0);
31+
if (j < 48) return (((x | ~y) ^ z) >>> 0);
32+
if (j < 64) return (((x & z) | (y & ~z)) >>> 0);
33+
return ((x ^ (y | ~z)) >>> 0);
34+
}
35+
36+
// Constants K and K' for left and right lines.
37+
const K = [0x00000000, 0x5a827999, 0x6ed9eba1, 0x8f1bbcdc, 0xa953fd4e];
38+
const KP = [0x50a28be6, 0x5c4dd124, 0x6d703ef3, 0x7a6d76e9, 0x00000000];
39+
40+
// Message-word selection r, r' for left and right lines (5 rounds × 16 ops).
41+
const R: number[] = [
42+
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
43+
7, 4, 13, 1, 10, 6, 15, 3, 12, 0, 9, 5, 2, 14, 11, 8,
44+
3, 10, 14, 4, 9, 15, 8, 1, 2, 7, 0, 6, 13, 11, 5, 12,
45+
1, 9, 11, 10, 0, 8, 12, 4, 13, 3, 7, 15, 14, 5, 6, 2,
46+
4, 0, 5, 9, 7, 12, 2, 10, 14, 1, 3, 8, 11, 6, 15, 13,
47+
];
48+
const RP: number[] = [
49+
5, 14, 7, 0, 9, 2, 11, 4, 13, 6, 15, 8, 1, 10, 3, 12,
50+
6, 11, 3, 7, 0, 13, 5, 10, 14, 15, 8, 12, 4, 9, 1, 2,
51+
15, 5, 1, 3, 7, 14, 6, 9, 11, 8, 12, 2, 10, 0, 4, 13,
52+
8, 6, 4, 1, 3, 11, 15, 0, 5, 12, 2, 13, 9, 7, 10, 14,
53+
12, 15, 10, 4, 1, 5, 8, 7, 6, 2, 13, 14, 0, 3, 9, 11,
54+
];
55+
56+
// Per-step rotate amounts s, s' for left and right lines.
57+
const S: number[] = [
58+
11, 14, 15, 12, 5, 8, 7, 9, 11, 13, 14, 15, 6, 7, 9, 8,
59+
7, 6, 8, 13, 11, 9, 7, 15, 7, 12, 15, 9, 11, 7, 13, 12,
60+
11, 13, 6, 7, 14, 9, 13, 15, 14, 8, 13, 6, 5, 12, 7, 5,
61+
11, 12, 14, 15, 14, 15, 9, 8, 9, 14, 5, 6, 8, 6, 5, 12,
62+
9, 15, 5, 11, 6, 8, 13, 12, 5, 12, 13, 14, 11, 8, 5, 6,
63+
];
64+
const SP: number[] = [
65+
8, 9, 9, 11, 13, 15, 15, 5, 7, 7, 8, 11, 14, 14, 12, 6,
66+
9, 13, 15, 7, 12, 8, 9, 11, 7, 7, 12, 7, 6, 15, 13, 11,
67+
9, 7, 15, 11, 8, 6, 6, 14, 12, 13, 5, 14, 13, 13, 7, 5,
68+
15, 5, 8, 11, 14, 14, 6, 14, 6, 9, 12, 9, 12, 5, 15, 8,
69+
8, 5, 12, 9, 12, 5, 14, 6, 8, 13, 6, 5, 15, 13, 11, 11,
70+
];
71+
72+
function processBlock(state: Uint32Array, block: Uint32Array): void {
73+
let aa = state[0], bb = state[1], cc = state[2], dd = state[3], ee = state[4];
74+
let aap = state[0], bbp = state[1], ccp = state[2], ddp = state[3], eep = state[4];
75+
76+
for (let j = 0; j < 80; j++) {
77+
const round = (j / 16) | 0;
78+
let t = (aa + f(j, bb, cc, dd) + block[R[j]] + K[round]) >>> 0;
79+
t = (rotl32(t, S[j]) + ee) >>> 0;
80+
aa = ee; ee = dd; dd = rotl32(cc, 10); cc = bb; bb = t;
81+
82+
let tp = (aap + f(79 - j, bbp, ccp, ddp) + block[RP[j]] + KP[round]) >>> 0;
83+
tp = (rotl32(tp, SP[j]) + eep) >>> 0;
84+
aap = eep; eep = ddp; ddp = rotl32(ccp, 10); ccp = bbp; bbp = tp;
85+
}
86+
87+
const t = (state[1] + cc + ddp) >>> 0;
88+
state[1] = (state[2] + dd + eep) >>> 0;
89+
state[2] = (state[3] + ee + aap) >>> 0;
90+
state[3] = (state[4] + aa + bbp) >>> 0;
91+
state[4] = (state[0] + bb + ccp) >>> 0;
92+
state[0] = t;
93+
}
94+
95+
/** RIPEMD-160 hash. Returns a 20-byte Uint8Array. */
96+
export function ripemd160(message: Uint8Array): Uint8Array {
97+
// Initial state vectors (per spec).
98+
const state = new Uint32Array([0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0]);
99+
100+
// Pad: append 0x80, then zeros, then 8-byte LE bit length.
101+
const bitLen = BigInt(message.length) * 8n;
102+
const padLen = (message.length % 64 < 56)
103+
? (56 - (message.length % 64))
104+
: (120 - (message.length % 64));
105+
const padded = new Uint8Array(message.length + padLen + 8);
106+
padded.set(message, 0);
107+
padded[message.length] = 0x80;
108+
// Write bit length as little-endian 64-bit.
109+
const dv = new DataView(padded.buffer);
110+
dv.setUint32(padded.length - 8, Number(bitLen & 0xffffffffn), true);
111+
dv.setUint32(padded.length - 4, Number(bitLen >> 32n) & 0xffffffff, true);
112+
113+
// Process 64-byte blocks as 16 little-endian uint32 words.
114+
const block = new Uint32Array(16);
115+
for (let off = 0; off < padded.length; off += 64) {
116+
for (let i = 0; i < 16; i++) {
117+
block[i] =
118+
(padded[off + i * 4]) |
119+
(padded[off + i * 4 + 1] << 8) |
120+
(padded[off + i * 4 + 2] << 16) |
121+
(padded[off + i * 4 + 3] << 24);
122+
}
123+
processBlock(state, block);
124+
}
125+
126+
// Output 20 bytes, little-endian.
127+
const out = new Uint8Array(20);
128+
const odv = new DataView(out.buffer);
129+
odv.setUint32(0, state[0], true);
130+
odv.setUint32(4, state[1], true);
131+
odv.setUint32(8, state[2], true);
132+
odv.setUint32(12, state[3], true);
133+
odv.setUint32(16, state[4], true);
134+
return out;
135+
}

0 commit comments

Comments
 (0)