@adastracomputing/ink 0.1.0-alpha.1 → 0.1.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,17 @@ here. Pre-1.0 releases follow `0.Y.Z` semantics, see
8
8
 
9
9
  No unreleased changes.
10
10
 
11
+ ## 0.1.0-alpha.2, inclusion-receipt verifier
12
+
13
+ Adds a public verification path for INK Auditability Section 7
14
+ inclusion receipts, plus a CLI any third party can run without
15
+ trusting any specific operator's UI.
16
+
17
+ ### Added
18
+
19
+ - `verifyInclusionReceipt({receipt, witnessPublicKey, eventHash?, laterCheckpoint?})` exported from the package root. Pure function. Returns `{valid, steps[]}` where each step explains pass/fail with detail. Always verifies structure + Ed25519 service signature against the canonical `ink/audit-inclusion/v1\n` + JCS format. Optionally walks the Merkle proof when `eventHash` is provided, and cross-checks against a `laterCheckpoint` for tree-grew-not-rewound + no-fork-at-same-treeSize.
20
+ - `ink` CLI dispatcher with a `verify-inclusion` subcommand. `npx @adastracomputing/ink verify-inclusion --file receipt.json --witness https://witness.example.com` fetches the witness DID document + current checkpoint and runs the full verification. Witness URL is validated (https-only by default, `--allow-http` opt-in, no credentials). Exit code 0 = valid, 1 = invalid, 2 = usage / network / validation error. Self-contained ESM JavaScript so it works on any Node 22+ install with no TypeScript toolchain.
21
+
11
22
  ## 0.1.0-alpha.1, spec clarification
12
23
 
13
24
  Spec-only release. Reference-implementation code in `src/` is
