@adastracomputing/ink 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,81 @@ All notable changes to INK are recorded
4
4
  here. Pre-1.0 releases follow `0.Y.Z` semantics, see
5
5
  [`docs/maturity.md`](docs/maturity.md) for the versioning policy.
6
6
 
7
+ ## 0.5.0, transparency-log consistency proofs
8
+
9
+ This release adds RFC 6962 consistency-proof verification, so a verifier can
10
+ confirm a transparency log only ever appended to its tree between two
11
+ checkpoints rather than forking its history. The size comparison alone cannot
12
+ detect a split view. It is published on the `next` dist-tag; this release is
13
+ additive, with no breaking changes.
14
+
15
+ ### Additions
16
+
17
+ - `verifyConsistencyProof(first, firstRoot, second, secondRoot, proof)` verifies
18
+ an RFC 6962 Section 2.1.2 consistency proof: that the tree of `first` leaves is
19
+ an append-only prefix of the tree of `second` leaves. It returns false (never
20
+ throws) for any malformed input or any proof that does not reconstruct both
21
+ roots with every node consumed.
22
+ - The `verify-inclusion` CLI uses it: when `--origin` enables the checkpoint
23
+ cross-check and the signature-verified checkpoint is newer than the receipt's
24
+ tree, the CLI fetches a consistency proof and verifies the receipt's tree is an
25
+ append-only prefix of the checkpoint. A witness that serves no proof has the
26
+ step reported as skipped, not passed.
27
+
28
+ The reference witness serves these proofs at `GET /ink/v1/consistency?first=N&second=M`.
29
+
30
+ ## 0.4.0, stricter verification, message-size bounds, checkpoint and receipt verification
31
+
32
+ This release tightens signature verification and input validation and adds
33
+ several verification helpers. It is published on the `next` dist-tag.
34
+
35
+ ### Potentially breaking validation tightenings
36
+
37
+ These reject inputs that `0.3.0` accepted. Legitimate signer and receiver
38
+ traffic is unaffected; the rejected inputs are malformed, malicious, or outside
39
+ the documented profile.
40
+
41
+ - Ed25519 signatures are now verified in strict RFC 8032 mode at every
42
+ verification site. Small-order public keys and non-canonical point encodings
43
+ are rejected.
44
+ - Signed JSON numbers are constrained to the forms every canonicalizer
45
+ serializes identically: non-finite values, negative zero, and values whose
46
+ shortest form uses exponential notation are rejected at signing and
47
+ verification.
48
+ - The agent card, audit, handshake, and discovery schemas now enforce maximum
49
+ field lengths and array sizes.
50
+ - The `Authorization: INK-Ed25519` header is matched against single literal
51
+ spaces; a tab, carriage return, or line feed in the separator is rejected.
52
+
53
+ ### Additions
54
+
55
+ - `verifyCheckpoint(signed, witnessPublicKey, expectedOrigin)` verifies a signed
56
+ C2SP checkpoint: the witness Ed25519 signature over the checkpoint body and the
57
+ log origin. A checkpoint used for the inclusion-receipt cross-check must be
58
+ verified this way first.
59
+ - `verifyReceipt({ receipt, senderPublicKey, expected })` binds a delivery
60
+ receipt to the exact message it acknowledges: issuer key, `from`/`to`/
61
+ `messageId`, the recomputed message hash, and an optional `disposition`.
62
+ - `verifyInclusionReceipt` accepts an `event` option that recomputes the leaf
63
+ hash and binds it to `receipt.eventId`. The legacy `eventHash` is retained but
64
+ does not provide that binding.
65
+ - `verifyInkAuth` returns a prefix-independent `principal` alongside the raw
66
+ sender id; per-sender security state (blocks, rate limits) should key on
67
+ `principal`. `canonicalAgentPrincipal(agentId)` is exported for the same use.
68
+
69
+ Per the pre-1.0 policy this release publishes under the `next` dist-tag; `latest`
70
+ is unchanged.
71
+
72
+ ## 0.3.0, accept the ink: agentId alias for key extraction
73
+
74
+ `extractPublicKeyFromAgentId` now accepts either the canonical `tulpa:` prefix or the `ink:` alias introduced in ink/0.4. Both carry the identical multibase Ed25519 key, so the bootstrap verification key is byte-identical and a signature made with that key verifies regardless of which accepted prefix carried it. The prefix is identity syntax, not signing authority.
75
+
76
+ Emission is unchanged: `deriveAgentId` still returns `tulpa:` (accept both, emit one). The new `AGENT_ID_KEY_PREFIXES` export is frozen so a consumer cannot widen the accepted set at runtime. The change is additive and backward compatible. Existing `tulpa:` inputs behave exactly as before, and every previously rejected prefix other than `ink:` is still rejected. The wire protocol version is unchanged.
77
+
78
+ A receiver that keys per-sender security state (blocks, rate limits, duplicate-payload checks, cached verification keys, connection identity) MUST collapse the two spellings to one prefix-independent principal so a sender cannot switch prefix to dodge a block or split a rate-limit window. See [Identity](https://ink.tulpa.network/spec/identity/).
79
+
80
+ Per the pre-1.0 policy this release publishes under the `next` dist-tag.
81
+
7
82
  ## 0.2.0, version-keyed body-signature domain
8
83
 
9
84
  Version-keyed body-signature domain. The body message signature is now domain-separated by protocol version. ink/0.1 messages, and any object with no explicit ink/0.2 protocol, keep the legacy `tulpa/sign` domain so every signature produced to date still verifies. ink/0.2 messages are signed and verified under the neutral `ink/sign` domain. The verifier selects exactly one domain from the signed `protocol` field and never tries an alternate, so a signature made under one version's domain cannot be replayed under another.
package/README.md CHANGED
@@ -98,6 +98,17 @@ For consumers of bilateral audit-exchange responses (`network.tulpa.audit_respon
98
98
 
99
99
  For consumers of witness audit-query responses (`network.tulpa.audit_query_response`, Auditability §7.3, added in `0.1.0-alpha.3`), call `verifyAuditQueryResponse({response, witnessPublicKey, expectedRequester, expectedMessageId, verifyEventSignature, expectedServiceDid?, laterCheckpoint?})`. The `verifyEventSignature` callback is REQUIRED: it resolves the submitting agent's Ed25519 keys (typically via Agent Card §2) and validates each event's `agentSignature`. Without it, the verifier refuses to return valid, because Merkle inclusion alone does not prove a real agent produced the event (§7.5). The verifier enforces envelope shape, the `requester` binding (prevents cross-requester replay), events/proofs strict one-to-one alignment, the §7.4 per-event scope rule, walks every Merkle proof via `computeAuditMerkleLeafHash` up to the response's `rootHash`, runs `verifyEventSignature` on every event and supports an optional later-checkpoint cross-check. The lower-level `verifyAuditQueryResponseSignature` is signature-only and is not sufficient to accept a witness response on its own.
100
100
 
101
+ Verification helpers added in `0.4.0`:
102
+
103
+ - `verifyCheckpoint(signed, witnessPublicKey, expectedOrigin)` verifies a signed C2SP checkpoint's witness Ed25519 signature and binds its log origin, returning the parsed `{origin, treeSize, rootHash}` or `null`. Any checkpoint passed to `verifyInclusionReceipt`'s `laterCheckpoint` cross-check must be verified this way first; an unverified checkpoint body is attacker-controllable and provides no anti-rollback value.
104
+ - `verifyReceipt({receipt, senderPublicKey, expected})` verifies a delivery receipt against the message it acknowledges: the issuer's signature plus `from`/`to`/`messageId`, the recomputed message hash, and an optional `disposition`. It returns `{valid, reason?}`.
105
+ - `verifyInclusionReceipt` accepts an `event` option that recomputes the leaf hash and binds `event.id` to `receipt.eventId`, so the proof attests the named event's inclusion. Prefer it over the legacy unbound `eventHash`.
106
+ - `verifyInkAuth` returns a prefix-independent `principal` alongside the raw `senderAgentId`. Per-sender security state (block lists, rate limits) MUST key on `principal`, because the `tulpa:` and `ink:` spellings of one key are the same actor; `canonicalAgentPrincipal(agentId)` exposes the same mapping.
107
+
108
+ Added in `0.5.0`:
109
+
110
+ - `verifyConsistencyProof(first, firstRoot, second, secondRoot, proof)` verifies an RFC 6962 consistency proof that the tree of `first` leaves is an append-only prefix of the tree of `second` leaves, so a witness that forks its history rather than only appending is detected. The witness serves these proofs at `GET /ink/v1/consistency?first=N&second=M`, and the `verify-inclusion` CLI checks one against the current checkpoint when `--origin` is passed.
111
+
101
112
  ## Agent-assisted implementation
102
113
 
103
114
  If you are asking an AI coding agent to add INK support to an existing service, the canonical packet for that workflow is the [Agent-assisted implementation](https://ink.tulpa.network/guides/agent-assisted-implementation/) guide. It contains the curated implementer prompt, a mandatory traceability matrix, the conformance checklist, and a human-review checklist. The guide is updated as the protocol evolves; treat it as the live source rather than copying its contents into your repo.
@@ -152,9 +163,11 @@ Subject to change before v1.0:
152
163
 
153
164
  You will see `network.tulpa.*` on the wire (e.g. `network.tulpa.intent`) and `ink.tulpa.network` for the docs site. Both are historical artifacts of the protocol's origin and do not imply a runtime dependency on Tulpa. A vendor-neutral namespace may be introduced in a future revision.
154
165
 
166
+ As a first, non-breaking step in that direction, agentIds may use either the canonical `tulpa:` method prefix or the `ink:` alias; both encode the same Ed25519 key and denote the same actor. `deriveAgentId` still emits `tulpa:`, and `extractPublicKeyFromAgentId` accepts both (accept-both, emit-one). A receiver MUST collapse the two spellings to one prefix-independent principal for all per-sender security state (blocks, rate limits, duplicate-payload checks, cached keys, connection identity).
167
+
155
168
  ## Relationship to Tulpa
156
169
 
157
- INK is developed by [Ad Astra Computing](https://adastracomputing.com) as the underlying protocol for [Tulpa](https://tulpa.network). The spec and the library in this repo are deliberately free of Tulpa product code so other agent platforms can adopt INK without inheriting Tulpa's surface area. Tulpa's product integration (message orchestration, marketplace, user-facing APIs) lives in a separate, closed-source codebase.
170
+ INK is developed by [Ad Astra Computing](https://adastracomputing.com) as the underlying protocol for [Tulpa](https://tulpa.network). The spec and the library in this repo are deliberately free of Tulpa product code so other agent platforms can adopt INK without inheriting Tulpa's surface area. Tulpa's product integration (message orchestration, marketplace, user-facing APIs) lives in a separate codebase.
158
171
 
159
172
  ## Interoperability
160
173
 
@@ -38,6 +38,7 @@ function parseArgs(argv) {
38
38
  const a = argv[i];
39
39
  if (a === "--file" || a === "-f") out.file = argv[++i];
40
40
  else if (a === "--witness" || a === "-w") out.witness = argv[++i];
41
+ else if (a === "--origin") out.origin = argv[++i];
41
42
  else if (a === "--event-hash" || a === "-e") out.eventHash = argv[++i];
42
43
  else if (a === "--allow-http") out.allowHttp = true;
43
44
  else if (a === "--help" || a === "-h") out.help = true;
@@ -75,6 +76,17 @@ Usage:
75
76
 
76
77
  Options:
77
78
  -w, --witness <url> Witness base URL (e.g. https://witness.tulpa.network)
79
+ --origin <name> Optional. Expected checkpoint origin (log identity)
80
+ to bind the signed checkpoint to. Enables the
81
+ checkpoint cross-check: the current checkpoint's
82
+ signature is verified against the witness key, and
83
+ when it is newer than the receipt and the witness
84
+ serves an RFC 6962 consistency proof, that proof is
85
+ verified so a forked history is caught. If the witness
86
+ serves no proof the consistency step is reported as
87
+ skipped, not passed. When --origin is omitted the
88
+ cross-check is skipped rather than trusting an
89
+ unverified checkpoint body.
78
90
  -f, --file <path> Receipt JSON file. Omit to read from stdin.
79
91
  -e, --event-hash <hex> Optional. RFC 6962 leaf hash for the audit event:
80
92
  SHA-256(0x00 || JCS(event-without-agentSignature)),
@@ -175,6 +187,47 @@ async function recomputeRoot(currentHash, proof, proofIdx, leafIndex, start, siz
175
187
  return hashPair(proof[proofIdx], rightResult);
176
188
  }
177
189
 
190
+ const EMPTY_TREE_ROOT = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
191
+
192
+ /**
193
+ * Verify an RFC 6962 Section 2.1.2 consistency proof (the first-sized tree is a
194
+ * prefix of the second). Mirrors verifyConsistencyProof() in
195
+ * src/audit/inclusion-receipt.ts — keep them in sync.
196
+ */
197
+ async function verifyConsistencyProofCli(first, firstRoot, second, secondRoot, proof) {
198
+ const isHash = (s) => typeof s === "string" && /^[0-9a-f]{64}$/.test(s);
199
+ if (!Number.isSafeInteger(first) || !Number.isSafeInteger(second)) return false;
200
+ if (first < 0 || second < 0) return false;
201
+ if (!isHash(firstRoot) || !isHash(secondRoot)) return false;
202
+ if (!Array.isArray(proof) || !proof.every(isHash) || proof.length > 64) return false;
203
+ if (first > second) return false;
204
+ if (first === second) return proof.length === 0 && firstRoot === secondRoot;
205
+ if (first === 0) return proof.length === 0 && firstRoot === EMPTY_TREE_ROOT;
206
+
207
+ let node = first - 1;
208
+ let last = second - 1;
209
+ while (node % 2 === 1) { node = Math.floor(node / 2); last = Math.floor(last / 2); }
210
+ let i = 0;
211
+ const take = () => (i < proof.length ? proof[i++] : null);
212
+ let oldHash;
213
+ if (node > 0) { const h = take(); if (h === null) return false; oldHash = h; } else { oldHash = firstRoot; }
214
+ let newHash = oldHash;
215
+ while (node > 0) {
216
+ if (node % 2 === 1) {
217
+ const h = take(); if (h === null) return false;
218
+ oldHash = await hashPair(h, oldHash);
219
+ newHash = await hashPair(h, newHash);
220
+ } else if (node < last) {
221
+ const h = take(); if (h === null) return false;
222
+ newHash = await hashPair(newHash, h);
223
+ }
224
+ node = Math.floor(node / 2); last = Math.floor(last / 2);
225
+ }
226
+ while (last > 0) { const h = take(); if (h === null) return false; newHash = await hashPair(newHash, h); last = Math.floor(last / 2); }
227
+ if (i !== proof.length) return false;
228
+ return oldHash === firstRoot && newHash === secondRoot;
229
+ }
230
+
178
231
  // ── core verifier ──
179
232
 
180
233
  const MAX_PROOF_LENGTH = 64;
@@ -226,7 +279,8 @@ async function verifyReceipt(receipt, witnessPublicKey, eventHash, laterCheckpoi
226
279
  let sigValid = false;
227
280
  try {
228
281
  const sig = base64urlDecode(receipt.serviceSignature);
229
- sigValid = await ed.verifyAsync(sig, new TextEncoder().encode(sigBase), witnessPublicKey);
282
+ // RFC 8032 strict verification, matching the library (reject small-order keys).
283
+ sigValid = await ed.verifyAsync(sig, new TextEncoder().encode(sigBase), witnessPublicKey, { zip215: false });
230
284
  } catch (e) {
231
285
  steps.push({ name: "signature", pass: false, detail: e instanceof Error ? e.message : "signature decode failed" });
232
286
  return { valid: false, steps };
@@ -343,28 +397,59 @@ async function fetchWitnessPublicKey(witnessUrl) {
343
397
  }
344
398
 
345
399
  /**
346
- * Parse a C2SP tlog-checkpoint response. The body has three header
347
- * lines (origin, treeSize, rootHash), each terminated by \n, then a
348
- * blank line, then a signature line. We don't verify the signature
349
- * here (it'd require pre-fetching the witness key, which the caller
350
- * already does for the receipt). Just extract the header fields with
351
- * strict regexes so a malformed checkpoint can't fake-pass.
400
+ * Verify a signed C2SP tlog-checkpoint and return { treeSize, rootHash,
401
+ * origin }, or null if the signature, origin, or format is invalid. The
402
+ * Ed25519 signature covers the body bytes `<origin>\n<treeSize>\n<rootHash>`
403
+ * (no trailing newline). The anti-rollback cross-check below only means
404
+ * anything against a checkpoint whose signature we have verified against the
405
+ * witness key, so this MUST verify, not just parse.
406
+ *
407
+ * Mirrors verifyCheckpoint() in src/ink/checkpoint.ts — keep them in sync.
352
408
  */
353
- function parseCheckpointBody(body) {
354
- const sepIdx = body.indexOf("\n\n");
355
- if (sepIdx < 0) return null;
356
- const header = body.slice(0, sepIdx);
357
- const lines = header.split("\n");
409
+ async function verifyCheckpointBody(signed, witnessPublicKey, expectedOrigin) {
410
+ if (typeof signed !== "string" || signed.length === 0 || signed.length > 4096) return null;
411
+ const SEP = "\n\n-- ";
412
+ const idx = signed.indexOf(SEP);
413
+ if (idx < 0) return null;
414
+ const body = signed.slice(0, idx);
415
+ const lines = body.split("\n");
358
416
  if (lines.length !== 3) return null;
359
- if (!lines[0]) return null;
360
- if (!/^\d+$/.test(lines[1])) return null;
361
- const treeSize = parseInt(lines[1], 10);
417
+ const [origin, sizeLine, rootHash] = lines;
418
+ if (!origin || origin.length > 256) return null;
419
+ if (!/^\d+$/.test(sizeLine)) return null;
420
+ const treeSize = parseInt(sizeLine, 10);
362
421
  if (!Number.isInteger(treeSize) || treeSize < 0 || treeSize > Number.MAX_SAFE_INTEGER) return null;
363
- if (!/^[0-9a-f]{64}$/.test(lines[2])) return null;
364
- return { treeSize, rootHash: lines[2] };
422
+ if (!/^[0-9a-f]{64}$/.test(rootHash)) return null;
423
+ // The expected origin must be supplied by the caller (a trusted value), not
424
+ // taken from the checkpoint body, so a witness key that signs several origins
425
+ // cannot substitute a checkpoint for a different log than the receipt's.
426
+ if (typeof expectedOrigin !== "string" || expectedOrigin.length === 0) return null;
427
+ if (origin !== expectedOrigin) return null;
428
+ const sigLines = signed.slice(idx + 2).split("\n").filter((l) => l.length > 0);
429
+ if (sigLines.length === 0 || sigLines.length > 8) return null;
430
+ const bodyBytes = new TextEncoder().encode(body);
431
+ for (const line of sigLines) {
432
+ if (!line.startsWith("-- ")) return null;
433
+ const rest = line.slice(3);
434
+ const sp = rest.indexOf(" ");
435
+ if (sp < 0) return null;
436
+ if (rest.slice(0, sp) !== expectedOrigin) continue;
437
+ try {
438
+ const sig = base64urlDecode(rest.slice(sp + 1));
439
+ if (sig.length !== 64) return null;
440
+ const ok = await ed.verifyAsync(sig, bodyBytes, witnessPublicKey, { zip215: false });
441
+ return ok ? { treeSize, rootHash, origin } : null;
442
+ } catch {
443
+ return null;
444
+ }
445
+ }
446
+ return null;
365
447
  }
366
448
 
367
- async function fetchCurrentCheckpoint(witnessUrl) {
449
+ async function fetchCurrentCheckpoint(witnessUrl, witnessPublicKey, expectedOrigin) {
450
+ // No trusted origin, no cross-check: refuse to trust the checkpoint body's
451
+ // self-asserted origin. Pass --origin <witness-origin> to enable it.
452
+ if (typeof expectedOrigin !== "string" || expectedOrigin.length === 0) return null;
368
453
  const url = `${witnessUrl.replace(/\/$/, "")}/ink/v1/checkpoint`;
369
454
  let body;
370
455
  try {
@@ -374,7 +459,27 @@ async function fetchCurrentCheckpoint(witnessUrl) {
374
459
  // 'not available' rather than crashing the verifier.
375
460
  return null;
376
461
  }
377
- return parseCheckpointBody(body);
462
+ return verifyCheckpointBody(body, witnessPublicKey, expectedOrigin);
463
+ }
464
+
465
+ async function fetchConsistencyProof(witnessUrl, first, second) {
466
+ const url = `${witnessUrl.replace(/\/$/, "")}/ink/v1/consistency?first=${first}&second=${second}`;
467
+ let body;
468
+ try {
469
+ body = await fetchBounded(url);
470
+ } catch {
471
+ // A witness that does not serve consistency proofs downgrades the check to
472
+ // 'not available' rather than failing the receipt.
473
+ return null;
474
+ }
475
+ let parsed;
476
+ try {
477
+ parsed = JSON.parse(body);
478
+ } catch {
479
+ return null;
480
+ }
481
+ if (!parsed || !Array.isArray(parsed.proof)) return null;
482
+ return parsed.proof;
378
483
  }
379
484
 
380
485
  async function readStdin() {
@@ -451,20 +556,63 @@ async function main() {
451
556
  process.exit(2);
452
557
  }
453
558
 
454
- const laterCheckpoint = await fetchCurrentCheckpoint(witnessBase);
559
+ // The checkpoint cross-check only carries weight against a checkpoint whose
560
+ // Ed25519 signature we have verified against the witness key. An unverified
561
+ // checkpoint (bad/absent signature, or origin mismatch) is dropped so the
562
+ // cross-check is skipped rather than trusting attacker-controlled values.
563
+ const laterCheckpoint = await fetchCurrentCheckpoint(witnessBase, witnessPublicKey, args.origin);
455
564
 
456
565
  const result = await verifyReceipt(receipt, witnessPublicKey, args.eventHash, laterCheckpoint ?? undefined);
457
566
 
567
+ // Append-only proof: when the signature-verified checkpoint is strictly newer
568
+ // than the receipt's tree, fetch and verify an RFC 6962 consistency proof, so
569
+ // a witness that forked its history between the two snapshots is caught. The
570
+ // checkpoint size comparison alone cannot detect a same-prefix fork.
571
+ if (laterCheckpoint && Number.isInteger(receipt?.treeSize) && laterCheckpoint.treeSize > receipt.treeSize) {
572
+ const proof = await fetchConsistencyProof(witnessBase, receipt.treeSize, laterCheckpoint.treeSize);
573
+ if (proof === null) {
574
+ // Honest downgrade: the witness did not serve a usable consistency proof.
575
+ // Mark it skipped (NOT passed) so a witness that forked cannot look clean
576
+ // by withholding the endpoint; the checkpoint signature, rewind, and
577
+ // same-size fork checks still ran.
578
+ result.steps.push({
579
+ name: "consistency",
580
+ skip: true,
581
+ detail: "not checked (witness did not serve a consistency proof); append-only growth was not verified",
582
+ });
583
+ } else {
584
+ const consistent = await verifyConsistencyProofCli(
585
+ receipt.treeSize, receipt.rootHash, laterCheckpoint.treeSize, laterCheckpoint.rootHash, proof,
586
+ );
587
+ if (consistent) {
588
+ result.steps.push({
589
+ name: "consistency",
590
+ pass: true,
591
+ detail: `receipt tree (size ${receipt.treeSize}) is an append-only prefix of the checkpoint (size ${laterCheckpoint.treeSize})`,
592
+ });
593
+ } else {
594
+ result.steps.push({
595
+ name: "consistency",
596
+ pass: false,
597
+ detail: `receipt tree (size ${receipt.treeSize}) is NOT an append-only prefix of the checkpoint (size ${laterCheckpoint.treeSize}); the witness forked its history`,
598
+ });
599
+ result.valid = false;
600
+ }
601
+ }
602
+ }
603
+
458
604
  console.log(`Receipt: eventId=${receipt?.eventId} leafIndex=${receipt?.leafIndex} treeSize=${receipt?.treeSize}`);
459
605
  console.log(`Witness: ${witnessBase}`);
460
606
  if (laterCheckpoint) {
461
- console.log(`Current checkpoint: treeSize=${laterCheckpoint.treeSize} rootHash=${laterCheckpoint.rootHash}`);
607
+ console.log(`Current checkpoint (signature verified): treeSize=${laterCheckpoint.treeSize} rootHash=${laterCheckpoint.rootHash}`);
608
+ } else if (!args.origin) {
609
+ console.log("Current checkpoint: cross-check skipped (pass --origin <witness-origin> to enable the anti-rollback check)");
462
610
  } else {
463
- console.log("Current checkpoint: not available (skipping checkpoint cross-check)");
611
+ console.log("Current checkpoint: not available or signature unverified (skipping checkpoint cross-check)");
464
612
  }
465
613
  console.log("");
466
614
  for (const step of result.steps) {
467
- const mark = step.pass ? "PASS" : "FAIL";
615
+ const mark = step.skip ? "SKIP" : step.pass ? "PASS" : "FAIL";
468
616
  console.log(` [${mark}] ${step.name}${step.detail ? ": " + step.detail : ""}`);
469
617
  }
470
618
  console.log("");
@@ -26,21 +26,31 @@ export interface InclusionReceiptVerifyResult {
26
26
  * - Service signature verification against `witnessPublicKey`
27
27
  *
28
28
  * Optionally performs (when the corresponding input is provided):
29
- * - Leaf-to-root proof walk (`eventHash`)
29
+ * - Leaf-to-root proof walk: pass `event` (recommended — recomputes the leaf
30
+ * hash and binds it to `receipt.eventId`) or `eventHash` (legacy, unbound)
30
31
  * - Cross-check against a later signed checkpoint (`laterCheckpoint`)
31
32
  */
32
33
  export declare function verifyInclusionReceipt(opts: {
33
34
  receipt: InclusionReceipt;
34
35
  /** Raw 32-byte Ed25519 public key of the witness service. */
35
36
  witnessPublicKey: Uint8Array;
36
- /** Optional RFC 6962 leaf hash for the underlying audit event:
37
- * SHA-256(0x00 || JCS(event-without-agentSignature)), hex-encoded.
38
- * Use `computeAuditMerkleLeafHash` to derive it. When provided, the
39
- * inclusion proof is walked from this leaf up to the claimed rootHash. */
37
+ /** Optional audit event the receipt claims inclusion for. This is the
38
+ * RECOMMENDED way to verify the proof: the leaf hash is recomputed from the
39
+ * event with `computeAuditMerkleLeafHash`, and `event.id` is bound to
40
+ * `receipt.eventId`, so the proof attests that the event named by the
41
+ * receipt is in the tree — not merely that some caller-chosen hash is. */
42
+ event?: Record<string, unknown>;
43
+ /** Optional pre-computed RFC 6962 leaf hash (hex). LEGACY / lower-assurance:
44
+ * unlike `event`, a bare hash is NOT bound to `receipt.eventId`, so the proof
45
+ * only attests "this hash is in the tree", not "the event the receipt names
46
+ * is in the tree". Prefer `event`. Ignored when `event` is provided. */
40
47
  eventHash?: string;
41
- /** Optional later checkpoint to cross-check the receipt against.
42
- * Must come from a `/ink/v1/checkpoint` response that the verifier
43
- * has separately validated as authentic. */
48
+ /** Optional later checkpoint to cross-check the receipt against. This MUST be
49
+ * the parsed body of a checkpoint whose Ed25519 signature and origin the
50
+ * caller has already verified with `verifyCheckpoint` against the witness
51
+ * key. Passing an unverified (merely parsed) checkpoint gives the
52
+ * anti-rollback / fork cross-check no security, because the treeSize and
53
+ * rootHash would then be attacker-controllable. */
44
54
  laterCheckpoint?: {
45
55
  treeSize: number;
46
56
  rootHash: string;
@@ -140,3 +150,18 @@ export declare function verifyAuditQueryResponse(opts: {
140
150
  * audit), they MUST explicitly pass a callback that does so. */
141
151
  verifyEventSignature: (event: Record<string, unknown>) => Promise<boolean>;
142
152
  }): Promise<AuditQueryResponseVerifyResult>;
153
+ /**
154
+ * Verify an RFC 6962 Section 2.1.2 consistency proof: that the Merkle tree of
155
+ * `first` leaves with root `firstRoot` is a prefix of the tree of `second`
156
+ * leaves with root `secondRoot`. A valid proof is proof the log only ever
157
+ * appended; it is what detects a witness that forks its history (split view)
158
+ * rather than merely growing, which the `second >= first` size comparison
159
+ * alone cannot.
160
+ *
161
+ * `firstRoot`, `secondRoot` and every proof entry are 64 lowercase hex chars
162
+ * (SHA-256). Internal nodes are hashed as SHA-256(0x01 || left || right), the
163
+ * same construction the inclusion verifier uses, so the two agree on tree
164
+ * shape. Returns false (never throws) for any malformed input or any proof that
165
+ * does not reconstruct both roots with every element consumed.
166
+ */
167
+ export declare function verifyConsistencyProof(first: number, firstRoot: string, second: number, secondRoot: string, proof: string[]): Promise<boolean>;
@@ -30,12 +30,13 @@ import { base64urlDecode, jcsCanonicalize, hexToBytes, bytesToHex, computeAuditM
30
30
  * - Service signature verification against `witnessPublicKey`
31
31
  *
32
32
  * Optionally performs (when the corresponding input is provided):
33
- * - Leaf-to-root proof walk (`eventHash`)
33
+ * - Leaf-to-root proof walk: pass `event` (recommended — recomputes the leaf
34
+ * hash and binds it to `receipt.eventId`) or `eventHash` (legacy, unbound)
34
35
  * - Cross-check against a later signed checkpoint (`laterCheckpoint`)
35
36
  */
36
37
  export async function verifyInclusionReceipt(opts) {
37
38
  const steps = [];
38
- const { receipt, witnessPublicKey, eventHash, laterCheckpoint } = opts;
39
+ const { receipt, witnessPublicKey, event, eventHash, laterCheckpoint } = opts;
39
40
  // ── Step 1: structural validation ──
40
41
  const structuralProblem = checkReceiptShape(receipt);
41
42
  if (structuralProblem) {
@@ -55,7 +56,7 @@ export async function verifyInclusionReceipt(opts) {
55
56
  let sigValid = false;
56
57
  try {
57
58
  const sig = base64urlDecode(receipt.serviceSignature);
58
- sigValid = await ed.verifyAsync(sig, new TextEncoder().encode(sigBase), witnessPublicKey);
59
+ sigValid = await ed.verifyAsync(sig, new TextEncoder().encode(sigBase), witnessPublicKey, { zip215: false });
59
60
  }
60
61
  catch (e) {
61
62
  steps.push({
@@ -71,12 +72,35 @@ export async function verifyInclusionReceipt(opts) {
71
72
  }
72
73
  steps.push({ name: "signature", pass: true });
73
74
  // ── Step 3: inclusion-proof walk (optional) ──
74
- if (eventHash !== undefined) {
75
+ // Prefer the `event` path: recompute the leaf hash from the event and bind it
76
+ // to receipt.eventId, so the proof attests the named event's inclusion.
77
+ let leafHash;
78
+ if (event !== undefined) {
79
+ if (typeof event.id !== "string") {
80
+ steps.push({ name: "proof", pass: false, detail: "event.id is missing or not a string" });
81
+ return { valid: false, steps };
82
+ }
83
+ if (event.id !== receipt.eventId) {
84
+ steps.push({ name: "proof", pass: false, detail: "event.id does not match receipt.eventId" });
85
+ return { valid: false, steps };
86
+ }
87
+ try {
88
+ leafHash = await computeAuditMerkleLeafHash(event);
89
+ }
90
+ catch {
91
+ steps.push({ name: "proof", pass: false, detail: "could not compute leaf hash from event" });
92
+ return { valid: false, steps };
93
+ }
94
+ }
95
+ else if (eventHash !== undefined) {
75
96
  if (!/^[0-9a-f]{64}$/.test(eventHash)) {
76
97
  steps.push({ name: "proof", pass: false, detail: "eventHash must be 64 lowercase hex chars" });
77
98
  return { valid: false, steps };
78
99
  }
79
- const verified = await verifyInclusionProof(eventHash, receipt.inclusionProof, receipt.leafIndex, receipt.treeSize, receipt.rootHash);
100
+ leafHash = eventHash;
101
+ }
102
+ if (leafHash !== undefined) {
103
+ const verified = await verifyInclusionProof(leafHash, receipt.inclusionProof, receipt.leafIndex, receipt.treeSize, receipt.rootHash);
80
104
  if (!verified) {
81
105
  steps.push({ name: "proof", pass: false, detail: "leaf-to-root walk did not reach claimed rootHash" });
82
106
  return { valid: false, steps };
@@ -494,3 +518,99 @@ async function verifyInclusionProof(leafHash, proof, leafIndex, treeSize, expect
494
518
  return false;
495
519
  }
496
520
  }
521
+ function isMerkleHashHex(s) {
522
+ return typeof s === "string" && /^[0-9a-f]{64}$/.test(s);
523
+ }
524
+ /**
525
+ * Verify an RFC 6962 Section 2.1.2 consistency proof: that the Merkle tree of
526
+ * `first` leaves with root `firstRoot` is a prefix of the tree of `second`
527
+ * leaves with root `secondRoot`. A valid proof is proof the log only ever
528
+ * appended; it is what detects a witness that forks its history (split view)
529
+ * rather than merely growing, which the `second >= first` size comparison
530
+ * alone cannot.
531
+ *
532
+ * `firstRoot`, `secondRoot` and every proof entry are 64 lowercase hex chars
533
+ * (SHA-256). Internal nodes are hashed as SHA-256(0x01 || left || right), the
534
+ * same construction the inclusion verifier uses, so the two agree on tree
535
+ * shape. Returns false (never throws) for any malformed input or any proof that
536
+ * does not reconstruct both roots with every element consumed.
537
+ */
538
+ export async function verifyConsistencyProof(first, firstRoot, second, secondRoot, proof) {
539
+ if (!Number.isSafeInteger(first) || !Number.isSafeInteger(second))
540
+ return false;
541
+ if (first < 0 || second < 0)
542
+ return false;
543
+ if (!isMerkleHashHex(firstRoot) || !isMerkleHashHex(secondRoot))
544
+ return false;
545
+ if (!Array.isArray(proof) || !proof.every(isMerkleHashHex))
546
+ return false;
547
+ // A consistency proof for a tree of `second` safe-integer leaves has at most
548
+ // ceil(log2(second)) + 1 nodes, comfortably under 64. Cap it so a hostile
549
+ // caller cannot force unbounded hashing work.
550
+ if (proof.length > 64)
551
+ return false;
552
+ if (first > second)
553
+ return false;
554
+ // Same size: the roots must match and there is nothing to prove.
555
+ if (first === second)
556
+ return proof.length === 0 && firstRoot === secondRoot;
557
+ // The empty tree is a prefix of every tree; its root is fixed and the proof
558
+ // carries no nodes.
559
+ if (first === 0)
560
+ return proof.length === 0 && firstRoot === EMPTY_TREE_ROOT;
561
+ // 0 < first < second. Walk the path from leaf `first - 1` up to the roots,
562
+ // shifting `node`/`last` together. `node` indexes the first tree's rightmost
563
+ // leaf, `last` the second tree's.
564
+ let node = first - 1;
565
+ let last = second - 1;
566
+ while (node % 2 === 1) {
567
+ node = Math.floor(node / 2);
568
+ last = Math.floor(last / 2);
569
+ }
570
+ let i = 0;
571
+ const take = () => (i < proof.length ? proof[i++] : null);
572
+ // When `first` is an exact power of two, `node` has shifted to 0 and the old
573
+ // subtree hash is `firstRoot` itself; otherwise it is the first proof node.
574
+ let oldHash;
575
+ if (node > 0) {
576
+ const h = take();
577
+ if (h === null)
578
+ return false;
579
+ oldHash = h;
580
+ }
581
+ else {
582
+ oldHash = firstRoot;
583
+ }
584
+ let newHash = oldHash;
585
+ while (node > 0) {
586
+ if (node % 2 === 1) {
587
+ // Right child: the sibling on the left is shared by both trees.
588
+ const h = take();
589
+ if (h === null)
590
+ return false;
591
+ oldHash = await hashPair(h, oldHash);
592
+ newHash = await hashPair(h, newHash);
593
+ }
594
+ else if (node < last) {
595
+ // Left child with a right sibling that exists only in the second tree.
596
+ const h = take();
597
+ if (h === null)
598
+ return false;
599
+ newHash = await hashPair(newHash, h);
600
+ }
601
+ node = Math.floor(node / 2);
602
+ last = Math.floor(last / 2);
603
+ }
604
+ // Remaining nodes extend only the second tree to its root.
605
+ while (last > 0) {
606
+ const h = take();
607
+ if (h === null)
608
+ return false;
609
+ newHash = await hashPair(newHash, h);
610
+ last = Math.floor(last / 2);
611
+ }
612
+ // Every proof element must be consumed, and both reconstructions must match.
613
+ if (i !== proof.length)
614
+ return false;
615
+ return oldHash === firstRoot && newHash === secondRoot;
616
+ }
@@ -1,6 +1,7 @@
1
1
  import * as ed from "@noble/ed25519";
2
2
  import { x25519 } from "@noble/curves/ed25519.js";
3
3
  import canonicalize from "canonicalize";
4
+ import { isJcsSafeNumber } from "./sign.js";
4
5
  // ── Encoding helpers ──
5
6
  const MAX_ENCODE_INPUT_BYTES = 2_000_000;
6
7
  function base64urlEncode(bytes) {
@@ -137,6 +138,11 @@ function isWithinCanonicalizeBounds(value) {
137
138
  if (chars > MAX_PRECHECK_CHARS)
138
139
  return false;
139
140
  }
141
+ else if (typeof v === "number" && !isJcsSafeNumber(v)) {
142
+ // Reject numbers that don't canonicalize identically across JSON
143
+ // serializers (non-finite, -0, exponential notation). See sign.ts.
144
+ return false;
145
+ }
140
146
  return true;
141
147
  }
142
148
  if (Array.isArray(v)) {
@@ -236,7 +242,9 @@ export async function verifyInkSignature(input, signatureBase64url, publicKey) {
236
242
  const bytes = new TextEncoder().encode(sigBase);
237
243
  try {
238
244
  const sig = base64urlDecode(signatureBase64url);
239
- return await ed.verifyAsync(sig, bytes, publicKey);
245
+ // RFC 8032 strict verification (not the default ZIP-215): reject
246
+ // small-order keys and non-canonical encodings. See verifyMessage.
247
+ return await ed.verifyAsync(sig, bytes, publicKey, { zip215: false });
240
248
  }
241
249
  catch {
242
250
  return false;
@@ -629,7 +637,7 @@ export async function verifyAuditEventSignature(event, publicKey) {
629
637
  if (bytes.length > MAX_SIGBASE_BODY_BYTES)
630
638
  return false;
631
639
  const sig = base64urlDecode(signature);
632
- return await ed.verifyAsync(sig, bytes, publicKey);
640
+ return await ed.verifyAsync(sig, bytes, publicKey, { zip215: false });
633
641
  }
634
642
  catch {
635
643
  return false;
@@ -739,7 +747,7 @@ export async function verifyAuditResponseSignature(events, signature, publicKey)
739
747
  if (bytes.length > MAX_SIGBASE_BODY_BYTES)
740
748
  return false;
741
749
  const sig = base64urlDecode(signature);
742
- return await ed.verifyAsync(sig, bytes, publicKey);
750
+ return await ed.verifyAsync(sig, bytes, publicKey, { zip215: false });
743
751
  }
744
752
  catch {
745
753
  return false;
@@ -905,7 +913,7 @@ export async function verifyAuditQueryResponseSignature(responseWithoutSignature
905
913
  if (bytes.length > MAX_SIGBASE_BODY_BYTES)
906
914
  return false;
907
915
  const sig = base64urlDecode(signature);
908
- return await ed.verifyAsync(sig, bytes, publicKey);
916
+ return await ed.verifyAsync(sig, bytes, publicKey, { zip215: false });
909
917
  }
910
918
  catch {
911
919
  return false;