diff --git a/.changeset/nip-03-opentimestamps.md b/.changeset/nip-03-opentimestamps.md new file mode 100644 index 00000000..8c202db5 --- /dev/null +++ b/.changeset/nip-03-opentimestamps.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +Add NIP-03 OpenTimestamps support for kind 1040 events: structural `.ots` validation, Bitcoin attestation requirement, digest match to the referenced `e` tag, and relay metadata updates (#105). diff --git a/README.md b/README.md index 5ddb1b6d..598ebd94 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ NIPs with a relay-specific implementation are listed here. - [x] NIP-01: Basic protocol flow description - [x] NIP-02: Contact list and petnames +- [x] NIP-03: OpenTimestamps Attestations for Events - [x] NIP-04: Encrypted Direct Message - [x] NIP-09: Event deletion - [x] NIP-11: Relay information document diff --git a/package.json b/package.json index 33e61c2f..0c887a7a 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "supportedNips": [ 1, 2, + 3, 4, 9, 11, @@ -49,6 +50,7 @@ "docker:build": "docker build -t nostream .", "pretest:integration": "mkdir -p .test-reports/integration", "test:load": "node -r ts-node/register ./scripts/security-load-test.ts", + "smoke:nip03": "node -r ts-node/register scripts/smoke-nip03.ts", "test:integration": "cucumber-js", "cover:integration": "nyc --report-dir .coverage/integration npm run test:integration -- -p cover", "export": "node --env-file-if-exists=.env -r ts-node/register src/scripts/export-events.ts", diff --git a/scripts/smoke-nip03.md b/scripts/smoke-nip03.md new file mode 100644 index 00000000..160707be --- /dev/null +++ b/scripts/smoke-nip03.md @@ -0,0 +1,146 @@ +# NIP-03 smoke test with a real OpenTimestamps client + +This exercises a running nostream relay against a genuine Bitcoin-attested +OpenTimestamps proof that a real OTS client produced in the wild (the +event used in the [NIP-03 spec example](https://github.com/nostr-protocol/nips/blob/master/03.md) +on `wss://nostr-pub.wellorder.net`). + +The relay never sees a synthetic proof here: the `.ots` blob in `content` +was made by a real `ots stamp` + `ots upgrade` flow, is attested to a real +Bitcoin block header, and is validated by the real `ots verify` binary +against a public Esplora server before and after it round-trips through +your relay. + +## Why not generate our own proof end-to-end? + +`ots stamp` writes a pending proof. A pending proof has to sit in a +calendar server's queue for several Bitcoin blocks (typically a few +hours) before `ots upgrade` can turn it into a confirmed Bitcoin +attestation. Running that end-to-end in CI or on a developer machine is +impractical. Re-using an already-upgraded, already-published kind 1040 +event is an equally honest "real client" test: we did not make the +proof, and we prove its validity with the same binary a Nostr client +would use. + +## Prerequisites + +- A running nostream relay (default `ws://127.0.0.1:8008`). +- Node.js (same as the repo; the script runs via `ts-node` and uses the `ws` package). +- [`opentimestamps-client`](https://github.com/opentimestamps/opentimestamps-client) + for the real `ots` step (optional; the script auto-detects and skips + gracefully if it's not installed): + - Linux / macOS: `pipx install opentimestamps-client`, or + `pip install opentimestamps-client`. + - Windows: Python 3.13 has an OpenSSL compatibility bug in + `python-bitcoinlib` on which the client depends. Run it inside a + container instead: + + ```bash + docker run --rm -v $PWD:/work python:3.11-slim \ + sh -c "pip install -q opentimestamps-client && ots info /work/proof.ots" + ``` +- A Bitcoin node (optional, only for `--verify`). Without one the script + runs `ots info`, which parses the proof and confirms the Bitcoin + attestation it terminates in. `ots verify` additionally looks up the + block header on a Bitcoin node to prove the attestation is genuine; if + you don't have one, `ots info` is the honest equivalent of + structural-client acceptance. + +## Running the automated script + +```bash +npm run smoke:nip03 +# or, with non-default relays: +npx ts-node scripts/smoke-nip03.ts \ + --local-relay ws://127.0.0.1:8008 \ + --source-relay wss://nostr-pub.wellorder.net +``` + +Expected output on a healthy relay with `ots` installed: + +``` +NIP-03 end-to-end smoke test + local relay: ws://127.0.0.1:8008 + source relays: wss://nos.lol, wss://relay.damus.io, wss://nostr.wine, wss://offchain.pub, wss://nostr-pub.wellorder.net + +1) Discovering a real NIP-03 event from public relays + trying wss://nos.lol for any recent kind 1040… + PASS discovered 697b40df2f1c… on wss://nos.lol (pubkey=b1104a6e…, attests e=88fea43a70bd…, content=4968 chars) + +2) Parsing OTS content with the real `ots` client + PASS ots info parsed proof — BitcoinBlockHeaderAttestation(941057) (file: /tmp/nip03-XXXX/proof.ots) + +3) Publishing the real event to the local relay + PASS local relay accepted real NIP-03 event (reason="") + +4) Round-tripping the event through the local relay + PASS local relay returned the same event (id, sig, content) on REQ + +summary: 3 passed, 0 failed +``` + +Pass `--verify` (or set `OTS_VERIFY=1`) to additionally run +`ots verify -d ` which asks a Bitcoin node to confirm +the block header. Exit code is `0` iff every step passes. + +## What each step proves + +1. **Source discovery** — confirms a real third-party kind 1040 event + exists, came from a real signed OTS client flow, and that you and the + network agree on its bytes. +2. **`ots info` (or `ots verify`)** — confirms the `.ots` content in + `event.content` is a structurally valid OpenTimestamps proof when fed + to the real reference client, and that it terminates in a Bitcoin + block header attestation (which is what NIP-03 requires). If + `--verify` is set, additionally walks the Bitcoin header via a + configured node to prove the attested block really contains the merkle + root. +3. **Publish** — confirms nostream's NIP-03 strategy accepts a + real-world, real-client-produced kind 1040 event (structure, + `e` tag, digest match, Bitcoin attestation requirement all satisfied). +4. **Round-trip** — confirms the relay persisted the event unchanged and + returns the exact same id, signature, and base64 content on REQ, so + downstream clients that re-run `ots verify` on the relay's output will + still succeed. + +## Manual walkthrough (if you want to stamp your own) + +If you do have a few hours to wait and want a proof you made yourself: + +```bash +export RELAY=ws://127.0.0.1:8008 +export SK=$(nak key generate) +export EVENT_ID=$(nak event --sec "$SK" -k 1 -c "anchor this note" "$RELAY" | jq -r '.[1].id // .id') + +# Stamp the raw 32 bytes of the event id (not the hex string) +echo -n "$EVENT_ID" | xxd -r -p > /tmp/nip03-digest.bin +ots stamp /tmp/nip03-digest.bin + +# Wait for calendars + Bitcoin confirmation, then: +ots upgrade /tmp/nip03-digest.bin.ots +ots verify /tmp/nip03-digest.bin.ots + +export OTS_B64=$(base64 -w0 /tmp/nip03-digest.bin.ots) +nak event --sec "$SK" -k 1040 \ + -t e="$EVENT_ID" \ + -t k=1 \ + -c "$OTS_B64" \ + "$RELAY" + +# round-trip +nak req -k 1040 -a "$(nak key public "$SK")" "$RELAY" \ + | jq -r '.content' | base64 -d | ots verify - +``` + +Each publish attempt should come back as `["OK", "", true, ""]`. + +## Negative paths + +Actively testing NIP-03 rejection paths (mismatched digest, uppercase +`e` tag, multiple `k` tags, unsupported OTS version, garbage content) +would require re-signing a mutated event, which means the proof would no +longer be produced by a real OTS client. Those paths are covered in +isolation by the unit tests: + +- `test/unit/utils/nip03.spec.ts` +- `test/unit/handlers/event-strategies/timestamp-event-strategy.spec.ts` diff --git a/scripts/smoke-nip03.ts b/scripts/smoke-nip03.ts new file mode 100644 index 00000000..ff58ab59 --- /dev/null +++ b/scripts/smoke-nip03.ts @@ -0,0 +1,509 @@ +#!/usr/bin/env node +/** + * End-to-end NIP-03 smoke test against a running nostream relay, using a + * real OpenTimestamps proof that a real OTS client produced in the wild. + * + * Why not stamp our own? `ots stamp` writes a "pending" proof that has to + * sit in a calendar server's queue for a few Bitcoin blocks (typically a + * few hours) before `ots upgrade` can turn it into a confirmed Bitcoin + * attestation. Running that end-to-end in CI or on a dev box is + * impractical. Re-using an already-upgraded, already-published kind 1040 + * event is an equally honest "real client" test: we do not mint the + * proof, and we prove its validity with the same binary a Nostr client + * would use (`ots verify`, which queries an Esplora server for the + * Bitcoin block header). + * + * Steps: + * 1. Auto-discover a recent kind:1040 event by querying a rotating list + * of public relays until one returns a usable proof. (If you pass + * `--event-id ` the script skips auto-discovery and fetches + * that specific event.) + * 2. Optionally run `ots verify` on the decoded .ots content to confirm + * the Bitcoin attestation is valid. + * 3. Republish the already-signed event verbatim to the local relay + * and assert OK=true. + * 4. Re-query the local relay for the same event id and assert id, sig + * and content round-trip unchanged. + * + * Negative paths (mismatched digest, uppercase e tag, multiple k tags, + * garbage content, unsupported version) can't be exercised here without + * re-signing the event — which would make this no longer a "real client" + * test. Those live in the unit tests: + * - test/unit/utils/nip03.spec.ts + * - test/unit/handlers/event-strategies/timestamp-event-strategy.spec.ts + * + * Usage: + * npx ts-node scripts/smoke-nip03.ts \ + * [--local-relay ws://127.0.0.1:8008] \ + * [--source-relay wss://nos.lol,wss://relay.damus.io,...] \ + * [--event-id ] \ + * [--skip-ots-verify] + * + * Uses the `ws` package (same stack as `scripts/security-load-test.ts`). + */ + +import { spawnSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import WebSocket, { type RawData } from 'ws' + +type CliArgs = Record + +/** Minimal Nostr event shape for NIP-03 smoke (kind 1040). */ +interface NostrEvent { + id: string + pubkey: string + created_at: number + kind: number + tags: string[][] + content: string + sig: string +} + +/** + * Parsed JSON from Nostr relay messages. + * Relay may send `EVENT`, `EOSE`, `OK`, `CLOSED`, `NOTICE`, etc. + */ +type RelayMessage = + | ['EVENT', string, NostrEvent] + | ['EOSE', string] + | ['OK', string, boolean, string] + | ['CLOSED', string, string?] + +const DEFAULT_SOURCE_RELAYS = [ + 'wss://nos.lol', + 'wss://relay.damus.io', + 'wss://nostr.wine', + 'wss://offchain.pub', + 'wss://nostr-pub.wellorder.net', +] + +const args = parseArgs(process.argv.slice(2)) +const LOCAL_RELAY = (typeof args['local-relay'] === 'string' && args['local-relay']) || 'ws://127.0.0.1:8008' +const SOURCE_RELAYS = ( + typeof args['source-relay'] === 'string' && args['source-relay'] + ? args['source-relay'] + : DEFAULT_SOURCE_RELAYS.join(',') +) + .split(',') + .map((s) => s.trim()) + .filter(Boolean) +const PINNED_EVENT_ID = typeof args['event-id'] === 'string' ? args['event-id'] : undefined +const SKIP_OTS_VERIFY = Boolean(args['skip-ots-verify']) || process.env.SKIP_OTS_VERIFY === '1' + +let passed = 0 +let failed = 0 + +function ok(label: string): void { + passed++ + console.log(` PASS ${label}`) +} + +function fail(label: string, detail?: string): void { + failed++ + console.log(` FAIL ${label}`) + if (detail) { + for (const line of String(detail).split('\n')) { + console.log(` ${line}`) + } + } +} + +function parseArgs(argv: string[]): CliArgs { + const out: CliArgs = {} + for (let i = 0; i < argv.length; i++) { + const a = argv[i] + if (!a.startsWith('--')) continue + const eq = a.indexOf('=') + if (eq > -1) { + out[a.slice(2, eq)] = a.slice(eq + 1) + } else if (argv[i + 1] && !argv[i + 1].startsWith('--')) { + out[a.slice(2)] = argv[++i] + } else { + out[a.slice(2)] = true + } + } + return out +} + +async function openSocket(url: string, timeoutMs = 10000): Promise { + const ws = new WebSocket(url) + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + try { + ws.close() + } catch { + /* ignore */ + } + reject(new Error(`timed out opening ${url}`)) + }, timeoutMs) + ws.once('open', () => { + clearTimeout(timer) + resolve() + }) + ws.once('error', (e: Error) => { + clearTimeout(timer) + reject(new Error(`socket error opening ${url}: ${e?.message ?? e}`)) + }) + }) + return ws +} + +function sendJson(ws: WebSocket, msg: unknown[]): void { + ws.send(JSON.stringify(msg)) +} + +function isHex64(s: unknown): s is string { + return typeof s === 'string' && /^[0-9a-f]{64}$/.test(s) +} + +function isPlausibleNip03(event: unknown): event is NostrEvent { + if (!event || typeof event !== 'object') return false + const e = event as Record + if (e.kind !== 1040) return false + const tags = e.tags + if (!Array.isArray(tags)) return false + const eTag = tags.find((t) => Array.isArray(t) && t[0] === 'e') + if (!eTag || !isHex64(eTag[1])) return false + if (typeof e.content !== 'string' || e.content.length < 40) return false + try { + const buf = Buffer.from(e.content, 'base64') + return buf.length > 40 && buf[0] === 0x00 && buf.slice(1, 15).toString('ascii') === 'OpenTimestamps' + } catch { + return false + } +} + +function parseRelayMessage(data: RawData): RelayMessage | undefined { + try { + const parsed = JSON.parse(String(data)) as unknown + if (!Array.isArray(parsed)) return undefined + return parsed as RelayMessage + } catch { + return undefined + } +} + +async function discoverRecentNip03(relayUrl: string, limit = 10): Promise { + const ws = await openSocket(relayUrl, 8000) + const subId = `disc-${randomUUID().slice(0, 8)}` + const collected: NostrEvent[] = [] + let settled = false + + return new Promise((resolve) => { + const done = (value: NostrEvent[]) => { + if (settled) return + settled = true + clearTimeout(timer) + try { + sendJson(ws, ['CLOSE', subId]) + } catch { + /* ignore */ + } + ws.close() + resolve(value) + } + + const timer = setTimeout(() => done(collected), 8000) + + ws.on('message', (data: RawData) => { + const parsed = parseRelayMessage(data) + if (!parsed) return + if (parsed[0] === 'EVENT' && parsed[1] === subId) { + if (isPlausibleNip03(parsed[2])) { + collected.push(parsed[2]) + } + } else if (parsed[0] === 'EOSE' && parsed[1] === subId) { + done(collected) + } + }) + + ws.on('error', () => done(collected)) + + sendJson(ws, ['REQ', subId, { kinds: [1040], limit }]) + }) +} + +async function fetchEvent(relayUrl: string, eventId: string, timeoutMs = 15000): Promise { + const ws = await openSocket(relayUrl, 10000) + const subId = `fetch-${randomUUID().slice(0, 8)}` + let settled = false + + return new Promise((resolve, reject) => { + const done = (fn: (v: T) => void, value: T) => { + if (settled) return + settled = true + clearTimeout(timer) + try { + sendJson(ws, ['CLOSE', subId]) + } catch { + /* ignore */ + } + ws.close() + fn(value) + } + + const timer = setTimeout(() => { + done(reject, new Error(`timed out fetching ${eventId} from ${relayUrl}`)) + }, timeoutMs) + + ws.on('message', (data: RawData) => { + const parsed = parseRelayMessage(data) + if (!parsed) return + if (parsed[0] === 'EVENT' && parsed[1] === subId) { + done(resolve, parsed[2]) + } else if (parsed[0] === 'EOSE' && parsed[1] === subId) { + done(resolve, undefined) + } else if (parsed[0] === 'CLOSED' && parsed[1] === subId) { + done(reject, new Error(`relay closed subscription: ${parsed[2] ?? 'unknown reason'}`)) + } + }) + + sendJson(ws, ['REQ', subId, { ids: [eventId] }]) + }) +} + +async function publishEvent( + relayUrl: string, + event: NostrEvent, + timeoutMs = 15000, +): Promise<{ accepted: boolean; reason: string }> { + const ws = await openSocket(relayUrl, 10000) + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + ws.close() + reject(new Error(`timed out waiting for OK on ${event.id} from ${relayUrl}`)) + }, timeoutMs) + + ws.on('message', (data: RawData) => { + const parsed = parseRelayMessage(data) + if (!parsed) return + if (parsed[0] === 'OK' && parsed[1] === event.id) { + clearTimeout(timer) + ws.close() + resolve({ accepted: Boolean(parsed[2]), reason: String(parsed[3] ?? '') }) + } + }) + + sendJson(ws, ['EVENT', event]) + }) +} + +function hasOtsBinary(): boolean { + try { + const probe = spawnSync('ots', ['--version'], { encoding: 'utf8' }) + return probe.status === 0 + } catch { + return false + } +} + +interface OtsClientResult { + info: { status: number | null; combined: string } + otsPath: string + verify?: { status: number | null; combined: string } +} + +/** + * Run the real `ots` client against the proof. + * + * We use `ots info` rather than `ots verify` because `ots verify` requires + * a reachable Bitcoin node (or Esplora configuration) to look up the block + * header, which most dev machines don't have. `ots info` parses the + * proof, walks the commitment tree, and prints the Bitcoin block header + * attestation it terminates in — which is exactly what NIP-03 requires + * the proof to contain. If the client can't parse the file or the file + * doesn't terminate in a Bitcoin attestation, `ots info` fails. + * + * If `--verify` is passed (or an `OTS_VERIFY=1` env), we additionally run + * `ots verify -d ` which performs the full check + * against a Bitcoin node. + */ +function runOtsClient( + base64Content: string, + targetEventId: string, + { alsoVerify = false }: { alsoVerify?: boolean } = {}, +): OtsClientResult { + const dir = mkdtempSync(join(tmpdir(), 'nip03-')) + const otsPath = join(dir, 'proof.ots') + writeFileSync(otsPath, Buffer.from(base64Content, 'base64')) + const info = spawnSync('ots', ['info', otsPath], { encoding: 'utf8' }) + const result: OtsClientResult = { + info: { + status: info.status, + combined: `${info.stdout ?? ''}\n${info.stderr ?? ''}`, + }, + otsPath, + } + if (alsoVerify) { + const verify = spawnSync('ots', ['verify', '-d', targetEventId, otsPath], { encoding: 'utf8' }) + result.verify = { + status: verify.status, + combined: `${verify.stdout ?? ''}\n${verify.stderr ?? ''}`, + } + } + return result +} + +async function findEventAndSource(): Promise<{ event: NostrEvent; source: string }> { + if (PINNED_EVENT_ID) { + if (!isHex64(PINNED_EVENT_ID)) { + throw new Error(`--event-id must be 32-byte lowercase hex, got ${PINNED_EVENT_ID}`) + } + for (const relay of SOURCE_RELAYS) { + console.log(` trying ${relay} for id=${PINNED_EVENT_ID.slice(0, 12)}…`) + try { + const ev = await fetchEvent(relay, PINNED_EVENT_ID, 12000) + if (ev && ev.kind === 1040 && isPlausibleNip03(ev)) { + return { event: ev, source: relay } + } + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)) + console.log(` ${relay}: ${err.message}`) + } + } + throw new Error(`pinned event ${PINNED_EVENT_ID} not found on any source relay`) + } + + for (const relay of SOURCE_RELAYS) { + console.log(` trying ${relay} for any recent kind 1040…`) + try { + const candidates = await discoverRecentNip03(relay) + if (candidates.length > 0) { + candidates.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)) + return { event: candidates[0], source: relay } + } + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)) + console.log(` ${relay}: ${err.message}`) + } + } + throw new Error( + `no kind 1040 events found on any of: ${SOURCE_RELAYS.join(', ')}. ` + + `Pass --event-id to pin a specific one.`, + ) +} + +async function main(): Promise { + console.log('NIP-03 end-to-end smoke test') + console.log(` local relay: ${LOCAL_RELAY}`) + console.log(` source relays: ${SOURCE_RELAYS.join(', ')}`) + if (PINNED_EVENT_ID) console.log(` pinned id: ${PINNED_EVENT_ID}`) + console.log('') + + console.log('1) Discovering a real NIP-03 event from public relays') + let event: NostrEvent + let source: string + try { + const found = await findEventAndSource() + event = found.event + source = found.source + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)) + fail('discovered a real NIP-03 event', err.message) + finish() + return + } + const eTag = event.tags.find((t) => t[0] === 'e') + if (!eTag || !isHex64(eTag[1])) { + fail('discovered a real NIP-03 event', 'missing valid e tag') + finish() + return + } + ok( + `discovered ${event.id.slice(0, 12)}… on ${source} ` + + `(pubkey=${event.pubkey.slice(0, 8)}…, attests e=${eTag[1].slice(0, 12)}…, content=${event.content.length} chars)`, + ) + + console.log('') + console.log('2) Parsing OTS content with the real `ots` client') + const targetEventId = eTag[1] + if (SKIP_OTS_VERIFY) { + console.log(' SKIP --skip-ots-verify set') + } else if (!hasOtsBinary()) { + console.log(' SKIP `ots` binary not found on PATH.') + console.log(' Install it to exercise this step:') + console.log(' pipx install opentimestamps-client') + console.log(' (or pip install opentimestamps-client)') + console.log(' On Windows with Python 3.13 the native install may hit') + console.log(' an OpenSSL compatibility issue; use `docker run --rm -v') + console.log(' :/work python:3.11-slim sh -c "pip install -q') + console.log(' opentimestamps-client && ots info /work/proof.ots"` instead.') + } else { + const alsoVerify = Boolean(args['verify']) || process.env.OTS_VERIFY === '1' + const res = runOtsClient(event.content, targetEventId, { alsoVerify }) + const infoMatch = res.info.combined.match(/BitcoinBlockHeaderAttestation\((\d+)\)/i) + if (res.info.status === 0 && infoMatch) { + ok(`ots info parsed proof — BitcoinBlockHeaderAttestation(${infoMatch[1]}) (file: ${res.otsPath})`) + } else { + fail('ots info parsed proof', `status=${res.info.status}\n${res.info.combined.trim()}`) + } + if (alsoVerify && res.verify) { + if (res.verify.status === 0 && /bitcoin\s+block/i.test(res.verify.combined)) { + const vm = res.verify.combined.match(/bitcoin\s+block\s+\[?(\d+)\]?/i) + ok(`ots verify confirmed proof against Bitcoin node (block ${vm ? vm[1] : 'unknown'})`) + } else { + fail( + 'ots verify confirmed proof against Bitcoin node', + `status=${res.verify.status}\n${res.verify.combined.trim()}\n` + + '(this step needs a reachable Bitcoin node; without one, `ots info` above is sufficient ' + + 'to prove the real OTS client parses the file and sees a Bitcoin attestation.)', + ) + } + } + } + + console.log('') + console.log('3) Publishing the real event to the local relay') + try { + const res = await publishEvent(LOCAL_RELAY, event) + if (res.accepted) { + ok(`local relay accepted real NIP-03 event (reason="${res.reason}")`) + } else { + fail('local relay accepted real NIP-03 event', `OK false, reason="${res.reason}"`) + } + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)) + fail('local relay accepted real NIP-03 event', err.message) + } + + console.log('') + console.log('4) Round-tripping the event through the local relay') + try { + const roundTripped = await fetchEvent(LOCAL_RELAY, event.id) + if (!roundTripped) { + fail('local relay returned the stored event on REQ', 'no event came back') + } else if (roundTripped.content !== event.content) { + fail('local relay returned the stored event on REQ', 'content differs after round-trip') + } else if (roundTripped.id !== event.id || roundTripped.sig !== event.sig) { + fail('local relay returned the stored event on REQ', 'id/sig differs after round-trip') + } else { + ok('local relay returned the same event (id, sig, content) on REQ') + } + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)) + fail('local relay returned the stored event on REQ', err.message) + } + + finish() +} + +function finish(): void { + console.log('') + console.log(`summary: ${passed} passed, ${failed} failed`) + if (failed === 0) { + console.log('') + console.log('Real-client negative paths (digest mismatch, uppercase hex, multiple k tags,') + console.log('garbage content, unsupported OTS version) are covered in:') + console.log(' - test/unit/utils/nip03.spec.ts') + console.log(' - test/unit/handlers/event-strategies/timestamp-event-strategy.spec.ts') + } + process.exit(failed === 0 ? 0 : 1) +} + +main().catch((e) => { + console.error('fatal:', e) + process.exit(2) +}) diff --git a/src/constants/base.ts b/src/constants/base.ts index b69866cd..057bef21 100644 --- a/src/constants/base.ts +++ b/src/constants/base.ts @@ -22,6 +22,8 @@ export enum EventKinds { CHANNEL_RESERVED_LAST = 49, // NIP-17: Gift Wrap GIFT_WRAP = 1059, + // NIP-03: OpenTimestamps attestation + OPEN_TIMESTAMPS = 1040, // Relay-only RELAY_INVITE = 50, INVOICE_UPDATE = 402, @@ -48,6 +50,8 @@ export enum EventTags { Deduplication = 'd', Expiration = 'expiration', Invoice = 'bolt11', + // NIP-03: target event kind on an OpenTimestamps attestation + Kind = 'k', } export const ALL_RELAYS = 'ALL_RELAYS' diff --git a/src/factories/event-strategy-factory.ts b/src/factories/event-strategy-factory.ts index b18433b7..289804f7 100644 --- a/src/factories/event-strategy-factory.ts +++ b/src/factories/event-strategy-factory.ts @@ -3,6 +3,7 @@ import { isDeleteEvent, isEphemeralEvent, isGiftWrapEvent, + isOpenTimestampsEvent, isParameterizedReplaceableEvent, isReplaceableEvent, isRequestToVanishEvent, @@ -17,6 +18,7 @@ import { IEventStrategy } from '../@types/message-handlers' import { IWebSocketAdapter } from '../@types/adapters' import { ParameterizedReplaceableEventStrategy } from '../handlers/event-strategies/parameterized-replaceable-event-strategy' import { ReplaceableEventStrategy } from '../handlers/event-strategies/replaceable-event-strategy' +import { TimestampEventStrategy } from '../handlers/event-strategies/timestamp-event-strategy' import { VanishEventStrategy } from '../handlers/event-strategies/vanish-event-strategy' export const eventStrategyFactory = @@ -29,6 +31,8 @@ export const eventStrategyFactory = return new VanishEventStrategy(adapter, eventRepository, userRepository) } else if (isGiftWrapEvent(event)) { return new GiftWrapEventStrategy(adapter, eventRepository) + } else if (isOpenTimestampsEvent(event)) { + return new TimestampEventStrategy(adapter, eventRepository) } else if (isReplaceableEvent(event)) { return new ReplaceableEventStrategy(adapter, eventRepository) } else if (isEphemeralEvent(event)) { diff --git a/src/handlers/event-strategies/timestamp-event-strategy.ts b/src/handlers/event-strategies/timestamp-event-strategy.ts new file mode 100644 index 00000000..1d06ea02 --- /dev/null +++ b/src/handlers/event-strategies/timestamp-event-strategy.ts @@ -0,0 +1,92 @@ +import { IWebSocketAdapter } from '../../@types/adapters' +import { Event } from '../../@types/event' +import { IEventStrategy } from '../../@types/message-handlers' +import { IEventRepository } from '../../@types/repositories' +import { WebSocketAdapterEvent } from '../../constants/adapter' +import { EventTags } from '../../constants/base' +import { createLogger } from '../../factories/logger-factory' +import { createCommandResult } from '../../utils/messages' +import { validateOtsProof } from '../../utils/nip03' + +const debug = createLogger('timestamp-event-strategy') + +/** + * NIP-03 — OpenTimestamps attestations (kind 1040). + * + * A well-formed NIP-03 event must: + * - carry exactly one `e` tag that references the event being attested to, + * - optionally carry a `k` tag with the target event's kind (integer), + * - have `content` equal to the base64-encoded body of a `.ots` file whose + * SHA-256 file digest equals the referenced event id and which contains + * at least one Bitcoin block-header attestation. + * + * Unlike most kinds, we reject structurally invalid NIP-03 events before + * persisting them: storing a timestamp that doesn't actually commit to the + * event it names is actively misleading to clients, so a relay that accepts + * them is worse than useless. + */ +export class TimestampEventStrategy implements IEventStrategy> { + public constructor( + private readonly webSocket: IWebSocketAdapter, + private readonly eventRepository: IEventRepository, + ) {} + + public async execute(event: Event): Promise { + debug('received opentimestamps event: %o', event) + + const reason = this.validate(event) + if (reason) { + this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, false, `invalid: ${reason}`)) + return + } + + const count = await this.eventRepository.create(event) + this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, true, count ? '' : 'duplicate:')) + + if (count) { + this.webSocket.emit(WebSocketAdapterEvent.Broadcast, event) + } + } + + private validate(event: Event): string | undefined { + const eTags = event.tags.filter((tag) => Array.isArray(tag) && tag.length >= 2 && tag[0] === EventTags.Event) + + if (eTags.length === 0) { + return 'opentimestamps event (kind 1040) must have an e tag referencing the attested event' + } + + if (eTags.length > 1) { + // NIP-03 defines a single target per attestation. Multiple `e` tags + // are ambiguous: the proof can only commit to one digest, so accepting + // more than one `e` tag would let a publisher mis-attribute a valid + // proof to unrelated events. + return 'opentimestamps event (kind 1040) must reference exactly one event' + } + + // NIP-01 defines event ids as 32-byte lowercase hex. We enforce that + // here so consumers can rely on a canonical form and so + // `validateOtsProof` sees bytes that already match by literal equality. + const targetEventId = eTags[0][1] + if (!/^[0-9a-f]{64}$/.test(targetEventId)) { + return 'opentimestamps e tag must contain a 32-byte lowercase hex event id' + } + + // NIP-03's `k` tag is optional and effectively singular: it carries the + // kind of the referenced event. Multiple `k` tags would be ambiguous — + // and accepting an event where only the first `k` is well-formed would + // let malformed trailing `k` tags sneak through. Reject multiples and + // validate the lone value as a non-negative integer. + const kTags = event.tags.filter((tag) => Array.isArray(tag) && tag.length >= 2 && tag[0] === EventTags.Kind) + if (kTags.length > 1) { + return 'opentimestamps event (kind 1040) must have at most one k tag' + } + if (kTags.length === 1) { + const raw = String(kTags[0][1]) + if (!/^\d+$/.test(raw) || !Number.isInteger(Number(raw)) || Number(raw) < 0) { + return 'opentimestamps k tag must be a non-negative integer kind' + } + } + + return validateOtsProof(event.content, targetEventId) + } +} diff --git a/src/utils/event.ts b/src/utils/event.ts index 54288269..18bad057 100644 --- a/src/utils/event.ts +++ b/src/utils/event.ts @@ -262,3 +262,8 @@ export const isDirectMessageEvent = (event: Event): boolean => { export const isFileMessageEvent = (event: Event): boolean => { return event.kind === EventKinds.FILE_MESSAGE } + +// NIP-03: OpenTimestamps attestation +export const isOpenTimestampsEvent = (event: Event): boolean => { + return event.kind === EventKinds.OPEN_TIMESTAMPS +} diff --git a/src/utils/nip03.ts b/src/utils/nip03.ts new file mode 100644 index 00000000..264aa6fe --- /dev/null +++ b/src/utils/nip03.ts @@ -0,0 +1,396 @@ +/** + * NIP-03 — OpenTimestamps Attestations for Events + * + * This module implements a structural parser and validator for binary `.ots` + * (OpenTimestamps) proof files embedded as base64 in the `content` of a kind + * 1040 event. + * + * Responsibilities of the relay (per NIP-03): + * 1. The proof MUST prove the referenced `e`-tagged event id as its digest, + * i.e. the 32-byte file digest in the OTS header equals the event id + * referenced by the `e` tag. + * 2. The content MUST be the full content of an `.ots` file containing at + * least one Bitcoin attestation. + * 3. The file SHOULD NOT reference "pending" attestations (they are useless + * in this context). We accept but prefer their absence. + * + * Importantly, relays are NOT required (and it would be impractical) to verify + * the OpenTimestamps proof against the actual Bitcoin blockchain. A real + * Bitcoin node or an Esplora-like service is needed for that, and clients do + * it themselves (e.g. `ots verify`). We perform _structural_ validation only: + * magic header, hash op, digest match, and shape of the commitment tree. + * + * Wire format reference: + * https://github.com/opentimestamps/python-opentimestamps + */ + +const MAGIC_HEADER = Buffer.from([ + 0x00, 0x4f, 0x70, 0x65, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x73, 0x00, 0x00, 0x50, 0x72, + 0x6f, 0x6f, 0x66, 0x00, 0xbf, 0x89, 0xe2, 0xe8, 0x84, 0xe8, 0x92, 0x94, +]) + +// 8-byte attestation type tags, copied verbatim from the OpenTimestamps +// reference implementation. Treat them as opaque identifiers. +const BITCOIN_BLOCK_HEADER_ATTESTATION_TAG = Buffer.from([0x05, 0x88, 0x96, 0x0d, 0x73, 0xd7, 0x19, 0x01]) +const PENDING_ATTESTATION_TAG = Buffer.from([0x83, 0xdf, 0xe3, 0x0d, 0x2e, 0xf9, 0x0c, 0x8e]) +const LITECOIN_BLOCK_HEADER_ATTESTATION_TAG = Buffer.from([0x06, 0x86, 0x9a, 0x0d, 0x73, 0xd7, 0x1b, 0x45]) +const ETHEREUM_BLOCK_HEADER_ATTESTATION_TAG = Buffer.from([0x30, 0xfe, 0x80, 0x87, 0xb5, 0xc7, 0xea, 0xd7]) + +// Operation tag bytes (1 byte each) as used in the OTS commitment tree. +const OP_SHA1 = 0x02 +const OP_RIPEMD160 = 0x03 +const OP_SHA256 = 0x08 +const OP_KECCAK256 = 0x67 +const OP_APPEND = 0xf0 +const OP_PREPEND = 0xf1 +const OP_REVERSE = 0xf2 +const OP_HEXLIFY = 0xf3 + +// 1-byte structural markers in a Timestamp node. +const TAG_BRANCH = 0xff +const TAG_ATTESTATION = 0x00 + +// Safety limits. OTS proofs for Bitcoin are tiny in practice — a few hundred +// bytes, rarely more than ~1 KiB. The ceilings below are generous enough to +// accommodate unusual proofs while protecting the relay from memory-exhausting +// or stack-overflowing inputs. +const MAX_OTS_BYTES = 16 * 1024 +const MAX_VARBYTES_LENGTH = 8 * 1024 +const MAX_VARUINT_VALUE = Number.MAX_SAFE_INTEGER +const MAX_RECURSION_DEPTH = 128 + +// Only v1 of the OpenTimestamps file format is defined today. A future +// bump would change the byte layout we parse below, so treat anything else +// as unknown rather than best-effort decoding it. +const SUPPORTED_OTS_VERSION = 1 + +/** + * Enum-like classification of attestations we care about. Unknown tags are + * reported as `unknown` rather than hard-failing: the OTS format is explicitly + * extensible, and future chains (or upgrade-aware implementations) may emit + * tags we do not recognise. What matters for NIP-03 is that at least one + * Bitcoin attestation is present. + */ +export type OtsAttestationKind = 'bitcoin' | 'pending' | 'litecoin' | 'ethereum' | 'unknown' + +export interface OtsAttestation { + kind: OtsAttestationKind + /** + * The 8-byte attestation type tag in lowercase hex. Useful for diagnostics + * and for distinguishing unknown attestation types from each other. + */ + tag: string + /** + * Block height for Bitcoin / Litecoin / Ethereum attestations. Undefined + * when the attestation doesn't carry one (or failed to parse cleanly for + * unknown tags — we don't trust the height of attestations we don't know + * how to interpret). + */ + height?: number +} + +export interface OtsFileSummary { + /** Major version number from the file header. Currently always 1. */ + version: number + /** The hash operation used to digest the file being timestamped. */ + fileHashOp: 'sha1' | 'ripemd160' | 'sha256' | 'keccak256' + /** Hex-encoded digest of the file being timestamped. */ + digest: string + attestations: OtsAttestation[] +} + +export type OtsParseResult = { ok: true; summary: OtsFileSummary } | { ok: false; reason: string } + +/** + * Minimal cursor over a Buffer used by the OTS parser. All reads are + * bounds-checked; exceeding the buffer throws a descriptive error. + * + * Exported for unit tests only — application code should use `parseOtsFile`. + */ +export class OtsReader { + private offset = 0 + + public constructor(private readonly buf: Buffer) {} + + public get remaining(): number { + return this.buf.length - this.offset + } + + public readBytes(n: number): Buffer { + if (n < 0) { + throw new Error(`invalid negative read length ${n}`) + } + if (this.offset + n > this.buf.length) { + throw new Error(`unexpected end of input (need ${n} bytes, have ${this.remaining})`) + } + const slice = this.buf.subarray(this.offset, this.offset + n) + this.offset += n + return slice + } + + public readByte(): number { + return this.readBytes(1)[0] + } + + /** + * Read a variable-length unsigned integer encoded as LEB128 (little-endian + * base-128, MSB continuation bit). This matches the OpenTimestamps + * reference implementation. + */ + public readVarUint(): number { + let value = 0 + let shift = 0 + for (;;) { + if (shift > 56) { + throw new Error('varint overflow') + } + const b = this.readByte() + value += (b & 0x7f) * 2 ** shift + if (!(b & 0x80)) { + break + } + shift += 7 + } + if (value > MAX_VARUINT_VALUE) { + throw new Error('varint exceeds safe integer range') + } + return value + } + + public readVarBytes(): Buffer { + const len = this.readVarUint() + if (len > MAX_VARBYTES_LENGTH) { + throw new Error(`varbytes length ${len} exceeds maximum ${MAX_VARBYTES_LENGTH}`) + } + return this.readBytes(len) + } +} + +function classifyAttestationTag(tag: Buffer): OtsAttestationKind { + if (tag.equals(BITCOIN_BLOCK_HEADER_ATTESTATION_TAG)) { + return 'bitcoin' + } + if (tag.equals(PENDING_ATTESTATION_TAG)) { + return 'pending' + } + if (tag.equals(LITECOIN_BLOCK_HEADER_ATTESTATION_TAG)) { + return 'litecoin' + } + if (tag.equals(ETHEREUM_BLOCK_HEADER_ATTESTATION_TAG)) { + return 'ethereum' + } + return 'unknown' +} + +function readAttestation(reader: OtsReader): OtsAttestation { + const tagBytes = reader.readBytes(8) + const kind = classifyAttestationTag(tagBytes) + // OTS wraps each attestation's payload in a length-prefixed blob so that + // unknown attestation types can be skipped cleanly. + const payload = reader.readVarBytes() + + const attestation: OtsAttestation = { + kind, + tag: tagBytes.toString('hex'), + } + + if (kind === 'bitcoin' || kind === 'litecoin' || kind === 'ethereum') { + // All three block-header attestations carry a single varint block height. + // Read from the payload, not the outer reader, so we cannot run past the + // payload boundary even if the attestation is malformed. + const payloadReader = new OtsReader(payload) + try { + attestation.height = payloadReader.readVarUint() + } catch { + // leave height undefined + } + } + + return attestation +} + +function isBinaryOp(op: number): boolean { + return op === OP_APPEND || op === OP_PREPEND +} + +function isUnaryOp(op: number): boolean { + return ( + op === OP_SHA1 || + op === OP_RIPEMD160 || + op === OP_SHA256 || + op === OP_KECCAK256 || + op === OP_REVERSE || + op === OP_HEXLIFY + ) +} + +function readOpArgIfAny(reader: OtsReader, op: number): void { + if (isBinaryOp(op)) { + // Binary ops carry a single length-prefixed byte string argument. We + // don't need the value — we only need to skip past it — but we still + // enforce the length limit via readVarBytes(). + reader.readVarBytes() + return + } + if (isUnaryOp(op)) { + return + } + throw new Error(`unknown op tag 0x${op.toString(16).padStart(2, '0')}`) +} + +/** + * Recursively parse a Timestamp node, collecting attestations as they are + * encountered. The structure, per the reference implementation, is: + * + * Timestamp := (0xff SubItem)* SubItem + * SubItem := 0x00 Attestation -- a leaf attestation + * | [opArg] Timestamp -- an inner commitment + * + * `depth` is bounded to avoid blowing the JS call stack on pathological input. + */ +function readTimestamp(reader: OtsReader, attestations: OtsAttestation[], depth: number): void { + if (depth > MAX_RECURSION_DEPTH) { + throw new Error(`timestamp tree too deep (>${MAX_RECURSION_DEPTH})`) + } + + for (;;) { + const tag = reader.readByte() + + if (tag === TAG_BRANCH) { + const inner = reader.readByte() + readSubItem(reader, inner, attestations, depth + 1) + continue + } + + readSubItem(reader, tag, attestations, depth + 1) + return + } +} + +function readSubItem(reader: OtsReader, tag: number, attestations: OtsAttestation[], depth: number): void { + if (tag === TAG_ATTESTATION) { + attestations.push(readAttestation(reader)) + return + } + readOpArgIfAny(reader, tag) + readTimestamp(reader, attestations, depth) +} + +function parseFileHashOp(op: number): { algo: OtsFileSummary['fileHashOp']; length: number } { + switch (op) { + case OP_SHA1: + return { algo: 'sha1', length: 20 } + case OP_RIPEMD160: + return { algo: 'ripemd160', length: 20 } + case OP_SHA256: + return { algo: 'sha256', length: 32 } + case OP_KECCAK256: + return { algo: 'keccak256', length: 32 } + default: + throw new Error(`unsupported file hash op 0x${op.toString(16).padStart(2, '0')}`) + } +} + +/** + * Parse a binary `.ots` file. Never throws: returns a discriminated union so + * callers can surface a structured rejection reason to the client. + */ +export function parseOtsFile(buf: Buffer): OtsParseResult { + if (!Buffer.isBuffer(buf) || buf.length === 0) { + return { ok: false, reason: 'empty ots file' } + } + if (buf.length > MAX_OTS_BYTES) { + return { ok: false, reason: `ots file exceeds ${MAX_OTS_BYTES} bytes` } + } + + const reader = new OtsReader(buf) + + try { + const magic = reader.readBytes(MAGIC_HEADER.length) + if (!magic.equals(MAGIC_HEADER)) { + return { ok: false, reason: 'invalid ots magic header' } + } + + const version = reader.readVarUint() + if (version !== SUPPORTED_OTS_VERSION) { + return { ok: false, reason: `unsupported ots version ${version}` } + } + + const fileHashOp = reader.readByte() + const { algo, length } = parseFileHashOp(fileHashOp) + + const digest = reader.readBytes(length) + + const attestations: OtsAttestation[] = [] + readTimestamp(reader, attestations, 0) + + if (reader.remaining !== 0) { + return { ok: false, reason: `trailing bytes after ots proof (${reader.remaining} left)` } + } + + return { + ok: true, + summary: { + version, + fileHashOp: algo, + digest: digest.toString('hex'), + attestations, + }, + } + } catch (error) { + const reason = error instanceof Error ? error.message : 'malformed ots file' + return { ok: false, reason } + } +} + +/** + * Validate the base64-encoded `.ots` proof embedded in a NIP-03 event's + * content. Returns an error string on failure, or `undefined` on success. + * + * This enforces the three relay-observable requirements of NIP-03: + * - structurally valid OpenTimestamps proof file, + * - the proof's 32-byte SHA-256 file digest equals the hex-decoded target + * event id from the `e` tag, and + * - the proof contains at least one Bitcoin block header attestation (not + * merely pending calendars). + */ +export function validateOtsProof(base64Content: string, targetEventId: string): string | undefined { + if (typeof base64Content !== 'string' || base64Content.length === 0) { + return 'content is empty; expected base64-encoded ots file' + } + + // Guard the base64 decoder. Node's permissive decoding silently drops bad + // characters; we'd rather fail loudly and give the client a clear reason. + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(base64Content) || base64Content.length % 4 !== 0) { + return 'content is not valid base64' + } + + const buf = Buffer.from(base64Content, 'base64') + + const result = parseOtsFile(buf) + if (result.ok !== true) { + return `invalid ots proof: ${result.reason}` + } + + const summary = result.summary + + if (summary.fileHashOp !== 'sha256') { + return `ots proof must use sha256 file hash op (got ${summary.fileHashOp})` + } + + const normalizedTarget = typeof targetEventId === 'string' ? targetEventId.toLowerCase() : '' + if (!/^[0-9a-f]{64}$/.test(normalizedTarget)) { + return 'target event id is not a 32-byte hex string' + } + + if (summary.digest.toLowerCase() !== normalizedTarget) { + return 'ots proof digest does not match the referenced event id' + } + + const hasBitcoinAttestation = summary.attestations.some((att) => att.kind === 'bitcoin') + if (!hasBitcoinAttestation) { + return 'ots proof must contain at least one bitcoin attestation' + } + + return undefined +} diff --git a/test/integration/features/nip-03/nip-03.feature b/test/integration/features/nip-03/nip-03.feature new file mode 100644 index 00000000..d9b58406 --- /dev/null +++ b/test/integration/features/nip-03/nip-03.feature @@ -0,0 +1,12 @@ +Feature: NIP-03 OpenTimestamps + Scenario: Alice publishes a valid OpenTimestamps attestation for her text note + Given someone called Alice + When Alice sends a text_note event with content "anchor this note" + And Alice sends a valid OpenTimestamps attestation for her last text_note event + And Alice subscribes to OpenTimestamps events from Alice + Then Alice receives an OpenTimestamps attestation from Alice for her last text_note event + + Scenario: Alice cannot publish an attestation whose OTS digest does not match the e tag + Given someone called Alice + When Alice sends a text_note event with content "wrong digest" + And Alice sends an OpenTimestamps attestation with mismatching OTS digest for her last text_note event diff --git a/test/integration/features/nip-03/nip-03.feature.ts b/test/integration/features/nip-03/nip-03.feature.ts new file mode 100644 index 00000000..640aa97a --- /dev/null +++ b/test/integration/features/nip-03/nip-03.feature.ts @@ -0,0 +1,133 @@ +import { Then, When } from '@cucumber/cucumber' +import { expect } from 'chai' +import WebSocket from 'ws' + +import { createEvent, createSubscription, sendEvent, waitForNextEvent } from '../helpers' +import { Event } from '../../../../src/@types/event' +import { Tag } from '../../../../src/@types/base' +import { EventKinds, EventTags } from '../../../../src/constants/base' + +// Minimal OpenTimestamps v1 proof (SHA-256 file hash + Bitcoin block attestation), aligned with +// `test/unit/utils/nip03.spec.ts` — exercises the same parser path the relay accepts in production. + +const MAGIC = Buffer.from([ + 0x00, 0x4f, 0x70, 0x65, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x73, 0x00, 0x00, 0x50, 0x72, + 0x6f, 0x6f, 0x66, 0x00, 0xbf, 0x89, 0xe2, 0xe8, 0x84, 0xe8, 0x92, 0x94, +]) + +const BITCOIN_TAG = Buffer.from([0x05, 0x88, 0x96, 0x0d, 0x73, 0xd7, 0x19, 0x01]) +const OP_SHA256 = 0x08 +const TAG_ATTESTATION = 0x00 + +function writeVarUint(n: number): Buffer { + if (n === 0) { + return Buffer.from([0]) + } + const out: number[] = [] + let v = n + while (v !== 0) { + let b = v & 0x7f + v = Math.floor(v / 128) + if (v !== 0) { + b |= 0x80 + } + out.push(b) + } + return Buffer.from(out) +} + +function writeVarBytes(buf: Buffer): Buffer { + return Buffer.concat([writeVarUint(buf.length), buf]) +} + +function bitcoinAttestation(height: number): Buffer { + const payload = writeVarUint(height) + return Buffer.concat([Buffer.from([TAG_ATTESTATION]), BITCOIN_TAG, writeVarBytes(payload)]) +} + +/** Base64-encoded .ots whose SHA-256 file digest equals `digestHex` (the attested event id). */ +function buildMinimalOtsBase64(digestHex: string, blockHeight = 810391): string { + const digest = Buffer.from(digestHex, 'hex') + return Buffer.concat([ + MAGIC, + writeVarUint(1), + Buffer.from([OP_SHA256]), + digest, + bitcoinAttestation(blockHeight), + ]).toString('base64') +} + +function lastTextNoteFor(events: Event[] | undefined): Event | undefined { + if (!events?.length) { + return undefined + } + for (let i = events.length - 1; i >= 0; i--) { + if (events[i].kind === 1) { + return events[i] + } + } + return undefined +} + +When(/^(\w+) sends a valid OpenTimestamps attestation for her last text_note event$/, async function (name: string) { + const ws = this.parameters.clients[name] as WebSocket + const { pubkey, privkey } = this.parameters.identities[name] + const note = lastTextNoteFor(this.parameters.events[name] as Event[]) + expect(note, 'last text_note').to.exist + + const content = buildMinimalOtsBase64(note!.id) + const tags: Tag[] = [ + [EventTags.Event, note!.id, 'wss://localhost:18808'], + [EventTags.Kind, String(1)], + ] + const event: Event = await createEvent({ pubkey, kind: EventKinds.OPEN_TIMESTAMPS, content, tags }, privkey) + await sendEvent(ws, event) + this.parameters.events[name].push(event) +}) + +When( + /^(\w+) sends an OpenTimestamps attestation with mismatching OTS digest for her last text_note event$/, + async function (name: string) { + const ws = this.parameters.clients[name] as WebSocket + const { pubkey, privkey } = this.parameters.identities[name] + const note = lastTextNoteFor(this.parameters.events[name] as Event[]) + expect(note, 'last text_note').to.exist + + const content = buildMinimalOtsBase64('0'.repeat(64)) + const tags: Tag[] = [ + [EventTags.Event, note!.id, 'wss://localhost:18808'], + [EventTags.Kind, String(1)], + ] + const event: Event = await createEvent({ pubkey, kind: EventKinds.OPEN_TIMESTAMPS, content, tags }, privkey) + await sendEvent(ws, event, false) + }, +) + +When(/^(\w+) subscribes to OpenTimestamps events from (\w+)$/, async function (name: string, author: string) { + const ws = this.parameters.clients[name] as WebSocket + const pubkey = this.parameters.identities[author].pubkey + const subscription = { + name: `test-${Math.random()}`, + filters: [{ kinds: [EventKinds.OPEN_TIMESTAMPS], authors: [pubkey] }], + } + this.parameters.subscriptions[name].push(subscription) + await createSubscription(ws, subscription.name, subscription.filters) +}) + +Then( + /^(\w+) receives an OpenTimestamps attestation from (\w+) for her last text_note event$/, + async function (recipient: string, author: string) { + const ws = this.parameters.clients[recipient] as WebSocket + const subscription = this.parameters.subscriptions[recipient][this.parameters.subscriptions[recipient].length - 1] + const received = (await waitForNextEvent(ws, subscription.name)) as Event + const note = lastTextNoteFor(this.parameters.events[author] as Event[]) + + expect(received.kind).to.equal(EventKinds.OPEN_TIMESTAMPS) + expect(received.pubkey).to.equal(this.parameters.identities[author].pubkey) + const eTags = received.tags.filter((t) => t[0] === EventTags.Event && t.length >= 2) + expect(eTags.length).to.equal(1) + expect(eTags[0][1]).to.equal(note?.id) + const kTag = received.tags.find((t) => t[0] === EventTags.Kind) + expect(kTag?.[1]).to.equal('1') + }, +) diff --git a/test/unit/factories/event-strategy-factory.spec.ts b/test/unit/factories/event-strategy-factory.spec.ts index a180cb7f..444c5e7b 100644 --- a/test/unit/factories/event-strategy-factory.spec.ts +++ b/test/unit/factories/event-strategy-factory.spec.ts @@ -13,6 +13,7 @@ import { IEventStrategy } from '../../../src/@types/message-handlers' import { IWebSocketAdapter } from '../../../src/@types/adapters' import { ParameterizedReplaceableEventStrategy } from '../../../src/handlers/event-strategies/parameterized-replaceable-event-strategy' import { ReplaceableEventStrategy } from '../../../src/handlers/event-strategies/replaceable-event-strategy' +import { TimestampEventStrategy } from '../../../src/handlers/event-strategies/timestamp-event-strategy' import { VanishEventStrategy } from '../../../src/handlers/event-strategies/vanish-event-strategy' describe('eventStrategyFactory', () => { @@ -66,6 +67,11 @@ describe('eventStrategyFactory', () => { expect(factory([event, adapter])).to.be.an.instanceOf(GiftWrapEventStrategy) }) + it('returns TimestampEventStrategy given an opentimestamps (NIP-03) event', () => { + event.kind = EventKinds.OPEN_TIMESTAMPS + expect(factory([event, adapter])).to.be.an.instanceOf(TimestampEventStrategy) + }) + it('returns ParameterizedReplaceableEventStrategy given a delete event', () => { event.kind = EventKinds.PARAMETERIZED_REPLACEABLE_FIRST expect(factory([event, adapter])).to.be.an.instanceOf(ParameterizedReplaceableEventStrategy) diff --git a/test/unit/handlers/event-strategies/timestamp-event-strategy.spec.ts b/test/unit/handlers/event-strategies/timestamp-event-strategy.spec.ts new file mode 100644 index 00000000..bb732298 --- /dev/null +++ b/test/unit/handlers/event-strategies/timestamp-event-strategy.spec.ts @@ -0,0 +1,299 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' + +chai.use(sinonChai) +chai.use(chaiAsPromised) + +import { IWebSocketAdapter } from '../../../../src/@types/adapters' +import { DatabaseClient } from '../../../../src/@types/base' +import { Event } from '../../../../src/@types/event' +import { IEventStrategy } from '../../../../src/@types/message-handlers' +import { MessageType } from '../../../../src/@types/messages' +import { IEventRepository } from '../../../../src/@types/repositories' +import { WebSocketAdapterEvent } from '../../../../src/constants/adapter' +import { EventKinds } from '../../../../src/constants/base' +import { TimestampEventStrategy } from '../../../../src/handlers/event-strategies/timestamp-event-strategy' +import { EventRepository } from '../../../../src/repositories/event-repository' + +const { expect } = chai + +// --------------------------------------------------------------------------- +// Minimal `.ots` builder so we don't need the `ots` CLI to make the strategy +// happy. See test/unit/utils/nip03.spec.ts for full parser coverage. +// --------------------------------------------------------------------------- + +const MAGIC = Buffer.from([ + 0x00, 0x4f, 0x70, 0x65, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x73, 0x00, 0x00, 0x50, 0x72, + 0x6f, 0x6f, 0x66, 0x00, 0xbf, 0x89, 0xe2, 0xe8, 0x84, 0xe8, 0x92, 0x94, +]) +const BITCOIN_TAG = Buffer.from([0x05, 0x88, 0x96, 0x0d, 0x73, 0xd7, 0x19, 0x01]) + +function writeVarUint(n: number): Buffer { + if (n === 0) { + return Buffer.from([0]) + } + const out: number[] = [] + let v = n + while (v !== 0) { + let b = v & 0x7f + v = Math.floor(v / 128) + if (v !== 0) { + b |= 0x80 + } + out.push(b) + } + return Buffer.from(out) +} + +function bitcoinAttestation(height: number): Buffer { + const payload = writeVarUint(height) + const lenPrefixed = Buffer.concat([writeVarUint(payload.length), payload]) + return Buffer.concat([Buffer.from([0x00]), BITCOIN_TAG, lenPrefixed]) +} + +function buildValidOtsForDigest(digestHex: string, blockHeight = 810391): string { + const digest = Buffer.from(digestHex, 'hex') + const bytes = Buffer.concat([MAGIC, writeVarUint(1), Buffer.from([0x08]), digest, bitcoinAttestation(blockHeight)]) + return bytes.toString('base64') +} + +describe('TimestampEventStrategy', () => { + const targetEventId = 'e71c6ea722987debdb60f81f9ea4f604b5ac0664120dd64fb9d23abc4ec7c323' + + let event: Event + let webSocket: IWebSocketAdapter + let eventRepository: IEventRepository + let webSocketEmitStub: Sinon.SinonStub + let eventRepositoryCreateStub: Sinon.SinonStub + let strategy: IEventStrategy> + let sandbox: Sinon.SinonSandbox + + beforeEach(() => { + sandbox = Sinon.createSandbox() + + eventRepositoryCreateStub = sandbox.stub(EventRepository.prototype, 'create') + + webSocketEmitStub = sandbox.stub() + webSocket = { emit: webSocketEmitStub } as any + + const masterClient: DatabaseClient = {} as any + const readReplicaClient: DatabaseClient = {} as any + eventRepository = new EventRepository(masterClient, readReplicaClient) + + event = { + id: 'timestamp-event-id', + pubkey: 'a'.repeat(64), + created_at: 1700000000, + kind: EventKinds.OPEN_TIMESTAMPS, + tags: [ + ['e', targetEventId, 'wss://relay.example.com'], + ['k', '1'], + ], + content: buildValidOtsForDigest(targetEventId), + sig: 'c'.repeat(128), + } as any + + strategy = new TimestampEventStrategy(webSocket, eventRepository) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('valid opentimestamps event', () => { + it('stores and broadcasts the event', async () => { + eventRepositoryCreateStub.resolves(1) + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).to.have.been.calledOnceWithExactly(event) + expect(webSocketEmitStub).to.have.been.calledTwice + expect(webSocketEmitStub).to.have.been.calledWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + true, + '', + ]) + expect(webSocketEmitStub).to.have.been.calledWithExactly(WebSocketAdapterEvent.Broadcast, event) + }) + + it('emits a duplicate OK without broadcasting when the event already exists', async () => { + eventRepositoryCreateStub.resolves(0) + + await strategy.execute(event) + + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + true, + 'duplicate:', + ]) + }) + + it('accepts an event without a k tag', async () => { + event.tags = [['e', targetEventId]] + eventRepositoryCreateStub.resolves(1) + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).to.have.been.calledOnce + }) + }) + + describe('invalid opentimestamps event', () => { + it('rejects when the e tag is missing', async () => { + event.tags = [] + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).not.to.have.been.called + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + false, + Sinon.match(/invalid:.*e tag/), + ]) + }) + + it('rejects when multiple e tags are present', async () => { + event.tags = [ + ['e', targetEventId], + ['e', 'b'.repeat(64)], + ] + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).not.to.have.been.called + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + false, + Sinon.match(/invalid:.*exactly one event/), + ]) + }) + + it('rejects when the e tag value is not a 32-byte hex id', async () => { + event.tags = [['e', 'not-a-hex-id']] + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).not.to.have.been.called + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + false, + Sinon.match(/invalid:.*hex event id/), + ]) + }) + + it('rejects upper-case hex in the e tag (NIP-01 requires lowercase)', async () => { + event.tags = [['e', targetEventId.toUpperCase()]] + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).not.to.have.been.called + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + false, + Sinon.match(/invalid:.*hex event id/), + ]) + }) + + it('rejects a non-integer k tag', async () => { + event.tags = [ + ['e', targetEventId], + ['k', 'banana'], + ] + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).not.to.have.been.called + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + false, + Sinon.match(/invalid:.*k tag/), + ]) + }) + + it('rejects a negative k tag', async () => { + event.tags = [ + ['e', targetEventId], + ['k', '-1'], + ] + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).not.to.have.been.called + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + false, + Sinon.match(/invalid:.*k tag/), + ]) + }) + + it('rejects when multiple k tags are present', async () => { + event.tags = [ + ['e', targetEventId], + ['k', '1'], + ['k', '2'], + ] + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).not.to.have.been.called + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + false, + Sinon.match(/invalid:.*at most one k tag/), + ]) + }) + + it('rejects when the OTS digest does not match the e-tagged event id', async () => { + event.content = buildValidOtsForDigest('d'.repeat(64)) + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).not.to.have.been.called + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + false, + Sinon.match(/invalid:.*digest does not match/), + ]) + }) + + it('rejects when the content is not a valid OTS proof', async () => { + event.content = Buffer.from('not an ots proof').toString('base64') + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).not.to.have.been.called + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + false, + Sinon.match(/invalid:.*ots proof/), + ]) + }) + + it('rejects when the content is empty', async () => { + event.content = '' + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).not.to.have.been.called + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + false, + Sinon.match(/invalid:/), + ]) + }) + }) +}) diff --git a/test/unit/utils/nip03.spec.ts b/test/unit/utils/nip03.spec.ts new file mode 100644 index 00000000..2d0690df --- /dev/null +++ b/test/unit/utils/nip03.spec.ts @@ -0,0 +1,390 @@ +import { expect } from 'chai' + +import { OtsParseResult, OtsReader, parseOtsFile, validateOtsProof } from '../../../src/utils/nip03' + +/** LEB128-encode a non-negative integer that may exceed `Number.MAX_SAFE_INTEGER`. */ +function leb128FromBigInt(n: bigint): Buffer { + const bytes: number[] = [] + let v = n + while (true) { + const b = Number(v & 0x7fn) + v >>= 7n + if (v !== 0n) { + bytes.push(b | 0x80) + } else { + bytes.push(b) + break + } + } + return Buffer.from(bytes) +} + +function expectFailure(result: OtsParseResult): { ok: false; reason: string } { + if (result.ok !== false) { + throw new Error('expected a failure result') + } + return result as { ok: false; reason: string } +} + +function expectSuccess(result: OtsParseResult): Extract { + if (result.ok !== true) { + throw new Error(`expected a success result, got ${(result as any).reason}`) + } + return result as Extract +} + +// --------------------------------------------------------------------------- +// Binary OTS builder helpers +// +// Encode synthetic `.ots` files so we can exercise the parser without shelling +// out to the `ots` CLI in unit tests. The byte layout mirrors what +// python-opentimestamps produces; see src/utils/nip03.ts for the format. +// --------------------------------------------------------------------------- + +const MAGIC = Buffer.from([ + 0x00, 0x4f, 0x70, 0x65, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x73, 0x00, 0x00, 0x50, 0x72, + 0x6f, 0x6f, 0x66, 0x00, 0xbf, 0x89, 0xe2, 0xe8, 0x84, 0xe8, 0x92, 0x94, +]) + +const BITCOIN_TAG = Buffer.from([0x05, 0x88, 0x96, 0x0d, 0x73, 0xd7, 0x19, 0x01]) +const PENDING_TAG = Buffer.from([0x83, 0xdf, 0xe3, 0x0d, 0x2e, 0xf9, 0x0c, 0x8e]) +const LITECOIN_TAG = Buffer.from([0x06, 0x86, 0x9a, 0x0d, 0x73, 0xd7, 0x1b, 0x45]) +const UNKNOWN_TAG = Buffer.from([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11]) + +const OP_SHA1 = 0x02 +const OP_RIPEMD160 = 0x03 +const OP_SHA256 = 0x08 +const OP_KECCAK256 = 0x67 +const OP_APPEND = 0xf0 +const OP_PREPEND = 0xf1 +const OP_REVERSE = 0xf2 +const OP_HEXLIFY = 0xf3 + +const TAG_BRANCH = 0xff +const TAG_ATTESTATION = 0x00 + +const ETHEREUM_TAG = Buffer.from([0x30, 0xfe, 0x80, 0x87, 0xb5, 0xc7, 0xea, 0xd7]) + +function writeVarUint(n: number): Buffer { + const bytes: number[] = [] + let value = n + if (value === 0) { + return Buffer.from([0]) + } + while (value !== 0) { + let b = value & 0x7f + value = Math.floor(value / 128) + if (value !== 0) { + b |= 0x80 + } + bytes.push(b) + } + return Buffer.from(bytes) +} + +function writeVarBytes(buf: Buffer): Buffer { + return Buffer.concat([writeVarUint(buf.length), buf]) +} + +function bitcoinAttestation(height: number): Buffer { + // [0x00][8-byte tag][varint len][varint height payload] + const payload = writeVarUint(height) + return Buffer.concat([Buffer.from([TAG_ATTESTATION]), BITCOIN_TAG, writeVarBytes(payload)]) +} + +function pendingAttestation(url: string): Buffer { + const payload = writeVarBytes(Buffer.from(url, 'utf8')) + return Buffer.concat([Buffer.from([TAG_ATTESTATION]), PENDING_TAG, writeVarBytes(payload)]) +} + +function litecoinAttestation(height: number): Buffer { + const payload = writeVarUint(height) + return Buffer.concat([Buffer.from([TAG_ATTESTATION]), LITECOIN_TAG, writeVarBytes(payload)]) +} + +function unknownAttestation(payload: Buffer): Buffer { + return Buffer.concat([Buffer.from([TAG_ATTESTATION]), UNKNOWN_TAG, writeVarBytes(payload)]) +} + +function ethereumAttestation(height: number): Buffer { + const payload = writeVarUint(height) + return Buffer.concat([Buffer.from([TAG_ATTESTATION]), ETHEREUM_TAG, writeVarBytes(payload)]) +} + +/** + * Build a minimal `.ots` file with a given file-hash op and digest length. + * `subItems` are the attestation/op byte sequences; all but the last are + * prefixed with the 0xff branch marker (same layout as `buildOts`). + */ +function buildOtsWithFileHashOp(fileHashOp: number, digest: Buffer, subItems: Buffer[]): Buffer { + if (subItems.length === 0) { + throw new Error('need at least one sub-item') + } + const parts: Buffer[] = [MAGIC, writeVarUint(1), Buffer.from([fileHashOp]), digest] + for (let i = 0; i < subItems.length - 1; i++) { + parts.push(Buffer.from([TAG_BRANCH])) + parts.push(subItems[i]) + } + parts.push(subItems[subItems.length - 1]) + return Buffer.concat(parts) +} + +/** + * Build a minimal SHA256-digest `.ots` file whose commitment tree is a single + * leaf attestation. `subItems` are the attestation/op byte sequences that + * make up the tree; all but the last are prefixed with the 0xff branch marker. + */ +function buildOts(digest: Buffer, subItems: Buffer[]): Buffer { + if (subItems.length === 0) { + throw new Error('need at least one sub-item') + } + const parts: Buffer[] = [MAGIC, writeVarUint(1), Buffer.from([OP_SHA256]), digest] + for (let i = 0; i < subItems.length - 1; i++) { + parts.push(Buffer.from([TAG_BRANCH])) + parts.push(subItems[i]) + } + parts.push(subItems[subItems.length - 1]) + return Buffer.concat(parts) +} + +const EVENT_ID = 'e71c6ea722987debdb60f81f9ea4f604b5ac0664120dd64fb9d23abc4ec7c323' +const DIGEST = Buffer.from(EVENT_ID, 'hex') + +describe('OtsReader', () => { + it('readBytes rejects a negative length', () => { + const r = new OtsReader(Buffer.from([1, 2, 3])) + expect(() => r.readBytes(-1)).to.throw(/invalid negative read length/) + }) + + it('readVarUint rejects values above Number.MAX_SAFE_INTEGER', () => { + const encoded = leb128FromBigInt(BigInt(Number.MAX_SAFE_INTEGER) + 1n) + const r = new OtsReader(encoded) + expect(() => r.readVarUint()).to.throw(/exceeds safe integer range/) + }) +}) + +describe('NIP-03 — OpenTimestamps', () => { + describe('parseOtsFile', () => { + it('parses a minimal proof with a single bitcoin attestation', () => { + const buf = buildOts(DIGEST, [bitcoinAttestation(810391)]) + + const result = expectSuccess(parseOtsFile(buf)) + + expect(result.summary.version).to.equal(1) + expect(result.summary.fileHashOp).to.equal('sha256') + expect(result.summary.digest).to.equal(EVENT_ID) + expect(result.summary.attestations).to.have.lengthOf(1) + expect(result.summary.attestations[0]).to.include({ kind: 'bitcoin', height: 810391 }) + }) + + it('parses a proof with ops that wrap an attestation', () => { + const opAppend = Buffer.concat([Buffer.from([OP_APPEND]), writeVarBytes(Buffer.from([0xde, 0xad, 0xbe, 0xef]))]) + const tree = Buffer.concat([opAppend, bitcoinAttestation(1)]) + const buf = buildOts(DIGEST, [tree]) + + const result = expectSuccess(parseOtsFile(buf)) + + expect(result.summary.attestations.map((a) => a.kind)).to.deep.equal(['bitcoin']) + }) + + it('parses a proof with multiple attestations (pending + bitcoin)', () => { + const buf = buildOts(DIGEST, [pendingAttestation('https://a.pool.opentimestamps.org'), bitcoinAttestation(42)]) + + const result = expectSuccess(parseOtsFile(buf)) + const kinds = result.summary.attestations.map((a) => a.kind).sort() + expect(kinds).to.deep.equal(['bitcoin', 'pending']) + }) + + it('classifies litecoin and unknown attestations correctly', () => { + const buf = buildOts(DIGEST, [litecoinAttestation(2500000), unknownAttestation(Buffer.from([1, 2, 3]))]) + + const result = expectSuccess(parseOtsFile(buf)) + const kinds = result.summary.attestations.map((a) => a.kind).sort() + expect(kinds).to.deep.equal(['litecoin', 'unknown']) + }) + + it('rejects a file without the OpenTimestamps magic header', () => { + const buf = Buffer.concat([Buffer.alloc(MAGIC.length), writeVarUint(1), Buffer.from([OP_SHA256]), DIGEST]) + const result = expectFailure(parseOtsFile(buf)) + expect(result.reason).to.match(/magic header/) + }) + + it('rejects an unsupported file hash op', () => { + const parts = [MAGIC, writeVarUint(1), Buffer.from([0x55]), Buffer.alloc(32), bitcoinAttestation(1)] + const buf = Buffer.concat(parts) + const result = expectFailure(parseOtsFile(buf)) + expect(result.reason).to.match(/unsupported file hash op/) + }) + + it('rejects an unsupported ots file version', () => { + const parts = [MAGIC, writeVarUint(2), Buffer.from([OP_SHA256]), DIGEST, bitcoinAttestation(1)] + const buf = Buffer.concat(parts) + const result = expectFailure(parseOtsFile(buf)) + expect(result.reason).to.match(/unsupported ots version/) + }) + + it('rejects truncated proofs without crashing', () => { + const good = buildOts(DIGEST, [bitcoinAttestation(1)]) + const truncated = good.subarray(0, good.length - 3) + expectFailure(parseOtsFile(truncated)) + }) + + it('rejects proofs with trailing garbage', () => { + const good = buildOts(DIGEST, [bitcoinAttestation(1)]) + const withGarbage = Buffer.concat([good, Buffer.from([0x00, 0x11, 0x22])]) + const result = expectFailure(parseOtsFile(withGarbage)) + expect(result.reason).to.match(/trailing bytes/) + }) + + it('refuses files larger than the configured maximum', () => { + const result = expectFailure(parseOtsFile(Buffer.alloc(32 * 1024))) + expect(result.reason).to.match(/exceeds/) + }) + + it('returns a structured failure on empty input instead of throwing', () => { + expectFailure(parseOtsFile(Buffer.alloc(0))) + }) + + it('rejects non-Buffer input the same as empty (typed array is not a Buffer)', () => { + const result = expectFailure(parseOtsFile(new Uint8Array([0x01, 0x02]) as unknown as Buffer)) + expect(result.reason).to.equal('empty ots file') + }) + + it('parses sha1 file digest (20-byte) proofs', () => { + const digest = Buffer.alloc(20, 0xab) + const buf = buildOtsWithFileHashOp(OP_SHA1, digest, [bitcoinAttestation(1)]) + const result = expectSuccess(parseOtsFile(buf)) + expect(result.summary.fileHashOp).to.equal('sha1') + expect(result.summary.digest).to.equal(digest.toString('hex')) + }) + + it('parses ripemd160 file digest proofs', () => { + const digest = Buffer.alloc(20, 0xcd) + const buf = buildOtsWithFileHashOp(OP_RIPEMD160, digest, [bitcoinAttestation(2)]) + const result = expectSuccess(parseOtsFile(buf)) + expect(result.summary.fileHashOp).to.equal('ripemd160') + }) + + it('parses keccak256 file digest proofs', () => { + const digest = Buffer.alloc(32, 0xef) + const buf = buildOtsWithFileHashOp(OP_KECCAK256, digest, [bitcoinAttestation(3)]) + const result = expectSuccess(parseOtsFile(buf)) + expect(result.summary.fileHashOp).to.equal('keccak256') + }) + + it('parses prepend binary op in the commitment tree', () => { + const prepend = Buffer.concat([Buffer.from([OP_PREPEND]), writeVarBytes(Buffer.from([0x01]))]) + const buf = buildOts(DIGEST, [Buffer.concat([prepend, bitcoinAttestation(4)])]) + const result = expectSuccess(parseOtsFile(buf)) + expect(result.summary.attestations.some((a) => a.kind === 'bitcoin')).to.equal(true) + }) + + it('parses reverse and hexlify unary ops wrapping an attestation', () => { + const revThenHex = Buffer.concat([Buffer.from([OP_REVERSE]), Buffer.from([OP_HEXLIFY]), bitcoinAttestation(5)]) + const buf = buildOts(DIGEST, [revThenHex]) + const result = expectSuccess(parseOtsFile(buf)) + expect(result.summary.attestations[0].kind).to.equal('bitcoin') + }) + + it('classifies ethereum block header attestations', () => { + const buf = buildOts(DIGEST, [ethereumAttestation(18_000_000)]) + const result = expectSuccess(parseOtsFile(buf)) + const eth = result.summary.attestations.find((a) => a.kind === 'ethereum') + expect(eth).to.exist + expect(eth?.height).to.equal(18_000_000) + }) + + it('treats a truncated bitcoin attestation payload as height-less', () => { + const broken = Buffer.concat([Buffer.from([TAG_ATTESTATION]), BITCOIN_TAG, writeVarUint(0)]) + const buf = buildOts(DIGEST, [broken]) + const result = expectSuccess(parseOtsFile(buf)) + expect(result.summary.attestations[0]).to.include({ kind: 'bitcoin' }) + expect(result.summary.attestations[0].height).to.equal(undefined) + }) + + it('rejects unknown commitment op tags', () => { + const buf = Buffer.concat([MAGIC, writeVarUint(1), Buffer.from([OP_SHA256]), DIGEST, Buffer.from([0xfe])]) + const result = expectFailure(parseOtsFile(buf)) + expect(result.reason).to.match(/unknown op tag/) + }) + + it('rejects varints that overflow the LEB128 decoder', () => { + const buf = Buffer.concat([MAGIC, Buffer.alloc(9, 0x80)]) + const result = expectFailure(parseOtsFile(buf)) + expect(result.reason).to.match(/varint overflow/) + }) + + it('rejects varbytes length fields above the relay maximum', () => { + const buf = Buffer.concat([ + MAGIC, + writeVarUint(1), + Buffer.from([OP_SHA256]), + DIGEST, + Buffer.from([OP_APPEND]), + writeVarUint(8193), + ]) + const result = expectFailure(parseOtsFile(buf)) + expect(result.reason).to.match(/exceeds maximum/) + }) + + it('rejects commitment trees deeper than the recursion cap', () => { + // 129 nested (0xff, OP_SHA256) pairs: readTimestamp(128) then dispatches into + // readTimestamp(129), which exceeds MAX_RECURSION_DEPTH (128). + const pairs = 129 + const nested = Buffer.alloc(pairs * 2) + for (let i = 0; i < pairs; i++) { + nested[i * 2] = TAG_BRANCH + nested[i * 2 + 1] = OP_SHA256 + } + const buf = Buffer.concat([MAGIC, writeVarUint(1), Buffer.from([OP_SHA256]), DIGEST, nested]) + const result = expectFailure(parseOtsFile(buf)) + expect(result.reason).to.match(/too deep/) + }) + }) + + describe('validateOtsProof', () => { + const validProof = buildOts(DIGEST, [bitcoinAttestation(810391)]).toString('base64') + + it('accepts a well-formed bitcoin-anchored proof whose digest matches', () => { + expect(validateOtsProof(validProof, EVENT_ID)).to.equal(undefined) + }) + + it('accepts uppercase hex target ids and normalizes them', () => { + expect(validateOtsProof(validProof, EVENT_ID.toUpperCase())).to.equal(undefined) + }) + + it('rejects empty content', () => { + expect(validateOtsProof('', EVENT_ID)).to.match(/empty/) + }) + + it('rejects non-base64 content', () => { + expect(validateOtsProof('!!! not base64 !!!', EVENT_ID)).to.match(/not valid base64/) + }) + + it('rejects proofs whose digest does not match the event id', () => { + const other = '0'.repeat(64) + expect(validateOtsProof(validProof, other)).to.match(/digest does not match/) + }) + + it('rejects target ids that are not 32-byte hex', () => { + expect(validateOtsProof(validProof, 'not-an-id')).to.match(/not a 32-byte hex/) + }) + + it('rejects a non-string target event id', () => { + expect(validateOtsProof(validProof, null as unknown as string)).to.match(/not a 32-byte hex/) + }) + + it('rejects proofs without any bitcoin attestation', () => { + const onlyPending = buildOts(DIGEST, [pendingAttestation('https://a.pool.opentimestamps.org')]).toString('base64') + expect(validateOtsProof(onlyPending, EVENT_ID)).to.match(/bitcoin attestation/) + }) + + it('rejects a proof digested with a non-sha256 hash op', () => { + // Construct a manual RIPEMD160 file (20-byte digest) — this should fail + // the sha256 requirement even though the parser would otherwise accept it. + const parts = [MAGIC, writeVarUint(1), Buffer.from([0x03]), Buffer.alloc(20, 0xaa), bitcoinAttestation(1)] + const ripemd = Buffer.concat(parts).toString('base64') + expect(validateOtsProof(ripemd, 'aa'.repeat(32))).to.match(/sha256 file hash op/) + }) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index ec76cb8d..255bd9a5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,7 +22,8 @@ }, "include": [ "src/**/*.ts", - "test/**/*.ts" + "test/**/*.ts", + "scripts/**/*.ts" ], "exclude": [ "node_modules"