@adastracomputing/ink 0.4.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,29 @@ 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
+
7
30
  ## 0.4.0, stricter verification, message-size bounds, checkpoint and receipt verification
8
31
 
9
32
  This release tightens signature verification and input validation and adds
package/README.md CHANGED
@@ -105,6 +105,10 @@ Verification helpers added in `0.4.0`:
105
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
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
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
+
108
112
  ## Agent-assisted implementation
109
113
 
110
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.
@@ -77,9 +77,16 @@ Usage:
77
77
  Options:
78
78
  -w, --witness <url> Witness base URL (e.g. https://witness.tulpa.network)
79
79
  --origin <name> Optional. Expected checkpoint origin (log identity)
80
- to bind the signed checkpoint to. When omitted, the
81
- checkpoint signature is still verified against the
82
- witness key and the body/signature origins must agree.
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.
83
90
  -f, --file <path> Receipt JSON file. Omit to read from stdin.
84
91
  -e, --event-hash <hex> Optional. RFC 6962 leaf hash for the audit event:
85
92
  SHA-256(0x00 || JCS(event-without-agentSignature)),
@@ -180,6 +187,47 @@ async function recomputeRoot(currentHash, proof, proofIdx, leafIndex, start, siz
180
187
  return hashPair(proof[proofIdx], rightResult);
181
188
  }
182
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
+
183
231
  // ── core verifier ──
184
232
 
185
233
  const MAX_PROOF_LENGTH = 64;
@@ -414,6 +462,26 @@ async function fetchCurrentCheckpoint(witnessUrl, witnessPublicKey, expectedOrig
414
462
  return verifyCheckpointBody(body, witnessPublicKey, expectedOrigin);
415
463
  }
416
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;
483
+ }
484
+
417
485
  async function readStdin() {
418
486
  return new Promise((resolve, reject) => {
419
487
  let data = "";
@@ -496,6 +564,43 @@ async function main() {
496
564
 
497
565
  const result = await verifyReceipt(receipt, witnessPublicKey, args.eventHash, laterCheckpoint ?? undefined);
498
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
+
499
604
  console.log(`Receipt: eventId=${receipt?.eventId} leafIndex=${receipt?.leafIndex} treeSize=${receipt?.treeSize}`);
500
605
  console.log(`Witness: ${witnessBase}`);
501
606
  if (laterCheckpoint) {
@@ -507,7 +612,7 @@ async function main() {
507
612
  }
508
613
  console.log("");
509
614
  for (const step of result.steps) {
510
- const mark = step.pass ? "PASS" : "FAIL";
615
+ const mark = step.skip ? "SKIP" : step.pass ? "PASS" : "FAIL";
511
616
  console.log(` [${mark}] ${step.name}${step.detail ? ": " + step.detail : ""}`);
512
617
  }
513
618
  console.log("");
@@ -150,3 +150,18 @@ export declare function verifyAuditQueryResponse(opts: {
150
150
  * audit), they MUST explicitly pass a callback that does so. */
151
151
  verifyEventSignature: (event: Record<string, unknown>) => Promise<boolean>;
152
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>;
@@ -518,3 +518,99 @@ async function verifyInclusionProof(leafHash, proof, leafIndex, treeSize, expect
518
518
  return false;
519
519
  }
520
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
+ }
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export { verifyInkSignatureWithKeys } from "./crypto/multi-key-verify.js";
4
4
  export { generateKeypair, generateEncryptionKeypair, deriveAgentId, encodePublicKeyMultibase, encodeEncryptionKeyMultibase, decodePublicKeyMultibase, decodeEncryptionKeyMultibase, extractPublicKeyFromAgentId, canonicalAgentPrincipal, AGENT_ID_KEY_PREFIXES, } from "./crypto/keys.js";
5
5
  export { fetchAgentCard, extractCandidateKeys, resolveBaseUrl, } from "./discovery/agent-card.js";
6
6
  export { verifyInkAuth, type NonceStore } from "./middleware/ink-auth.js";
7
- export { verifyInclusionReceipt, verifyAuditQueryResponse, type InclusionReceipt, type InclusionReceiptVerifyResult, type AuditQueryResponse, type AuditQueryResponseVerifyResult, type VerifyStep, } from "./audit/inclusion-receipt.js";
7
+ export { verifyInclusionReceipt, verifyConsistencyProof, verifyAuditQueryResponse, type InclusionReceipt, type InclusionReceiptVerifyResult, type AuditQueryResponse, type AuditQueryResponseVerifyResult, type VerifyStep, } from "./audit/inclusion-receipt.js";
8
8
  export { HandshakeBudgetTracker } from "./ink/handshake-budget.js";
9
9
  export { buildReceipt, verifyReceipt, shouldSendReceipt, sendReceiptFireAndForget, } from "./ink/receipts.js";
10
10
  export { resolveEffectiveTransports, checkTransportAllowed, } from "./ink/transport-auth.js";
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ export { fetchAgentCard, extractCandidateKeys, resolveBaseUrl, } from "./discove
10
10
  // Middleware: transport-level INK auth
11
11
  export { verifyInkAuth } from "./middleware/ink-auth.js";
12
12
  // Audit: inclusion-receipt + audit-query-response verification
13
- export { verifyInclusionReceipt, verifyAuditQueryResponse, } from "./audit/inclusion-receipt.js";
13
+ export { verifyInclusionReceipt, verifyConsistencyProof, verifyAuditQueryResponse, } from "./audit/inclusion-receipt.js";
14
14
  // Optional containment / governance primitives
15
15
  export { HandshakeBudgetTracker } from "./ink/handshake-budget.js";
16
16
  // Receipts: build, verify, and send INK delivery receipts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adastracomputing/ink",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Library and specification for the INK (Inter-agent Networking Kernel) protocol",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "author": "Ad Astra Computing Inc.",