@adastracomputing/ink 0.3.0 → 0.4.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,58 @@ 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.4.0, stricter verification, message-size bounds, checkpoint and receipt verification
8
+
9
+ This release tightens signature verification and input validation and adds
10
+ several verification helpers. It is published on the `next` dist-tag.
11
+
12
+ ### Potentially breaking validation tightenings
13
+
14
+ These reject inputs that `0.3.0` accepted. Legitimate signer and receiver
15
+ traffic is unaffected; the rejected inputs are malformed, malicious, or outside
16
+ the documented profile.
17
+
18
+ - Ed25519 signatures are now verified in strict RFC 8032 mode at every
19
+ verification site. Small-order public keys and non-canonical point encodings
20
+ are rejected.
21
+ - Signed JSON numbers are constrained to the forms every canonicalizer
22
+ serializes identically: non-finite values, negative zero, and values whose
23
+ shortest form uses exponential notation are rejected at signing and
24
+ verification.
25
+ - The agent card, audit, handshake, and discovery schemas now enforce maximum
26
+ field lengths and array sizes.
27
+ - The `Authorization: INK-Ed25519` header is matched against single literal
28
+ spaces; a tab, carriage return, or line feed in the separator is rejected.
29
+
30
+ ### Additions
31
+
32
+ - `verifyCheckpoint(signed, witnessPublicKey, expectedOrigin)` verifies a signed
33
+ C2SP checkpoint: the witness Ed25519 signature over the checkpoint body and the
34
+ log origin. A checkpoint used for the inclusion-receipt cross-check must be
35
+ verified this way first.
36
+ - `verifyReceipt({ receipt, senderPublicKey, expected })` binds a delivery
37
+ receipt to the exact message it acknowledges: issuer key, `from`/`to`/
38
+ `messageId`, the recomputed message hash, and an optional `disposition`.
39
+ - `verifyInclusionReceipt` accepts an `event` option that recomputes the leaf
40
+ hash and binds it to `receipt.eventId`. The legacy `eventHash` is retained but
41
+ does not provide that binding.
42
+ - `verifyInkAuth` returns a prefix-independent `principal` alongside the raw
43
+ sender id; per-sender security state (blocks, rate limits) should key on
44
+ `principal`. `canonicalAgentPrincipal(agentId)` is exported for the same use.
45
+
46
+ Per the pre-1.0 policy this release publishes under the `next` dist-tag; `latest`
47
+ is unchanged.
48
+
49
+ ## 0.3.0, accept the ink: agentId alias for key extraction
50
+
51
+ `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.
52
+
53
+ 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.
54
+
55
+ 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/).
56
+
57
+ Per the pre-1.0 policy this release publishes under the `next` dist-tag.
58
+
7
59
  ## 0.2.0, version-keyed body-signature domain
8
60
 
9
61
  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,13 @@ 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
+
101
108
  ## Agent-assisted implementation
102
109
 
103
110
  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 +159,11 @@ Subject to change before v1.0:
152
159
 
153
160
  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
161
 
162
+ 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).
163
+
155
164
  ## Relationship to Tulpa
156
165
 
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.
166
+ 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
167
 
159
168
  ## Interoperability
160
169
 
@@ -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,10 @@ 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. When omitted, the
81
+ checkpoint signature is still verified against the
82
+ witness key and the body/signature origins must agree.
78
83
  -f, --file <path> Receipt JSON file. Omit to read from stdin.
79
84
  -e, --event-hash <hex> Optional. RFC 6962 leaf hash for the audit event:
80
85
  SHA-256(0x00 || JCS(event-without-agentSignature)),
@@ -226,7 +231,8 @@ async function verifyReceipt(receipt, witnessPublicKey, eventHash, laterCheckpoi
226
231
  let sigValid = false;
227
232
  try {
228
233
  const sig = base64urlDecode(receipt.serviceSignature);
229
- sigValid = await ed.verifyAsync(sig, new TextEncoder().encode(sigBase), witnessPublicKey);
234
+ // RFC 8032 strict verification, matching the library (reject small-order keys).
235
+ sigValid = await ed.verifyAsync(sig, new TextEncoder().encode(sigBase), witnessPublicKey, { zip215: false });
230
236
  } catch (e) {
231
237
  steps.push({ name: "signature", pass: false, detail: e instanceof Error ? e.message : "signature decode failed" });
232
238
  return { valid: false, steps };
@@ -343,28 +349,59 @@ async function fetchWitnessPublicKey(witnessUrl) {
343
349
  }
344
350
 
345
351
  /**
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.
352
+ * Verify a signed C2SP tlog-checkpoint and return { treeSize, rootHash,
353
+ * origin }, or null if the signature, origin, or format is invalid. The
354
+ * Ed25519 signature covers the body bytes `<origin>\n<treeSize>\n<rootHash>`
355
+ * (no trailing newline). The anti-rollback cross-check below only means
356
+ * anything against a checkpoint whose signature we have verified against the
357
+ * witness key, so this MUST verify, not just parse.
358
+ *
359
+ * Mirrors verifyCheckpoint() in src/ink/checkpoint.ts — keep them in sync.
352
360
  */
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");
361
+ async function verifyCheckpointBody(signed, witnessPublicKey, expectedOrigin) {
362
+ if (typeof signed !== "string" || signed.length === 0 || signed.length > 4096) return null;
363
+ const SEP = "\n\n-- ";
364
+ const idx = signed.indexOf(SEP);
365
+ if (idx < 0) return null;
366
+ const body = signed.slice(0, idx);
367
+ const lines = body.split("\n");
358
368
  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);
369
+ const [origin, sizeLine, rootHash] = lines;
370
+ if (!origin || origin.length > 256) return null;
371
+ if (!/^\d+$/.test(sizeLine)) return null;
372
+ const treeSize = parseInt(sizeLine, 10);
362
373
  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] };
374
+ if (!/^[0-9a-f]{64}$/.test(rootHash)) return null;
375
+ // The expected origin must be supplied by the caller (a trusted value), not
376
+ // taken from the checkpoint body, so a witness key that signs several origins
377
+ // cannot substitute a checkpoint for a different log than the receipt's.
378
+ if (typeof expectedOrigin !== "string" || expectedOrigin.length === 0) return null;
379
+ if (origin !== expectedOrigin) return null;
380
+ const sigLines = signed.slice(idx + 2).split("\n").filter((l) => l.length > 0);
381
+ if (sigLines.length === 0 || sigLines.length > 8) return null;
382
+ const bodyBytes = new TextEncoder().encode(body);
383
+ for (const line of sigLines) {
384
+ if (!line.startsWith("-- ")) return null;
385
+ const rest = line.slice(3);
386
+ const sp = rest.indexOf(" ");
387
+ if (sp < 0) return null;
388
+ if (rest.slice(0, sp) !== expectedOrigin) continue;
389
+ try {
390
+ const sig = base64urlDecode(rest.slice(sp + 1));
391
+ if (sig.length !== 64) return null;
392
+ const ok = await ed.verifyAsync(sig, bodyBytes, witnessPublicKey, { zip215: false });
393
+ return ok ? { treeSize, rootHash, origin } : null;
394
+ } catch {
395
+ return null;
396
+ }
397
+ }
398
+ return null;
365
399
  }
366
400
 
367
- async function fetchCurrentCheckpoint(witnessUrl) {
401
+ async function fetchCurrentCheckpoint(witnessUrl, witnessPublicKey, expectedOrigin) {
402
+ // No trusted origin, no cross-check: refuse to trust the checkpoint body's
403
+ // self-asserted origin. Pass --origin <witness-origin> to enable it.
404
+ if (typeof expectedOrigin !== "string" || expectedOrigin.length === 0) return null;
368
405
  const url = `${witnessUrl.replace(/\/$/, "")}/ink/v1/checkpoint`;
369
406
  let body;
370
407
  try {
@@ -374,7 +411,7 @@ async function fetchCurrentCheckpoint(witnessUrl) {
374
411
  // 'not available' rather than crashing the verifier.
375
412
  return null;
376
413
  }
377
- return parseCheckpointBody(body);
414
+ return verifyCheckpointBody(body, witnessPublicKey, expectedOrigin);
378
415
  }
379
416
 
380
417
  async function readStdin() {
@@ -451,16 +488,22 @@ async function main() {
451
488
  process.exit(2);
452
489
  }
453
490
 
454
- const laterCheckpoint = await fetchCurrentCheckpoint(witnessBase);
491
+ // The checkpoint cross-check only carries weight against a checkpoint whose
492
+ // Ed25519 signature we have verified against the witness key. An unverified
493
+ // checkpoint (bad/absent signature, or origin mismatch) is dropped so the
494
+ // cross-check is skipped rather than trusting attacker-controlled values.
495
+ const laterCheckpoint = await fetchCurrentCheckpoint(witnessBase, witnessPublicKey, args.origin);
455
496
 
456
497
  const result = await verifyReceipt(receipt, witnessPublicKey, args.eventHash, laterCheckpoint ?? undefined);
457
498
 
458
499
  console.log(`Receipt: eventId=${receipt?.eventId} leafIndex=${receipt?.leafIndex} treeSize=${receipt?.treeSize}`);
459
500
  console.log(`Witness: ${witnessBase}`);
460
501
  if (laterCheckpoint) {
461
- console.log(`Current checkpoint: treeSize=${laterCheckpoint.treeSize} rootHash=${laterCheckpoint.rootHash}`);
502
+ console.log(`Current checkpoint (signature verified): treeSize=${laterCheckpoint.treeSize} rootHash=${laterCheckpoint.rootHash}`);
503
+ } else if (!args.origin) {
504
+ console.log("Current checkpoint: cross-check skipped (pass --origin <witness-origin> to enable the anti-rollback check)");
462
505
  } else {
463
- console.log("Current checkpoint: not available (skipping checkpoint cross-check)");
506
+ console.log("Current checkpoint: not available or signature unverified (skipping checkpoint cross-check)");
464
507
  }
465
508
  console.log("");
466
509
  for (const step of result.steps) {
@@ -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;
@@ -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 };
@@ -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;
@@ -53,3 +53,22 @@ export declare function deriveAgentId(publicKey: Uint8Array): string;
53
53
  * same way for both, so a malformed tail is rejected identically.
54
54
  */
55
55
  export declare function extractPublicKeyFromAgentId(agentId: string): Uint8Array;
56
+ /**
57
+ * Collapse an agent ID to a single, prefix-independent principal string that
58
+ * per-sender security state (block lists, rate limits, duplicate-payload
59
+ * checks, cached verification keys, connection identity) MUST key on.
60
+ *
61
+ * The accepted spellings `tulpa:zKEY` and `ink:zKEY` encode the same Ed25519
62
+ * key and are therefore the same actor; this maps both — and any non-canonical
63
+ * multibase encoding of that key — to `key:<canonical-multibase>`, so a sender
64
+ * cannot switch prefix or re-encode to dodge a block or split a rate-limit
65
+ * window. DIDs (and any other identifier) are returned unchanged. A raw `key:`
66
+ * input — never a legitimate agent ID — is escaped to `raw:key:…` so a sender
67
+ * cannot forge a collision with a canonicalized key principal.
68
+ *
69
+ * Not idempotent: call exactly once, at the storage boundary, on the raw
70
+ * agent ID. Total over well-formed string input (it never throws on a
71
+ * malformed key body — that is escaped to `raw:…` so a principal is always
72
+ * derivable); throws only on a non-string, empty, or over-length argument.
73
+ */
74
+ export declare function canonicalAgentPrincipal(agentId: string): string;
@@ -190,3 +190,42 @@ export function extractPublicKeyFromAgentId(agentId) {
190
190
  }
191
191
  return decodePublicKeyMultibase(agentId.slice(prefix.length));
192
192
  }
193
+ /**
194
+ * Collapse an agent ID to a single, prefix-independent principal string that
195
+ * per-sender security state (block lists, rate limits, duplicate-payload
196
+ * checks, cached verification keys, connection identity) MUST key on.
197
+ *
198
+ * The accepted spellings `tulpa:zKEY` and `ink:zKEY` encode the same Ed25519
199
+ * key and are therefore the same actor; this maps both — and any non-canonical
200
+ * multibase encoding of that key — to `key:<canonical-multibase>`, so a sender
201
+ * cannot switch prefix or re-encode to dodge a block or split a rate-limit
202
+ * window. DIDs (and any other identifier) are returned unchanged. A raw `key:`
203
+ * input — never a legitimate agent ID — is escaped to `raw:key:…` so a sender
204
+ * cannot forge a collision with a canonicalized key principal.
205
+ *
206
+ * Not idempotent: call exactly once, at the storage boundary, on the raw
207
+ * agent ID. Total over well-formed string input (it never throws on a
208
+ * malformed key body — that is escaped to `raw:…` so a principal is always
209
+ * derivable); throws only on a non-string, empty, or over-length argument.
210
+ */
211
+ export function canonicalAgentPrincipal(agentId) {
212
+ if (typeof agentId !== "string" || agentId.length === 0 || agentId.length > 512) {
213
+ throw new Error("Invalid agent ID");
214
+ }
215
+ const prefix = AGENT_ID_KEY_PREFIXES.find((p) => agentId.startsWith(p));
216
+ if (prefix) {
217
+ try {
218
+ return "key:" + encodePublicKeyMultibase(decodePublicKeyMultibase(agentId.slice(prefix.length)));
219
+ }
220
+ catch {
221
+ // Malformed multibase body: keep the function total by treating it as an
222
+ // opaque identifier. Such an ID cannot authenticate via the bootstrap
223
+ // path anyway, so it never collides with a real key principal.
224
+ return "raw:" + agentId;
225
+ }
226
+ }
227
+ if (agentId.startsWith("key:")) {
228
+ return "raw:" + agentId;
229
+ }
230
+ return agentId;
231
+ }
@@ -1,3 +1,24 @@
1
+ /**
2
+ * A number is safe for canonical JSON only if every conforming canonicalizer
3
+ * serializes it identically. We reject non-finite values (not valid JSON),
4
+ * negative zero (serializes as `0`, losing the sign), and any value whose
5
+ * shortest decimal uses exponential notation (`1e21`, `1e-7`) — exponential
6
+ * forms are exactly where JSON serializers and strict RFC 8785 disagree.
7
+ * Rejecting them keeps the signed-byte representation unambiguous across
8
+ * implementations (the reference and a future second implementation), without
9
+ * affecting the small integers and plain decimals INK payloads actually carry.
10
+ */
11
+ export declare function isJcsSafeNumber(n: number): boolean;
12
+ /**
13
+ * Cheap depth/node/byte walk over a value before it is handed to
14
+ * `canonicalize`. Bails before the recursive sort+serialize runs, so an
15
+ * attacker who supplies a syntactically valid-shape signature with a
16
+ * pathological message body cannot burn CPU/memory inside the verify
17
+ * path. Mirrors src/crypto/ink.ts:isWithinCanonicalizeBounds, including
18
+ * the byte counter that stops a single huge string from sneaking past
19
+ * the node check.
20
+ */
21
+ export declare function isWithinBounds(value: unknown): boolean;
1
22
  /**
2
23
  * Sign a message object using Ed25519.
3
24
  *
@@ -10,6 +10,23 @@ const MAX_MESSAGE_CHARS = 1_200_000;
10
10
  * walk: a message can be small in node count but still expand to huge
11
11
  * canonical bytes via long string values. */
12
12
  const MAX_MESSAGE_CANONICAL_BYTES = 1_048_576;
13
+ /**
14
+ * A number is safe for canonical JSON only if every conforming canonicalizer
15
+ * serializes it identically. We reject non-finite values (not valid JSON),
16
+ * negative zero (serializes as `0`, losing the sign), and any value whose
17
+ * shortest decimal uses exponential notation (`1e21`, `1e-7`) — exponential
18
+ * forms are exactly where JSON serializers and strict RFC 8785 disagree.
19
+ * Rejecting them keeps the signed-byte representation unambiguous across
20
+ * implementations (the reference and a future second implementation), without
21
+ * affecting the small integers and plain decimals INK payloads actually carry.
22
+ */
23
+ export function isJcsSafeNumber(n) {
24
+ if (!Number.isFinite(n))
25
+ return false;
26
+ if (Object.is(n, -0))
27
+ return false;
28
+ return !/[eE]/.test(String(n));
29
+ }
13
30
  /**
14
31
  * Cheap depth/node/byte walk over a value before it is handed to
15
32
  * `canonicalize`. Bails before the recursive sort+serialize runs, so an
@@ -19,7 +36,7 @@ const MAX_MESSAGE_CANONICAL_BYTES = 1_048_576;
19
36
  * the byte counter that stops a single huge string from sneaking past
20
37
  * the node check.
21
38
  */
22
- function isWithinBounds(value) {
39
+ export function isWithinBounds(value) {
23
40
  let nodes = 0;
24
41
  let chars = 0;
25
42
  function walk(v, depth) {
@@ -33,6 +50,9 @@ function isWithinBounds(value) {
33
50
  if (chars > MAX_MESSAGE_CHARS)
34
51
  return false;
35
52
  }
53
+ else if (typeof v === "number" && !isJcsSafeNumber(v)) {
54
+ return false;
55
+ }
36
56
  return true;
37
57
  }
38
58
  if (Array.isArray(v)) {
@@ -162,7 +182,11 @@ export async function verifyMessage(message, publicKey) {
162
182
  const prefixedBytes = new TextEncoder().encode(prefixed);
163
183
  try {
164
184
  const sig = base64urlDecode(signature);
165
- return await ed.verifyAsync(sig, prefixedBytes, publicKey);
185
+ // RFC 8032 strict verification, not the library default ZIP-215 mode:
186
+ // reject small-order public keys and non-canonical point encodings so a
187
+ // signature binds to exactly one (key, message). Identity is the embedded
188
+ // public key and signatures feed the audit log, so strictness is required.
189
+ return await ed.verifyAsync(sig, prefixedBytes, publicKey, { zip215: false });
166
190
  }
167
191
  catch {
168
192
  // Malformed signature (invalid base64url, wrong byte length, bad key) — treat as invalid
@@ -24,13 +24,15 @@ export interface FetchAgentCardOptions {
24
24
  * connect targets (e.g. undici with a custom dispatcher on Node, or
25
25
  * `cf: { resolveOverride: validatedIp }` on Cloudflare Workers). */
26
26
  fetch?: typeof fetch;
27
- /** Strict mode: require that the caller supply `options.fetch`. When
28
- * true, the default global `fetch` is refused for non-literal hostnames
29
- * because the default cannot perform connect-time IP filtering and is
30
- * therefore vulnerable to DNS rebinding. Off by default for backwards
31
- * compatibility; on by default for any production integration where
32
- * `baseUrl` is taken from untrusted input. Returns null without
33
- * fetching when the condition fails. */
27
+ /** Strict mode: require that the caller supply `options.fetch`, returning
28
+ * null (without fetching) if it is absent. This only guarantees that *some*
29
+ * fetch override was provided; it does NOT and cannot verify that the
30
+ * override pins connect-time IPs, so passing `requireSafeFetch: true` with
31
+ * the plain global `fetch` does not close the DNS-rebinding window. The
32
+ * literal-private-IP allowlist this module applies to `baseUrl` does not stop
33
+ * a public hostname that resolves to a private address at fetch time; only a
34
+ * connect-time-IP-pinning `options.fetch` (for example a custom undici
35
+ * dispatcher) does. Off by default for backwards compatibility. */
34
36
  requireSafeFetch?: boolean;
35
37
  }
36
38
  /**
package/dist/index.d.ts CHANGED
@@ -1,15 +1,15 @@
1
1
  export { signInkMessage, verifyInkSignature, buildSignatureBase, buildAuthHeader, computeMessageHash, computeEventHash, computeAuditMerkleLeafHash, signAuditEvent, verifyAuditEventSignature, signAuditResponse, verifyAuditResponseSignature, verifyAuditEventChain, signAuditQueryResponse, verifyAuditQueryResponseSignature, encryptInkPayload, decryptInkPayload, checkReplay, base64urlEncode, base64urlDecode, hexToBytes, bytesToHex, jcsCanonicalize, MAX_TIMESTAMP_AGE_MS, MAX_FUTURE_TIMESTAMP_MS, } from "./crypto/ink.js";
2
2
  export { signMessage, verifyMessage } from "./crypto/sign.js";
3
3
  export { verifyInkSignatureWithKeys } from "./crypto/multi-key-verify.js";
4
- export { generateKeypair, generateEncryptionKeypair, deriveAgentId, encodePublicKeyMultibase, encodeEncryptionKeyMultibase, decodePublicKeyMultibase, decodeEncryptionKeyMultibase, extractPublicKeyFromAgentId, AGENT_ID_KEY_PREFIXES, } from "./crypto/keys.js";
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
7
  export { verifyInclusionReceipt, 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
- export { buildReceipt, shouldSendReceipt, sendReceiptFireAndForget, } from "./ink/receipts.js";
9
+ export { buildReceipt, verifyReceipt, shouldSendReceipt, sendReceiptFireAndForget, } from "./ink/receipts.js";
10
10
  export { resolveEffectiveTransports, checkTransportAllowed, } from "./ink/transport-auth.js";
11
11
  export { buildRedactedCard, shouldRedactOnGet, AgentCardQuerySchema, } from "./ink/discovery-gating.js";
12
- export { parseCheckpoint, formatCheckpoint, } from "./ink/checkpoint.js";
12
+ export { parseCheckpoint, formatCheckpoint, verifyCheckpoint, } from "./ink/checkpoint.js";
13
13
  export type { CheckpointData } from "./ink/checkpoint.js";
14
14
  export { InkAuditEventTypeSchema, InkAuditEventSchema, InkAuditInclusionSchema, InkReceiptSchema, InkAuditQuerySchema, InkIntroductionReceiptSchema, } from "./models/ink-audit.js";
15
15
  export type { InkAuditEventType, InkAuditEvent, InkAuditInclusion, InkReceipt, InkAuditQuery, InkAuditResponse, InkIntroductionReceiptStatus, } from "./models/ink-audit.js";
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  export { signInkMessage, verifyInkSignature, buildSignatureBase, buildAuthHeader, computeMessageHash, computeEventHash, computeAuditMerkleLeafHash, signAuditEvent, verifyAuditEventSignature, signAuditResponse, verifyAuditResponseSignature, verifyAuditEventChain, signAuditQueryResponse, verifyAuditQueryResponseSignature, encryptInkPayload, decryptInkPayload, checkReplay, base64urlEncode, base64urlDecode, hexToBytes, bytesToHex, jcsCanonicalize, MAX_TIMESTAMP_AGE_MS, MAX_FUTURE_TIMESTAMP_MS, } from "./crypto/ink.js";
5
5
  export { signMessage, verifyMessage } from "./crypto/sign.js";
6
6
  export { verifyInkSignatureWithKeys } from "./crypto/multi-key-verify.js";
7
- export { generateKeypair, generateEncryptionKeypair, deriveAgentId, encodePublicKeyMultibase, encodeEncryptionKeyMultibase, decodePublicKeyMultibase, decodeEncryptionKeyMultibase, extractPublicKeyFromAgentId, AGENT_ID_KEY_PREFIXES, } from "./crypto/keys.js";
7
+ export { generateKeypair, generateEncryptionKeypair, deriveAgentId, encodePublicKeyMultibase, encodeEncryptionKeyMultibase, decodePublicKeyMultibase, decodeEncryptionKeyMultibase, extractPublicKeyFromAgentId, canonicalAgentPrincipal, AGENT_ID_KEY_PREFIXES, } from "./crypto/keys.js";
8
8
  // Discovery: Agent Card fetch + candidate-key extraction
9
9
  export { fetchAgentCard, extractCandidateKeys, resolveBaseUrl, } from "./discovery/agent-card.js";
10
10
  // Middleware: transport-level INK auth
@@ -13,14 +13,14 @@ export { verifyInkAuth } from "./middleware/ink-auth.js";
13
13
  export { verifyInclusionReceipt, verifyAuditQueryResponse, } from "./audit/inclusion-receipt.js";
14
14
  // Optional containment / governance primitives
15
15
  export { HandshakeBudgetTracker } from "./ink/handshake-budget.js";
16
- // Receipts: build and send INK delivery receipts
17
- export { buildReceipt, shouldSendReceipt, sendReceiptFireAndForget, } from "./ink/receipts.js";
16
+ // Receipts: build, verify, and send INK delivery receipts
17
+ export { buildReceipt, verifyReceipt, shouldSendReceipt, sendReceiptFireAndForget, } from "./ink/receipts.js";
18
18
  // Transport-auth: token-level transport allowlist for extension tokens
19
19
  export { resolveEffectiveTransports, checkTransportAllowed, } from "./ink/transport-auth.js";
20
20
  // Discovery-gating: visibility-aware Agent Card redaction
21
21
  export { buildRedactedCard, shouldRedactOnGet, AgentCardQuerySchema, } from "./ink/discovery-gating.js";
22
- // Checkpoint parsing for transparency-log signed checkpoints
23
- export { parseCheckpoint, formatCheckpoint, } from "./ink/checkpoint.js";
22
+ // Checkpoint parsing and signature verification for transparency-log checkpoints
23
+ export { parseCheckpoint, formatCheckpoint, verifyCheckpoint, } from "./ink/checkpoint.js";
24
24
  // Audit event schemas + types for receipts, query, inclusion proofs
25
25
  export { InkAuditEventTypeSchema, InkAuditEventSchema, InkAuditInclusionSchema, InkReceiptSchema, InkAuditQuerySchema, InkIntroductionReceiptSchema, } from "./models/ink-audit.js";
26
26
  // Handshake message schemas