Skip to content

Commit 0877d4f

Browse files
committed
chore: raise parser coverage and trim dead validation (#105)
1 parent 4a1d7e7 commit 0877d4f

2 files changed

Lines changed: 165 additions & 11 deletions

File tree

src/utils/nip03.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ const MAX_OTS_BYTES = 16 * 1024
5858
const MAX_VARBYTES_LENGTH = 8 * 1024
5959
const MAX_VARUINT_VALUE = Number.MAX_SAFE_INTEGER
6060
const MAX_RECURSION_DEPTH = 128
61-
const SHA256_DIGEST_LENGTH = 32
6261

6362
/**
6463
* Enum-like classification of attestations we care about. Unknown tags are
@@ -100,16 +99,14 @@ export type OtsParseResult = { ok: true; summary: OtsFileSummary } | { ok: false
10099
/**
101100
* Minimal cursor over a Buffer used by the OTS parser. All reads are
102101
* bounds-checked; exceeding the buffer throws a descriptive error.
102+
*
103+
* Exported for unit tests only — application code should use `parseOtsFile`.
103104
*/
104-
class OtsReader {
105+
export class OtsReader {
105106
private offset = 0
106107

107108
public constructor(private readonly buf: Buffer) {}
108109

109-
public get position(): number {
110-
return this.offset
111-
}
112-
113110
public get remaining(): number {
114111
return this.buf.length - this.offset
115112
}
@@ -373,10 +370,6 @@ export function validateOtsProof(base64Content: string, targetEventId: string):
373370
return `ots proof must use sha256 file hash op (got ${summary.fileHashOp})`
374371
}
375372

376-
if (summary.digest.length !== SHA256_DIGEST_LENGTH * 2) {
377-
return 'ots proof digest is not 32 bytes'
378-
}
379-
380373
const normalizedTarget = typeof targetEventId === 'string' ? targetEventId.toLowerCase() : ''
381374
if (!/^[0-9a-f]{64}$/.test(normalizedTarget)) {
382375
return 'target event id is not a 32-byte hex string'

test/unit/utils/nip03.spec.ts

Lines changed: 162 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,23 @@
11
import { expect } from 'chai'
22

3-
import { OtsParseResult, parseOtsFile, validateOtsProof } from '../../../src/utils/nip03'
3+
import { OtsParseResult, OtsReader, parseOtsFile, validateOtsProof } from '../../../src/utils/nip03'
4+
5+
/** LEB128-encode a non-negative integer that may exceed `Number.MAX_SAFE_INTEGER`. */
6+
function leb128FromBigInt(n: bigint): Buffer {
7+
const bytes: number[] = []
8+
let v = n
9+
while (true) {
10+
const b = Number(v & 0x7fn)
11+
v >>= 7n
12+
if (v !== 0n) {
13+
bytes.push(b | 0x80)
14+
} else {
15+
bytes.push(b)
16+
break
17+
}
18+
}
19+
return Buffer.from(bytes)
20+
}
421

522
function expectFailure(result: OtsParseResult): { ok: false; reason: string } {
623
if (result.ok !== false) {
@@ -34,12 +51,20 @@ const PENDING_TAG = Buffer.from([0x83, 0xdf, 0xe3, 0x0d, 0x2e, 0xf9, 0x0c, 0x8e]
3451
const LITECOIN_TAG = Buffer.from([0x06, 0x86, 0x9a, 0x0d, 0x73, 0xd7, 0x1b, 0x45])
3552
const UNKNOWN_TAG = Buffer.from([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11])
3653

54+
const OP_SHA1 = 0x02
55+
const OP_RIPEMD160 = 0x03
3756
const OP_SHA256 = 0x08
57+
const OP_KECCAK256 = 0x67
3858
const OP_APPEND = 0xf0
59+
const OP_PREPEND = 0xf1
60+
const OP_REVERSE = 0xf2
61+
const OP_HEXLIFY = 0xf3
3962

4063
const TAG_BRANCH = 0xff
4164
const TAG_ATTESTATION = 0x00
4265

66+
const ETHEREUM_TAG = Buffer.from([0x30, 0xfe, 0x80, 0x87, 0xb5, 0xc7, 0xea, 0xd7])
67+
4368
function writeVarUint(n: number): Buffer {
4469
const bytes: number[] = []
4570
let value = n
@@ -81,6 +106,29 @@ function unknownAttestation(payload: Buffer): Buffer {
81106
return Buffer.concat([Buffer.from([TAG_ATTESTATION]), UNKNOWN_TAG, writeVarBytes(payload)])
82107
}
83108

109+
function ethereumAttestation(height: number): Buffer {
110+
const payload = writeVarUint(height)
111+
return Buffer.concat([Buffer.from([TAG_ATTESTATION]), ETHEREUM_TAG, writeVarBytes(payload)])
112+
}
113+
114+
/**
115+
* Build a minimal `.ots` file with a given file-hash op and digest length.
116+
* `subItems` are the attestation/op byte sequences; all but the last are
117+
* prefixed with the 0xff branch marker (same layout as `buildOts`).
118+
*/
119+
function buildOtsWithFileHashOp(fileHashOp: number, digest: Buffer, subItems: Buffer[]): Buffer {
120+
if (subItems.length === 0) {
121+
throw new Error('need at least one sub-item')
122+
}
123+
const parts: Buffer[] = [MAGIC, writeVarUint(1), Buffer.from([fileHashOp]), digest]
124+
for (let i = 0; i < subItems.length - 1; i++) {
125+
parts.push(Buffer.from([TAG_BRANCH]))
126+
parts.push(subItems[i])
127+
}
128+
parts.push(subItems[subItems.length - 1])
129+
return Buffer.concat(parts)
130+
}
131+
84132
/**
85133
* Build a minimal SHA256-digest `.ots` file whose commitment tree is a single
86134
* leaf attestation. `subItems` are the attestation/op byte sequences that
@@ -102,6 +150,19 @@ function buildOts(digest: Buffer, subItems: Buffer[]): Buffer {
102150
const EVENT_ID = 'e71c6ea722987debdb60f81f9ea4f604b5ac0664120dd64fb9d23abc4ec7c323'
103151
const DIGEST = Buffer.from(EVENT_ID, 'hex')
104152

153+
describe('OtsReader', () => {
154+
it('readBytes rejects a negative length', () => {
155+
const r = new OtsReader(Buffer.from([1, 2, 3]))
156+
expect(() => r.readBytes(-1)).to.throw(/invalid negative read length/)
157+
})
158+
159+
it('readVarUint rejects values above Number.MAX_SAFE_INTEGER', () => {
160+
const encoded = leb128FromBigInt(BigInt(Number.MAX_SAFE_INTEGER) + 1n)
161+
const r = new OtsReader(encoded)
162+
expect(() => r.readVarUint()).to.throw(/exceeds safe integer range/)
163+
})
164+
})
165+
105166
describe('NIP-03 — OpenTimestamps', () => {
106167
describe('parseOtsFile', () => {
107168
it('parses a minimal proof with a single bitcoin attestation', () => {
@@ -176,6 +237,102 @@ describe('NIP-03 — OpenTimestamps', () => {
176237
it('returns a structured failure on empty input instead of throwing', () => {
177238
expectFailure(parseOtsFile(Buffer.alloc(0)))
178239
})
240+
241+
it('rejects non-Buffer input the same as empty (typed array is not a Buffer)', () => {
242+
const result = expectFailure(parseOtsFile(new Uint8Array([0x01, 0x02]) as unknown as Buffer))
243+
expect(result.reason).to.equal('empty ots file')
244+
})
245+
246+
it('parses sha1 file digest (20-byte) proofs', () => {
247+
const digest = Buffer.alloc(20, 0xab)
248+
const buf = buildOtsWithFileHashOp(OP_SHA1, digest, [bitcoinAttestation(1)])
249+
const result = expectSuccess(parseOtsFile(buf))
250+
expect(result.summary.fileHashOp).to.equal('sha1')
251+
expect(result.summary.digest).to.equal(digest.toString('hex'))
252+
})
253+
254+
it('parses ripemd160 file digest proofs', () => {
255+
const digest = Buffer.alloc(20, 0xcd)
256+
const buf = buildOtsWithFileHashOp(OP_RIPEMD160, digest, [bitcoinAttestation(2)])
257+
const result = expectSuccess(parseOtsFile(buf))
258+
expect(result.summary.fileHashOp).to.equal('ripemd160')
259+
})
260+
261+
it('parses keccak256 file digest proofs', () => {
262+
const digest = Buffer.alloc(32, 0xef)
263+
const buf = buildOtsWithFileHashOp(OP_KECCAK256, digest, [bitcoinAttestation(3)])
264+
const result = expectSuccess(parseOtsFile(buf))
265+
expect(result.summary.fileHashOp).to.equal('keccak256')
266+
})
267+
268+
it('parses prepend binary op in the commitment tree', () => {
269+
const prepend = Buffer.concat([Buffer.from([OP_PREPEND]), writeVarBytes(Buffer.from([0x01]))])
270+
const buf = buildOts(DIGEST, [Buffer.concat([prepend, bitcoinAttestation(4)])])
271+
const result = expectSuccess(parseOtsFile(buf))
272+
expect(result.summary.attestations.some((a) => a.kind === 'bitcoin')).to.equal(true)
273+
})
274+
275+
it('parses reverse and hexlify unary ops wrapping an attestation', () => {
276+
const revThenHex = Buffer.concat([Buffer.from([OP_REVERSE]), Buffer.from([OP_HEXLIFY]), bitcoinAttestation(5)])
277+
const buf = buildOts(DIGEST, [revThenHex])
278+
const result = expectSuccess(parseOtsFile(buf))
279+
expect(result.summary.attestations[0].kind).to.equal('bitcoin')
280+
})
281+
282+
it('classifies ethereum block header attestations', () => {
283+
const buf = buildOts(DIGEST, [ethereumAttestation(18_000_000)])
284+
const result = expectSuccess(parseOtsFile(buf))
285+
const eth = result.summary.attestations.find((a) => a.kind === 'ethereum')
286+
expect(eth).to.exist
287+
expect(eth?.height).to.equal(18_000_000)
288+
})
289+
290+
it('treats a truncated bitcoin attestation payload as height-less', () => {
291+
const broken = Buffer.concat([Buffer.from([TAG_ATTESTATION]), BITCOIN_TAG, writeVarUint(0)])
292+
const buf = buildOts(DIGEST, [broken])
293+
const result = expectSuccess(parseOtsFile(buf))
294+
expect(result.summary.attestations[0]).to.include({ kind: 'bitcoin' })
295+
expect(result.summary.attestations[0].height).to.equal(undefined)
296+
})
297+
298+
it('rejects unknown commitment op tags', () => {
299+
const buf = Buffer.concat([MAGIC, writeVarUint(1), Buffer.from([OP_SHA256]), DIGEST, Buffer.from([0xfe])])
300+
const result = expectFailure(parseOtsFile(buf))
301+
expect(result.reason).to.match(/unknown op tag/)
302+
})
303+
304+
it('rejects varints that overflow the LEB128 decoder', () => {
305+
const buf = Buffer.concat([MAGIC, Buffer.alloc(9, 0x80)])
306+
const result = expectFailure(parseOtsFile(buf))
307+
expect(result.reason).to.match(/varint overflow/)
308+
})
309+
310+
it('rejects varbytes length fields above the relay maximum', () => {
311+
const buf = Buffer.concat([
312+
MAGIC,
313+
writeVarUint(1),
314+
Buffer.from([OP_SHA256]),
315+
DIGEST,
316+
Buffer.from([OP_APPEND]),
317+
writeVarUint(8193),
318+
])
319+
const result = expectFailure(parseOtsFile(buf))
320+
expect(result.reason).to.match(/exceeds maximum/)
321+
})
322+
323+
it('rejects commitment trees deeper than the recursion cap', () => {
324+
// 129 nested (0xff, OP_SHA256) pairs: readTimestamp(128) then dispatches into
325+
// readTimestamp(129), which exceeds MAX_RECURSION_DEPTH (128).
326+
const pairs = 129
327+
const nested = Buffer.alloc(pairs * 2)
328+
for (let i = 0; i < pairs; i++) {
329+
nested[i * 2] = TAG_BRANCH
330+
nested[i * 2 + 1] = OP_SHA256
331+
}
332+
const buf = Buffer.concat([MAGIC, writeVarUint(1), Buffer.from([OP_SHA256]), DIGEST, nested])
333+
const result = expectFailure(parseOtsFile(buf))
334+
expect(result.reason).to.match(/too deep/)
335+
})
179336
})
180337

181338
describe('validateOtsProof', () => {
@@ -206,6 +363,10 @@ describe('NIP-03 — OpenTimestamps', () => {
206363
expect(validateOtsProof(validProof, 'not-an-id')).to.match(/not a 32-byte hex/)
207364
})
208365

366+
it('rejects a non-string target event id', () => {
367+
expect(validateOtsProof(validProof, null as unknown as string)).to.match(/not a 32-byte hex/)
368+
})
369+
209370
it('rejects proofs without any bitcoin attestation', () => {
210371
const onlyPending = buildOts(DIGEST, [pendingAttestation('https://a.pool.opentimestamps.org')]).toString('base64')
211372
expect(validateOtsProof(onlyPending, EVENT_ID)).to.match(/bitcoin attestation/)

0 commit comments

Comments
 (0)