package/bin/ink.mjs ADDED
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `ink` CLI dispatcher. Subcommands:
4
+ * verify-inclusion verify an INK inclusion receipt against a witness
5
+ *
6
+ * Usage:
7
+ * npx @adastracomputing/ink verify-inclusion --file receipt.json --witness https://witness.tulpa.network
8
+ *
9
+ * Resolves npm's bin invocation pattern: with a single bin named `ink`
10
+ * matching the package's unscoped slug, `npx @adastracomputing/ink ...`
11
+ * routes all args to this dispatcher.
12
+ */
13
+ import { fileURLToPath } from "node:url";
14
+ import { dirname, join } from "node:path";
15
+
16
+ const here = dirname(fileURLToPath(import.meta.url));
17
+
18
+ const SUBCOMMANDS = {
19
+ "verify-inclusion": "verify-inclusion-impl.mjs",
20
+ };
21
+
22
+ function printHelp() {
23
+ console.log(`ink: INK protocol command-line interface.
24
+
25
+ Subcommands:
26
+ verify-inclusion Verify an INK inclusion receipt against a witness.
27
+
28
+ Run a subcommand with --help for details, e.g.:
29
+ npx @adastracomputing/ink verify-inclusion --help
30
+ `);
31
+ }
32
+
33
+ const argv = process.argv.slice(2);
34
+ const sub = argv[0];
35
+
36
+ if (!sub || sub === "--help" || sub === "-h") {
37
+ printHelp();
38
+ process.exit(sub ? 0 : 2);
39
+ }
40
+
41
+ const impl = SUBCOMMANDS[sub];
42
+ if (!impl) {
43
+ console.error(`Unknown subcommand: ${sub}`);
44
+ printHelp();
45
+ process.exit(2);
46
+ }
47
+
48
+ // Re-route remaining args to the subcommand implementation. The impl
49
+ // reads from process.argv directly, so rewrite it before importing.
50
+ process.argv = [process.argv[0], join(here, impl), ...argv.slice(1)];
51
+ await import(`./${impl}`);
@@ -0,0 +1,480 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI: verify an INK inclusion receipt against a witness's published
4
+ * identity and current checkpoint. Self-contained ESM module so the
5
+ * shebang resolves on any Node 22+ install without a TS toolchain.
6
+ *
7
+ * Usage:
8
+ *
9
+ * # Receipt on stdin
10
+ * cat receipt.json | npx @adastracomputing/ink verify-inclusion \
11
+ * --witness https://witness.tulpa.network
12
+ *
13
+ * # Receipt from file
14
+ * npx @adastracomputing/ink verify-inclusion \
15
+ * --file receipt.json \
16
+ * --witness https://witness.tulpa.network
17
+ *
18
+ * # Also walk the inclusion proof
19
+ * npx @adastracomputing/ink verify-inclusion \
20
+ * --file receipt.json \
21
+ * --witness https://witness.tulpa.network \
22
+ * --event-hash 8a3c...
23
+ *
24
+ * Exit codes:
25
+ * 0 receipt is valid
26
+ * 1 receipt is invalid (a step failed)
27
+ * 2 usage / network / parsing error
28
+ */
29
+ import { readFileSync, statSync } from "node:fs";
30
+ import * as ed from "@noble/ed25519";
31
+ import canonicalize from "canonicalize";
32
+
33
+ // ── arg parsing ──
34
+
35
+ function parseArgs(argv) {
36
+ const out = {};
37
+ for (let i = 0; i < argv.length; i++) {
38
+ const a = argv[i];
39
+ if (a === "--file" || a === "-f") out.file = argv[++i];
40
+ else if (a === "--witness" || a === "-w") out.witness = argv[++i];
41
+ else if (a === "--event-hash" || a === "-e") out.eventHash = argv[++i];
42
+ else if (a === "--allow-http") out.allowHttp = true;
43
+ else if (a === "--help" || a === "-h") out.help = true;
44
+ else {
45
+ console.error(`Unknown argument: ${a}`);
46
+ process.exit(2);
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+
52
+ /**
53
+ * Validate and normalize the --witness URL. Rejects unparseable URLs,
54
+ * schemes other than https (or http with --allow-http), and URLs that
55
+ * carry credentials. Returns scheme://host[:port] with no path.
56
+ */
57
+ function validateWitnessUrl(raw, allowHttp) {
58
+ let u;
59
+ try { u = new URL(raw); }
60
+ catch { throw new Error(`--witness is not a valid URL: ${raw}`); }
61
+ if (u.username || u.password) throw new Error("--witness URL must not contain credentials");
62
+ if (u.protocol === "https:") return `${u.protocol}//${u.host}`;
63
+ if (u.protocol === "http:") {
64
+ if (!allowHttp) throw new Error("--witness URL must use https:// (pass --allow-http for plain http)");
65
+ return `${u.protocol}//${u.host}`;
66
+ }
67
+ throw new Error(`--witness URL scheme must be https:// or http://, got ${u.protocol}`);
68
+ }
69
+
70
+ function printHelp() {
71
+ console.log(`verify-inclusion: verify an INK inclusion receipt.
72
+
73
+ Usage:
74
+ verify-inclusion --witness <url> [--file <receipt.json>] [--event-hash <hex>]
75
+
76
+ Options:
77
+ -w, --witness <url> Witness base URL (e.g. https://witness.tulpa.network)
78
+ -f, --file <path> Receipt JSON file. Omit to read from stdin.
79
+ -e, --event-hash <hex> Optional. Re-walk the inclusion proof using this leaf.
80
+ -h, --help Show this help.
81
+
82
+ Exit codes:
83
+ 0 receipt valid
84
+ 1 receipt invalid
85
+ 2 usage or network error
86
+ `);
87
+ }
88
+
89
+ // ── encoding helpers (mirror src/crypto/ink.ts) ──
90
+
91
+ function base64urlDecode(s) {
92
+ const padded = s.replace(/-/g, "+").replace(/_/g, "/") + "==".slice(0, (4 - (s.length % 4)) % 4);
93
+ const bin = Buffer.from(padded, "base64");
94
+ return new Uint8Array(bin.buffer, bin.byteOffset, bin.byteLength);
95
+ }
96
+
97
+ function hexToBytes(hex) {
98
+ const out = new Uint8Array(hex.length / 2);
99
+ for (let i = 0; i < hex.length; i += 2) {
100
+ out[i / 2] = parseInt(hex.slice(i, i + 2), 16);
101
+ }
102
+ return out;
103
+ }
104
+
105
+ function bytesToHex(bytes) {
106
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
107
+ }
108
+
109
+ // ── multibase Ed25519 key decode (z-prefix, base58btc, 0xed 0x01 multicodec) ──
110
+
111
+ const BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
112
+ function decodePublicKeyMultibase(mb) {
113
+ if (typeof mb !== "string" || mb.length === 0 || mb[0] !== "z") {
114
+ throw new Error("publicKeyMultibase must start with 'z' (base58btc)");
115
+ }
116
+ const body = mb.slice(1);
117
+ let num = 0n;
118
+ for (const ch of body) {
119
+ const idx = BASE58.indexOf(ch);
120
+ if (idx < 0) throw new Error(`invalid base58btc char: ${ch}`);
121
+ num = num * 58n + BigInt(idx);
122
+ }
123
+ const bytes = [];
124
+ while (num > 0n) {
125
+ bytes.unshift(Number(num & 0xffn));
126
+ num >>= 8n;
127
+ }
128
+ for (const ch of body) {
129
+ if (ch !== "1") break;
130
+ bytes.unshift(0);
131
+ }
132
+ if (bytes.length < 2 || bytes[0] !== 0xed || bytes[1] !== 0x01) {
133
+ throw new Error("multibase key missing Ed25519 multicodec prefix (0xed 0x01)");
134
+ }
135
+ return new Uint8Array(bytes.slice(2));
136
+ }
137
+
138
+ // ── Merkle inclusion-proof walker (RFC 6962-derived) ──
139
+
140
+ async function hashPair(left, right) {
141
+ const l = hexToBytes(left);
142
+ const r = hexToBytes(right);
143
+ const buf = new Uint8Array(1 + l.length + r.length);
144
+ buf[0] = 0x01;
145
+ buf.set(l, 1);
146
+ buf.set(r, 1 + l.length);
147
+ const out = new Uint8Array(await crypto.subtle.digest("SHA-256", buf));
148
+ return bytesToHex(out);
149
+ }
150
+
151
+ function largestPowerOf2LessThan(n) {
152
+ if (n <= 1) return 0;
153
+ let p = 1;
154
+ while (p * 2 < n) p *= 2;
155
+ return p;
156
+ }
157
+
158
+ async function recomputeRoot(currentHash, proof, proofIdx, leafIndex, start, size) {
159
+ if (size === 1) {
160
+ if (proofIdx !== proof.length) throw new Error("inclusion proof has unused entries");
161
+ return currentHash;
162
+ }
163
+ if (proofIdx >= proof.length) {
164
+ throw new Error("inclusion proof too short for declared treeSize");
165
+ }
166
+ const split = largestPowerOf2LessThan(size);
167
+ if (leafIndex - start < split) {
168
+ const leftResult = await recomputeRoot(currentHash, proof, proofIdx + 1, leafIndex, start, split);
169
+ return hashPair(leftResult, proof[proofIdx]);
170
+ }
171
+ const rightResult = await recomputeRoot(currentHash, proof, proofIdx + 1, leafIndex, start + split, size - split);
172
+ return hashPair(proof[proofIdx], rightResult);
173
+ }
174
+
175
+ // ── core verifier ──
176
+
177
+ const MAX_PROOF_LENGTH = 64;
178
+ const MAX_RECEIPT_BYTES = 64 * 1024;
179
+
180
+ function checkCheckpointShape(cp) {
181
+ if (cp === null || typeof cp !== "object") return "laterCheckpoint must be an object";
182
+ if (!Number.isInteger(cp.treeSize) || cp.treeSize < 0) return "laterCheckpoint.treeSize must be a non-negative integer";
183
+ if (typeof cp.rootHash !== "string" || !/^[0-9a-f]{64}$/.test(cp.rootHash)) {
184
+ return "laterCheckpoint.rootHash must be 64 lowercase hex chars";
185
+ }
186
+ return null;
187
+ }
188
+
189
+ function checkReceiptShape(r) {
190
+ if (r === null || typeof r !== "object") return "receipt is not an object";
191
+ if (typeof r.eventId !== "string" || r.eventId.length === 0) return "eventId missing";
192
+ if (!Number.isInteger(r.leafIndex) || r.leafIndex < 0) return "leafIndex must be non-negative integer";
193
+ if (!Number.isInteger(r.treeSize) || r.treeSize < 1) return "treeSize must be positive integer";
194
+ if (r.leafIndex >= r.treeSize) return "leafIndex must be < treeSize";
195
+ if (typeof r.rootHash !== "string" || !/^[0-9a-f]{64}$/.test(r.rootHash)) return "rootHash must be 64 lowercase hex chars";
196
+ if (!Array.isArray(r.inclusionProof)) return "inclusionProof must be an array";
197
+ if (r.inclusionProof.length > MAX_PROOF_LENGTH) return `inclusionProof exceeds max length of ${MAX_PROOF_LENGTH} entries`;
198
+ for (const p of r.inclusionProof) {
199
+ if (typeof p !== "string" || !/^[0-9a-f]{64}$/.test(p)) return "every inclusionProof entry must be 64 lowercase hex chars";
200
+ }
201
+ if (typeof r.timestamp !== "string" || r.timestamp.length === 0) return "timestamp missing";
202
+ if (typeof r.serviceSignature !== "string" || r.serviceSignature.length === 0) return "serviceSignature missing";
203
+ return null;
204
+ }
205
+
206
+ async function verifyReceipt(receipt, witnessPublicKey, eventHash, laterCheckpoint) {
207
+ const steps = [];
208
+ const structuralProblem = checkReceiptShape(receipt);
209
+ if (structuralProblem) {
210
+ steps.push({ name: "structure", pass: false, detail: structuralProblem });
211
+ return { valid: false, steps };
212
+ }
213
+ steps.push({ name: "structure", pass: true });
214
+
215
+ const signedPayload = {
216
+ eventId: receipt.eventId,
217
+ leafIndex: receipt.leafIndex,
218
+ treeSize: receipt.treeSize,
219
+ rootHash: receipt.rootHash,
220
+ timestamp: receipt.timestamp,
221
+ };
222
+ const sigBase = `ink/audit-inclusion/v1\n${canonicalize(signedPayload)}`;
223
+ let sigValid = false;
224
+ try {
225
+ const sig = base64urlDecode(receipt.serviceSignature);
226
+ sigValid = await ed.verifyAsync(sig, new TextEncoder().encode(sigBase), witnessPublicKey);
227
+ } catch (e) {
228
+ steps.push({ name: "signature", pass: false, detail: e instanceof Error ? e.message : "signature decode failed" });
229
+ return { valid: false, steps };
230
+ }
231
+ if (!sigValid) {
232
+ steps.push({ name: "signature", pass: false, detail: "Ed25519 verification failed" });
233
+ return { valid: false, steps };
234
+ }
235
+ steps.push({ name: "signature", pass: true });
236
+
237
+ if (eventHash !== undefined) {
238
+ if (!/^[0-9a-f]{64}$/.test(eventHash)) {
239
+ steps.push({ name: "proof", pass: false, detail: "eventHash must be 64 lowercase hex chars" });
240
+ return { valid: false, steps };
241
+ }
242
+ let computed;
243
+ try {
244
+ computed = await recomputeRoot(eventHash, receipt.inclusionProof, 0, receipt.leafIndex, 0, receipt.treeSize);
245
+ } catch (e) {
246
+ steps.push({ name: "proof", pass: false, detail: e instanceof Error ? e.message : "proof walk failed" });
247
+ return { valid: false, steps };
248
+ }
249
+ if (computed !== receipt.rootHash) {
250
+ steps.push({ name: "proof", pass: false, detail: "leaf-to-root walk did not reach claimed rootHash" });
251
+ return { valid: false, steps };
252
+ }
253
+ steps.push({ name: "proof", pass: true });
254
+ }
255
+
256
+ if (laterCheckpoint !== undefined) {
257
+ const cpShape = checkCheckpointShape(laterCheckpoint);
258
+ if (cpShape) {
259
+ steps.push({ name: "checkpoint", pass: false, detail: cpShape });
260
+ return { valid: false, steps };
261
+ }
262
+ if (laterCheckpoint.treeSize < receipt.treeSize) {
263
+ steps.push({
264
+ name: "checkpoint",
265
+ pass: false,
266
+ detail: `checkpoint treeSize ${laterCheckpoint.treeSize} < receipt treeSize ${receipt.treeSize} (witness rewound the tree)`,
267
+ });
268
+ return { valid: false, steps };
269
+ }
270
+ if (laterCheckpoint.treeSize === receipt.treeSize && laterCheckpoint.rootHash !== receipt.rootHash) {
271
+ steps.push({
272
+ name: "checkpoint",
273
+ pass: false,
274
+ detail: "checkpoint rootHash differs from receipt rootHash at same treeSize (fork)",
275
+ });
276
+ return { valid: false, steps };
277
+ }
278
+ steps.push({ name: "checkpoint", pass: true });
279
+ }
280
+
281
+ return { valid: true, steps };
282
+ }
283
+
284
+ // ── witness HTTP helpers ──
285
+
286
+ /** Hard caps to prevent a malicious or compromised --witness URL
287
+ * from forcing unbounded memory growth via a streamed response. */
288
+ const MAX_RESPONSE_BYTES = 64 * 1024;
289
+ const FETCH_TIMEOUT_MS = 10_000;
290
+
291
+ /**
292
+ * Fetch with byte cap and abort timeout. Aborts the response stream
293
+ * mid-read if it exceeds the cap so we never allocate beyond it.
294
+ * Returns the decoded UTF-8 text.
295
+ */
296
+ async function fetchBounded(url) {
297
+ const ctrl = new AbortController();
298
+ const timer = setTimeout(() => ctrl.abort(new Error("fetch timed out")), FETCH_TIMEOUT_MS);
299
+ try {
300
+ const res = await fetch(url, { signal: ctrl.signal });
301
+ if (!res.ok) throw new Error(`fetch failed (${res.status}): ${url}`);
302
+ if (!res.body) return "";
303
+ const reader = res.body.getReader();
304
+ const chunks = [];
305
+ let total = 0;
306
+ try {
307
+ while (true) {
308
+ const { value, done } = await reader.read();
309
+ if (done) break;
310
+ if (value) {
311
+ total += value.byteLength;
312
+ if (total > MAX_RESPONSE_BYTES) {
313
+ try { await reader.cancel(); } catch { /* ignore */ }
314
+ throw new Error(`response exceeds ${MAX_RESPONSE_BYTES} bytes`);
315
+ }
316
+ chunks.push(value);
317
+ }
318
+ }
319
+ } finally {
320
+ try { reader.releaseLock(); } catch { /* ignore */ }
321
+ }
322
+ const merged = new Uint8Array(total);
323
+ let off = 0;
324
+ for (const c of chunks) { merged.set(c, off); off += c.byteLength; }
325
+ return new TextDecoder().decode(merged);
326
+ } finally {
327
+ clearTimeout(timer);
328
+ }
329
+ }
330
+
331
+ async function fetchWitnessPublicKey(witnessUrl) {
332
+ const url = `${witnessUrl.replace(/\/$/, "")}/.well-known/did.json`;
333
+ const body = await fetchBounded(url);
334
+ let doc;
335
+ try { doc = JSON.parse(body); }
336
+ catch { throw new Error(`DID document is not valid JSON: ${url}`); }
337
+ const vm = doc?.verificationMethod?.[0]?.publicKeyMultibase;
338
+ if (typeof vm !== "string") throw new Error("DID document missing verificationMethod[0].publicKeyMultibase");
339
+ return decodePublicKeyMultibase(vm);
340
+ }
341
+
342
+ /**
343
+ * Parse a C2SP tlog-checkpoint response. The body has three header
344
+ * lines (origin, treeSize, rootHash), each terminated by \n, then a
345
+ * blank line, then a signature line. We don't verify the signature
346
+ * here (it'd require pre-fetching the witness key, which the caller
347
+ * already does for the receipt). Just extract the header fields with
348
+ * strict regexes so a malformed checkpoint can't fake-pass.
349
+ */
350
+ function parseCheckpointBody(body) {
351
+ const sepIdx = body.indexOf("\n\n");
352
+ if (sepIdx < 0) return null;
353
+ const header = body.slice(0, sepIdx);
354
+ const lines = header.split("\n");
355
+ if (lines.length !== 3) return null;
356
+ if (!lines[0]) return null;
357
+ if (!/^\d+$/.test(lines[1])) return null;
358
+ const treeSize = parseInt(lines[1], 10);
359
+ if (!Number.isInteger(treeSize) || treeSize < 0 || treeSize > Number.MAX_SAFE_INTEGER) return null;
360
+ if (!/^[0-9a-f]{64}$/.test(lines[2])) return null;
361
+ return { treeSize, rootHash: lines[2] };
362
+ }
363
+
364
+ async function fetchCurrentCheckpoint(witnessUrl) {
365
+ const url = `${witnessUrl.replace(/\/$/, "")}/ink/v1/checkpoint`;
366
+ let body;
367
+ try {
368
+ body = await fetchBounded(url);
369
+ } catch {
370
+ // Checkpoint cross-check is optional; downgrade fetch failures to
371
+ // 'not available' rather than crashing the verifier.
372
+ return null;
373
+ }
374
+ return parseCheckpointBody(body);
375
+ }
376
+
377
+ async function readStdin() {
378
+ return new Promise((resolve, reject) => {
379
+ let data = "";
380
+ process.stdin.setEncoding("utf8");
381
+ process.stdin.on("data", (chunk) => {
382
+ data += chunk;
383
+ if (data.length > MAX_RECEIPT_BYTES) {
384
+ reject(new Error(`receipt input exceeds ${MAX_RECEIPT_BYTES} bytes`));
385
+ process.stdin.destroy();
386
+ }
387
+ });
388
+ process.stdin.on("end", () => resolve(data));
389
+ process.stdin.on("error", reject);
390
+ });
391
+ }
392
+
393
+ // ── main ──
394
+
395
+ async function main() {
396
+ const args = parseArgs(process.argv.slice(2));
397
+ if (args.help) {
398
+ printHelp();
399
+ process.exit(0);
400
+ }
401
+ if (!args.witness) {
402
+ console.error("Error: --witness <url> is required.");
403
+ printHelp();
404
+ process.exit(2);
405
+ }
406
+
407
+ let witnessBase;
408
+ try {
409
+ witnessBase = validateWitnessUrl(args.witness, args.allowHttp);
410
+ } catch (e) {
411
+ console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
412
+ process.exit(2);
413
+ }
414
+
415
+ let raw;
416
+ try {
417
+ if (args.file) {
418
+ // Stat first so a multi-GB file is rejected before allocation.
419
+ const st = statSync(args.file);
420
+ if (st.size > MAX_RECEIPT_BYTES) {
421
+ throw new Error(`receipt file exceeds ${MAX_RECEIPT_BYTES} bytes (${st.size} on disk)`);
422
+ }
423
+ raw = readFileSync(args.file, "utf8");
424
+ } else {
425
+ raw = await readStdin();
426
+ }
427
+ if (raw.length > MAX_RECEIPT_BYTES) {
428
+ throw new Error(`receipt exceeds ${MAX_RECEIPT_BYTES} bytes after decode`);
429
+ }
430
+ } catch (e) {
431
+ console.error(`Error reading receipt: ${e instanceof Error ? e.message : String(e)}`);
432
+ process.exit(2);
433
+ }
434
+
435
+ let receipt;
436
+ try {
437
+ receipt = JSON.parse(raw);
438
+ } catch (e) {
439
+ console.error(`Error parsing receipt JSON: ${e instanceof Error ? e.message : String(e)}`);
440
+ process.exit(2);
441
+ }
442
+
443
+ let witnessPublicKey;
444
+ try {
445
+ witnessPublicKey = await fetchWitnessPublicKey(witnessBase);
446
+ } catch (e) {
447
+ console.error(`Error fetching witness identity: ${e instanceof Error ? e.message : String(e)}`);
448
+ process.exit(2);
449
+ }
450
+
451
+ const laterCheckpoint = await fetchCurrentCheckpoint(witnessBase);
452
+
453
+ const result = await verifyReceipt(receipt, witnessPublicKey, args.eventHash, laterCheckpoint ?? undefined);
454
+
455
+ console.log(`Receipt: eventId=${receipt?.eventId} leafIndex=${receipt?.leafIndex} treeSize=${receipt?.treeSize}`);
456
+ console.log(`Witness: ${witnessBase}`);
457
+ if (laterCheckpoint) {
458
+ console.log(`Current checkpoint: treeSize=${laterCheckpoint.treeSize} rootHash=${laterCheckpoint.rootHash}`);
459
+ } else {
460
+ console.log("Current checkpoint: not available (skipping checkpoint cross-check)");
461
+ }
462
+ console.log("");
463
+ for (const step of result.steps) {
464
+ const mark = step.pass ? "PASS" : "FAIL";
465
+ console.log(` [${mark}] ${step.name}${step.detail ? ": " + step.detail : ""}`);
466
+ }
467
+ console.log("");
468
+ if (result.valid) {
469
+ console.log("RECEIPT VALID");
470
+ process.exit(0);
471
+ } else {
472
+ console.log("RECEIPT INVALID");
473
+ process.exit(1);
474
+ }
475
+ }
476
+
477
+ main().catch((e) => {
478
+ console.error(`Unexpected error: ${e instanceof Error ? e.message : String(e)}`);
479
+ process.exit(2);
480
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adastracomputing/ink",
3
- "version": "0.1.0-alpha.1",
3
+ "version": "0.1.0-alpha.2",
4
4
  "description": "Reference implementation and specification of the INK (Inter-agent Networking Kernel) protocol",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "author": "Ad Astra Computing Inc.",
@@ -26,8 +26,12 @@
26
26
  "engines": {
27
27
  "node": ">=22"
28
28
  },
29
+ "bin": {
30
+ "ink": "./bin/ink.mjs"
31
+ },
29
32
  "files": [
30
33
  "src/",
34
+ "bin/",
31
35
  "specs/",
32
36
  "docs/",
33
37
  "test-vectors/",
@@ -0,0 +1,268 @@
1
+ /**
2
+ * INK Auditability Section 7 inclusion-receipt verification.
3
+ *
4
+ * A witness returns a signed inclusion receipt when an agent submits
5
+ * an audit event. The receipt commits the witness to a specific
6
+ * (leafIndex, treeSize, rootHash) for the submitted event.
7
+ *
8
+ * To verify a receipt independently:
9
+ * 1. Check the witness's serviceSignature against its published
10
+ * Ed25519 public key. The signed bytes are
11
+ * `ink/audit-inclusion/v1\n` + JCS({eventId, leafIndex, treeSize,
12
+ * rootHash, timestamp}).
13
+ * 2. (Optional) Re-hash the audit event to derive the leaf hash and
14
+ * walk the inclusion proof up to the witness's claimed rootHash.
15
+ * 3. (Optional) Cross-check the receipt against a later signed
16
+ * checkpoint: the tree only grew (treeSize >= receipt.treeSize)
17
+ * and if equal, the rootHash matches.
18
+ *
19
+ * This module ships the pure verification logic. The bin/verify-inclusion
20
+ * CLI is a thin wrapper that fetches the witness DID document + a
21
+ * current checkpoint and calls verifyInclusionReceipt.
22
+ */
23
+ import * as ed from "@noble/ed25519";
24
+ import { base64urlDecode, jcsCanonicalize, hexToBytes, bytesToHex } from "../crypto/ink.js";
25
+
26
+ export interface InclusionReceipt {
27
+ eventId: string;
28
+ leafIndex: number;
29
+ treeSize: number;
30
+ rootHash: string;
31
+ inclusionProof: string[];
32
+ /** ISO 8601 timestamp at which the witness committed the leaf. */
33
+ timestamp: string;
34
+ /** Base64url Ed25519 signature over the canonical bytes. */
35
+ serviceSignature: string;
36
+ }
37
+
38
+ export interface VerifyStep {
39
+ name: string;
40
+ pass: boolean;
41
+ detail?: string;
42
+ }
43
+
44
+ export interface InclusionReceiptVerifyResult {
45
+ valid: boolean;
46
+ steps: VerifyStep[];
47
+ }
48
+
49
+ /**
50
+ * Verify an INK inclusion receipt.
51
+ *
52
+ * Always performs:
53
+ * - Structural validation of the receipt object
54
+ * - Service signature verification against `witnessPublicKey`
55
+ *
56
+ * Optionally performs (when the corresponding input is provided):
57
+ * - Leaf-to-root proof walk (`eventHash`)
58
+ * - Cross-check against a later signed checkpoint (`laterCheckpoint`)
59
+ */
60
+ export async function verifyInclusionReceipt(opts: {
61
+ receipt: InclusionReceipt;
62
+ /** Raw 32-byte Ed25519 public key of the witness service. */
63
+ witnessPublicKey: Uint8Array;
64
+ /** Optional leaf hash (SHA-256 of JCS(audit event without agentSignature),
65
+ * hex-encoded). When provided, the inclusion proof is walked from
66
+ * the leaf up to the claimed rootHash. */
67
+ eventHash?: string;
68
+ /** Optional later checkpoint to cross-check the receipt against.
69
+ * Must come from a `/ink/v1/checkpoint` response that the verifier
70
+ * has separately validated as authentic. */
71
+ laterCheckpoint?: { treeSize: number; rootHash: string };
72
+ }): Promise<InclusionReceiptVerifyResult> {
73
+ const steps: VerifyStep[] = [];
74
+ const { receipt, witnessPublicKey, eventHash, laterCheckpoint } = opts;
75
+
76
+ // ── Step 1: structural validation ──
77
+ const structuralProblem = checkReceiptShape(receipt);
78
+ if (structuralProblem) {
79
+ steps.push({ name: "structure", pass: false, detail: structuralProblem });
80
+ return { valid: false, steps };
81
+ }
82
+ steps.push({ name: "structure", pass: true });
83
+
84
+ // ── Step 2: signature ──
85
+ const signedPayload = {
86
+ eventId: receipt.eventId,
87
+ leafIndex: receipt.leafIndex,
88
+ treeSize: receipt.treeSize,
89
+ rootHash: receipt.rootHash,
90
+ timestamp: receipt.timestamp,
91
+ };
92
+ const sigBase = `ink/audit-inclusion/v1\n${jcsCanonicalize(signedPayload)}`;
93
+ let sigValid = false;
94
+ try {
95
+ const sig = base64urlDecode(receipt.serviceSignature);
96
+ sigValid = await ed.verifyAsync(sig, new TextEncoder().encode(sigBase), witnessPublicKey);
97
+ } catch (e) {
98
+ steps.push({
99
+ name: "signature",
100
+ pass: false,
101
+ detail: e instanceof Error ? e.message : "signature decode failed",
102
+ });
103
+ return { valid: false, steps };
104
+ }
105
+ if (!sigValid) {
106
+ steps.push({ name: "signature", pass: false, detail: "Ed25519 verification failed" });
107
+ return { valid: false, steps };
108
+ }
109
+ steps.push({ name: "signature", pass: true });
110
+
111
+ // ── Step 3: inclusion-proof walk (optional) ──
112
+ if (eventHash !== undefined) {
113
+ if (!/^[0-9a-f]{64}$/.test(eventHash)) {
114
+ steps.push({ name: "proof", pass: false, detail: "eventHash must be 64 lowercase hex chars" });
115
+ return { valid: false, steps };
116
+ }
117
+ const verified = await verifyInclusionProof(
118
+ eventHash,
119
+ receipt.inclusionProof,
120
+ receipt.leafIndex,
121
+ receipt.treeSize,
122
+ receipt.rootHash,
123
+ );
124
+ if (!verified) {
125
+ steps.push({ name: "proof", pass: false, detail: "leaf-to-root walk did not reach claimed rootHash" });
126
+ return { valid: false, steps };
127
+ }
128
+ steps.push({ name: "proof", pass: true });
129
+ }
130
+
131
+ // ── Step 4: later-checkpoint cross-check (optional) ──
132
+ if (laterCheckpoint !== undefined) {
133
+ const cpShape = checkCheckpointShape(laterCheckpoint);
134
+ if (cpShape) {
135
+ steps.push({ name: "checkpoint", pass: false, detail: cpShape });
136
+ return { valid: false, steps };
137
+ }
138
+ if (laterCheckpoint.treeSize < receipt.treeSize) {
139
+ steps.push({
140
+ name: "checkpoint",
141
+ pass: false,
142
+ detail: `checkpoint treeSize ${laterCheckpoint.treeSize} < receipt treeSize ${receipt.treeSize} (witness rewound the tree)`,
143
+ });
144
+ return { valid: false, steps };
145
+ }
146
+ if (laterCheckpoint.treeSize === receipt.treeSize && laterCheckpoint.rootHash !== receipt.rootHash) {
147
+ steps.push({
148
+ name: "checkpoint",
149
+ pass: false,
150
+ detail: "checkpoint rootHash differs from receipt rootHash at same treeSize (fork)",
151
+ });
152
+ return { valid: false, steps };
153
+ }
154
+ steps.push({ name: "checkpoint", pass: true });
155
+ }
156
+
157
+ return { valid: true, steps };
158
+ }
159
+
160
+ // ── Internal helpers ──
161
+
162
+ /** Generous upper bound on inclusion-proof length. Real proofs are
163
+ * ceil(log2(treeSize)) entries; a treeSize > 2^60 is implausible for
164
+ * any real log, so capping at 64 entries bounds memory + walker depth
165
+ * without rejecting legitimate input. The signed payload binds
166
+ * treeSize but not the proof array itself, so an attacker could
167
+ * otherwise append unbounded garbage to a valid receipt. */
168
+ const MAX_PROOF_LENGTH = 64;
169
+
170
+ function checkReceiptShape(receipt: InclusionReceipt): string | null {
171
+ if (receipt === null || typeof receipt !== "object") return "receipt is not an object";
172
+ if (typeof receipt.eventId !== "string" || receipt.eventId.length === 0) return "eventId missing";
173
+ if (!Number.isInteger(receipt.leafIndex) || receipt.leafIndex < 0) return "leafIndex must be non-negative integer";
174
+ if (!Number.isInteger(receipt.treeSize) || receipt.treeSize < 1) return "treeSize must be positive integer";
175
+ if (receipt.leafIndex >= receipt.treeSize) return "leafIndex must be < treeSize";
176
+ if (typeof receipt.rootHash !== "string" || !/^[0-9a-f]{64}$/.test(receipt.rootHash)) {
177
+ return "rootHash must be 64 lowercase hex chars";
178
+ }
179
+ if (!Array.isArray(receipt.inclusionProof)) return "inclusionProof must be an array";
180
+ if (receipt.inclusionProof.length > MAX_PROOF_LENGTH) {
181
+ return `inclusionProof exceeds max length of ${MAX_PROOF_LENGTH} entries`;
182
+ }
183
+ for (const p of receipt.inclusionProof) {
184
+ if (typeof p !== "string" || !/^[0-9a-f]{64}$/.test(p)) {
185
+ return "every inclusionProof entry must be 64 lowercase hex chars";
186
+ }
187
+ }
188
+ if (typeof receipt.timestamp !== "string" || receipt.timestamp.length === 0) return "timestamp missing";
189
+ if (typeof receipt.serviceSignature !== "string" || receipt.serviceSignature.length === 0) {
190
+ return "serviceSignature missing";
191
+ }
192
+ return null;
193
+ }
194
+
195
+ function checkCheckpointShape(cp: { treeSize: number; rootHash: string }): string | null {
196
+ if (cp === null || typeof cp !== "object") return "laterCheckpoint must be an object";
197
+ if (!Number.isInteger(cp.treeSize) || cp.treeSize < 0) {
198
+ return "laterCheckpoint.treeSize must be a non-negative integer";
199
+ }
200
+ if (typeof cp.rootHash !== "string" || !/^[0-9a-f]{64}$/.test(cp.rootHash)) {
201
+ return "laterCheckpoint.rootHash must be 64 lowercase hex chars";
202
+ }
203
+ return null;
204
+ }
205
+
206
+ async function hashPair(left: string, right: string): Promise<string> {
207
+ const l = hexToBytes(left);
208
+ const r = hexToBytes(right);
209
+ const buf = new Uint8Array(1 + l.length + r.length);
210
+ buf[0] = 0x01;
211
+ buf.set(l, 1);
212
+ buf.set(r, 1 + l.length);
213
+ const out = new Uint8Array(await crypto.subtle.digest("SHA-256", buf));
214
+ return bytesToHex(out);
215
+ }
216
+
217
+ function largestPowerOf2LessThan(n: number): number {
218
+ if (n <= 1) return 0;
219
+ let p = 1;
220
+ while (p * 2 < n) p *= 2;
221
+ return p;
222
+ }
223
+
224
+ async function recomputeRoot(
225
+ currentHash: string,
226
+ proof: string[],
227
+ proofIdx: number,
228
+ leafIndex: number,
229
+ start: number,
230
+ size: number,
231
+ ): Promise<string> {
232
+ if (size === 1) {
233
+ // Reached the leaf. Any proof entries left over mean the proof was
234
+ // padded with extras; reject it as malformed.
235
+ if (proofIdx !== proof.length) throw new Error("inclusion proof has unused entries");
236
+ return currentHash;
237
+ }
238
+ if (proofIdx >= proof.length) {
239
+ // Proof exhausted before walking down to the leaf. Without this,
240
+ // an attacker can present a short proof against a tree > 1 leaf
241
+ // and the walker returns currentHash (the leaf), which a verifier
242
+ // might mistakenly equate to rootHash.
243
+ throw new Error("inclusion proof too short for declared treeSize");
244
+ }
245
+ const split = largestPowerOf2LessThan(size);
246
+ if (leafIndex - start < split) {
247
+ const leftResult = await recomputeRoot(currentHash, proof, proofIdx + 1, leafIndex, start, split);
248
+ return hashPair(leftResult, proof[proofIdx]!);
249
+ }
250
+ const rightResult = await recomputeRoot(currentHash, proof, proofIdx + 1, leafIndex, start + split, size - split);
251
+ return hashPair(proof[proofIdx]!, rightResult);
252
+ }
253
+
254
+ async function verifyInclusionProof(
255
+ leafHash: string,
256
+ proof: string[],
257
+ leafIndex: number,
258
+ treeSize: number,
259
+ expectedRootHash: string,
260
+ ): Promise<boolean> {
261
+ if (leafIndex < 0 || leafIndex >= treeSize) return false;
262
+ try {
263
+ const computed = await recomputeRoot(leafHash, proof, 0, leafIndex, 0, treeSize);
264
+ return computed === expectedRootHash;
265
+ } catch {
266
+ return false;
267
+ }
268
+ }
package/src/index.ts CHANGED
@@ -46,6 +46,14 @@ export {
46
46
  // Middleware: transport-level INK auth
47
47
  export { verifyInkAuth, type NonceStore } from "./middleware/ink-auth.js";
48
48
 
49
+ // Audit: inclusion-receipt verification
50
+ export {
51
+ verifyInclusionReceipt,
52
+ type InclusionReceipt,
53
+ type InclusionReceiptVerifyResult,
54
+ type VerifyStep,
55
+ } from "./audit/inclusion-receipt.js";
56
+
49
57
  // Optional containment / governance primitives
50
58
  export { HandshakeBudgetTracker } from "./ink/handshake-budget.js";
51
59