@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.
@@ -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
- 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
- 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,23 +4,23 @@
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
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
- // 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
@@ -17,3 +17,24 @@ export interface CheckpointData {
17
17
  export declare function formatCheckpoint(data: CheckpointData): string;
18
18
  /** Parse a checkpoint body. Returns null if invalid. */
19
19
  export declare function parseCheckpoint(body: string): CheckpointData | null;
20
+ /**
21
+ * Verify a signed checkpoint and return its parsed body, or `null` if the
22
+ * signature, origin, or format is invalid.
23
+ *
24
+ * The signed form is the C2SP-style note used by the INK witness:
25
+ *
26
+ * <origin>\n<treeSize>\n<rootHash>\n\n-- <origin> <base64url(sig)>\n
27
+ *
28
+ * The Ed25519 signature covers the body bytes `<origin>\n<treeSize>\n<rootHash>`
29
+ * exactly (no trailing newline), so the `origin` first line is the domain
30
+ * separator binding the signed bytes to this log. Verification REQUIRES the
31
+ * caller's `expectedOrigin`: a checkpoint whose body origin, or whose matching
32
+ * signature-line origin, is not `expectedOrigin` is rejected, so a witness that
33
+ * operates several logs (or an attacker replaying another log's signed
34
+ * checkpoint) cannot substitute a different tree. This is the authenticated
35
+ * input that anti-rollback / freshness checks MUST consume; an unverified
36
+ * checkpoint body provides no security.
37
+ *
38
+ * Verification uses RFC 8032 strict mode (small-order keys rejected).
39
+ */
40
+ export declare function verifyCheckpoint(signed: string, witnessPublicKey: Uint8Array, expectedOrigin: string): Promise<CheckpointData | null>;
@@ -2,6 +2,8 @@
2
2
  * INK Checkpoint formatting (C2SP tlog-checkpoint compatible).
3
3
  * Used for the public checkpoint endpoint (INK Auditability §7.7).
4
4
  */
5
+ import * as ed from "@noble/ed25519";
6
+ import { base64urlDecode } from "../crypto/ink.js";
5
7
  /**
6
8
  * Format a checkpoint body per C2SP tlog-checkpoint spec:
7
9
  * line 1: origin (log identity)
@@ -67,3 +69,80 @@ export function parseCheckpoint(body) {
67
69
  return null;
68
70
  return { origin, treeSize, rootHash };
69
71
  }
72
+ /** A signed checkpoint is the 3-line body, a blank line, then one or more
73
+ * signature lines, plus a trailing newline. Bound the whole thing so an
74
+ * attacker-supplied blob cannot drive large scans before rejection. */
75
+ const MAX_SIGNED_CHECKPOINT_BODY = 4096;
76
+ /** Cap the number of cosignature lines a verifier will scan. */
77
+ const MAX_CHECKPOINT_SIGNATURES = 8;
78
+ /**
79
+ * Verify a signed checkpoint and return its parsed body, or `null` if the
80
+ * signature, origin, or format is invalid.
81
+ *
82
+ * The signed form is the C2SP-style note used by the INK witness:
83
+ *
84
+ * <origin>\n<treeSize>\n<rootHash>\n\n-- <origin> <base64url(sig)>\n
85
+ *
86
+ * The Ed25519 signature covers the body bytes `<origin>\n<treeSize>\n<rootHash>`
87
+ * exactly (no trailing newline), so the `origin` first line is the domain
88
+ * separator binding the signed bytes to this log. Verification REQUIRES the
89
+ * caller's `expectedOrigin`: a checkpoint whose body origin, or whose matching
90
+ * signature-line origin, is not `expectedOrigin` is rejected, so a witness that
91
+ * operates several logs (or an attacker replaying another log's signed
92
+ * checkpoint) cannot substitute a different tree. This is the authenticated
93
+ * input that anti-rollback / freshness checks MUST consume; an unverified
94
+ * checkpoint body provides no security.
95
+ *
96
+ * Verification uses RFC 8032 strict mode (small-order keys rejected).
97
+ */
98
+ export async function verifyCheckpoint(signed, witnessPublicKey, expectedOrigin) {
99
+ if (typeof signed !== "string" || signed.length === 0 || signed.length > MAX_SIGNED_CHECKPOINT_BODY) {
100
+ return null;
101
+ }
102
+ if (!(witnessPublicKey instanceof Uint8Array) || witnessPublicKey.length !== 32) {
103
+ return null;
104
+ }
105
+ if (typeof expectedOrigin !== "string" || expectedOrigin.length === 0 || expectedOrigin.length > MAX_CHECKPOINT_LINE) {
106
+ return null;
107
+ }
108
+ const SEP = "\n\n-- ";
109
+ const idx = signed.indexOf(SEP);
110
+ if (idx === -1)
111
+ return null;
112
+ const body = signed.slice(0, idx); // <origin>\n<treeSize>\n<rootHash>
113
+ const data = parseCheckpoint(body + "\n");
114
+ if (!data)
115
+ return null;
116
+ // Bind the body's own origin to the caller's expectation before any crypto.
117
+ if (data.origin !== expectedOrigin)
118
+ return null;
119
+ // Signature block starts at the "-- " that began the separator.
120
+ const sigBlock = signed.slice(idx + 2);
121
+ const sigLines = sigBlock.split("\n").filter((l) => l.length > 0);
122
+ if (sigLines.length === 0 || sigLines.length > MAX_CHECKPOINT_SIGNATURES)
123
+ return null;
124
+ const bodyBytes = new TextEncoder().encode(body);
125
+ for (const line of sigLines) {
126
+ if (!line.startsWith("-- "))
127
+ return null; // any malformed signature line is fatal
128
+ const rest = line.slice(3);
129
+ const sp = rest.indexOf(" ");
130
+ if (sp === -1)
131
+ return null;
132
+ const lineOrigin = rest.slice(0, sp);
133
+ const sigB64 = rest.slice(sp + 1);
134
+ if (lineOrigin !== expectedOrigin)
135
+ continue; // a cosigner whose key we were not given
136
+ try {
137
+ const sig = base64urlDecode(sigB64);
138
+ if (sig.length !== 64)
139
+ return null;
140
+ const ok = await ed.verifyAsync(sig, bodyBytes, witnessPublicKey, { zip215: false });
141
+ return ok ? data : null; // a matching-origin signature that fails verification is fatal
142
+ }
143
+ catch {
144
+ return null;
145
+ }
146
+ }
147
+ return null; // no signature line for the expected origin
148
+ }
@@ -66,16 +66,16 @@ export function buildRedactedCard(card) {
66
66
  export const AgentCardQuerySchema = z.object({
67
67
  protocol: z.literal("ink/0.1"),
68
68
  type: z.literal("network.tulpa.agent_card_query"),
69
- from: z.string(),
70
- nonce: z.string(),
69
+ from: z.string().max(512),
70
+ nonce: z.string().max(256),
71
71
  timestamp: z.string().datetime(),
72
- requestedFields: z.array(z.string()).optional(),
72
+ requestedFields: z.array(z.string().max(64)).max(32).optional(),
73
73
  });
74
74
  export const AgentCardResponseSchema = z.object({
75
75
  protocol: z.literal("ink/0.1"),
76
76
  type: z.literal("network.tulpa.agent_card_response"),
77
77
  card: AgentCardSchema,
78
- grantedFields: z.array(z.string()),
78
+ grantedFields: z.array(z.string().max(64)).max(32),
79
79
  timestamp: z.string().datetime(),
80
80
  });
81
81
  export const AgentCardDeniedSchema = z.object({
@@ -1,4 +1,4 @@
1
- import type { InkReceipt } from "../models/ink-audit.js";
1
+ import { type InkReceipt } from "../models/ink-audit.js";
2
2
  export interface BuildReceiptInput {
3
3
  from: string;
4
4
  to: string;
@@ -10,6 +10,38 @@ export interface BuildReceiptInput {
10
10
  }
11
11
  /** Build a signed INK receipt envelope. */
12
12
  export declare function buildReceipt(input: BuildReceiptInput): Promise<InkReceipt>;
13
+ export interface VerifyReceiptResult {
14
+ valid: boolean;
15
+ /** Machine-readable reason when `valid` is false. */
16
+ reason?: string;
17
+ }
18
+ /**
19
+ * Verify an INK receipt against the message it claims to acknowledge.
20
+ *
21
+ * Checks all the bindings a hand-rolled verifier commonly forgets: the
22
+ * receipt's Ed25519 signature against the issuer's (`from`) key, that `from`,
23
+ * `to` and `messageId` equal the expected values, and that `messageHash`
24
+ * equals the hash of the exact message body that was sent (recomputed here,
25
+ * not trusted from the receipt). A receipt that passes proves the named
26
+ * counterparty acknowledged that specific message; nothing weaker should be
27
+ * treated as proof of delivery.
28
+ */
29
+ export declare function verifyReceipt(opts: {
30
+ receipt: unknown;
31
+ /** Raw 32-byte Ed25519 public key of the issuer (the receipt's `from`). */
32
+ senderPublicKey: Uint8Array;
33
+ expected: {
34
+ from: string;
35
+ to: string;
36
+ messageId: string;
37
+ messageBody: Record<string, unknown>;
38
+ /** When set, require the receipt to acknowledge this specific disposition
39
+ * (e.g. "delivered"). The disposition is covered by the signature, but
40
+ * without this a signed receipt for a different state would still pass, so
41
+ * callers proving a specific delivery state MUST pin it. */
42
+ disposition?: InkReceipt["disposition"];
43
+ };
44
+ }): Promise<VerifyReceiptResult>;
13
45
  export declare function shouldSendReceipt(intentOrType: string): boolean;
14
46
  export interface SendReceiptOptions {
15
47
  /** Allow endpoints whose hostname is loopback / private / link-local /
@@ -1,6 +1,7 @@
1
1
  import { computeMessageHash, signInkMessage, buildAuthHeader } from "../crypto/ink.js";
2
- import { signMessage } from "../crypto/sign.js";
2
+ import { signMessage, verifyMessage } from "../crypto/sign.js";
3
3
  import { isPrivateHostname } from "../discovery/agent-card.js";
4
+ import { InkReceiptSchema } from "../models/ink-audit.js";
4
5
  /** Build a signed INK receipt envelope. */
5
6
  export async function buildReceipt(input) {
6
7
  if (input === null || typeof input !== "object" || Array.isArray(input)) {
@@ -28,6 +29,49 @@ export async function buildReceipt(input) {
28
29
  const signature = await signMessage(unsigned, input.privateKey);
29
30
  return { ...unsigned, signature };
30
31
  }
32
+ /**
33
+ * Verify an INK receipt against the message it claims to acknowledge.
34
+ *
35
+ * Checks all the bindings a hand-rolled verifier commonly forgets: the
36
+ * receipt's Ed25519 signature against the issuer's (`from`) key, that `from`,
37
+ * `to` and `messageId` equal the expected values, and that `messageHash`
38
+ * equals the hash of the exact message body that was sent (recomputed here,
39
+ * not trusted from the receipt). A receipt that passes proves the named
40
+ * counterparty acknowledged that specific message; nothing weaker should be
41
+ * treated as proof of delivery.
42
+ */
43
+ export async function verifyReceipt(opts) {
44
+ const parsed = InkReceiptSchema.safeParse(opts.receipt);
45
+ if (!parsed.success)
46
+ return { valid: false, reason: "malformed_receipt" };
47
+ const receipt = parsed.data;
48
+ const { senderPublicKey, expected } = opts;
49
+ if (!(senderPublicKey instanceof Uint8Array) || senderPublicKey.length !== 32) {
50
+ return { valid: false, reason: "invalid_public_key" };
51
+ }
52
+ if (receipt.from !== expected.from)
53
+ return { valid: false, reason: "from_mismatch" };
54
+ if (receipt.to !== expected.to)
55
+ return { valid: false, reason: "to_mismatch" };
56
+ if (receipt.messageId !== expected.messageId)
57
+ return { valid: false, reason: "message_id_mismatch" };
58
+ if (expected.disposition !== undefined && receipt.disposition !== expected.disposition) {
59
+ return { valid: false, reason: "disposition_mismatch" };
60
+ }
61
+ let expectedHash;
62
+ try {
63
+ expectedHash = await computeMessageHash(expected.messageBody);
64
+ }
65
+ catch {
66
+ return { valid: false, reason: "message_hash_error" };
67
+ }
68
+ if (receipt.messageHash !== expectedHash)
69
+ return { valid: false, reason: "message_hash_mismatch" };
70
+ const sigOk = await verifyMessage(receipt, senderPublicKey);
71
+ if (!sigOk)
72
+ return { valid: false, reason: "invalid_signature" };
73
+ return { valid: true };
74
+ }
31
75
  /** Loop prevention: don't send receipts for receipts or audit messages. */
32
76
  const NO_RECEIPT_TYPES = new Set([
33
77
  "network.tulpa.receipt",
@@ -60,6 +60,7 @@ export declare function verifyInkAuth(opts: {
60
60
  }): Promise<{
61
61
  valid: true;
62
62
  senderAgentId: string;
63
+ principal: string;
63
64
  keyId?: string;
64
65
  keyStatus?: KeyStatus;
65
66
  } | {
@@ -1,5 +1,5 @@
1
1
  import { verifyInkSignature, MAX_TIMESTAMP_AGE_MS, MAX_FUTURE_TIMESTAMP_MS } from "../crypto/ink.js";
2
- import { extractPublicKeyFromAgentId } from "../crypto/keys.js";
2
+ import { extractPublicKeyFromAgentId, canonicalAgentPrincipal } from "../crypto/keys.js";
3
3
  import { verifyInkSignatureWithKeys } from "../crypto/multi-key-verify.js";
4
4
  /**
5
5
  * Parse and verify an INK-Ed25519 Authorization header.
@@ -33,8 +33,10 @@ export async function verifyInkAuth(opts) {
33
33
  }
34
34
  // Ed25519 signatures are exactly 86 base64url chars — tighten the regex to
35
35
  // {86} so clearly-wrong lengths get rejected up front, rather than burning
36
- // CPU on verifyInkSignature for a malformed value.
37
- const match = opts.authHeader.match(/^INK-Ed25519\s+([A-Za-z0-9_-]{86})(?:\s+keyId=([A-Za-z0-9_:.-]{1,128}))?$/);
36
+ // CPU on verifyInkSignature for a malformed value. Single literal spaces
37
+ // match the spec grammar and buildAuthHeader's output; `\s` would let
38
+ // CR/LF/TAB into a parsed header value.
39
+ const match = opts.authHeader.match(/^INK-Ed25519 ([A-Za-z0-9_-]{86})(?: keyId=([A-Za-z0-9_:.-]{1,128}))?$/);
38
40
  if (!match) {
39
41
  return { valid: false, error: "invalid_auth_scheme" };
40
42
  }
@@ -172,6 +174,7 @@ export async function verifyInkAuth(opts) {
172
174
  return {
173
175
  valid: true,
174
176
  senderAgentId: senderDid,
177
+ principal: canonicalAgentPrincipal(senderDid),
175
178
  keyId: result.keyId,
176
179
  keyStatus: result.keyStatus,
177
180
  };
@@ -206,7 +209,7 @@ export async function verifyInkAuth(opts) {
206
209
  const noncePass = await recordNonce();
207
210
  if (!noncePass.ok)
208
211
  return { valid: false, error: noncePass.error };
209
- return { valid: true, senderAgentId: senderDid };
212
+ return { valid: true, senderAgentId: senderDid, principal: canonicalAgentPrincipal(senderDid) };
210
213
  }
211
214
  catch {
212
215
  return { valid: false, error: "signature_verification_failed" };
@@ -5,17 +5,17 @@ import { ProfileSnapshotSchema } from "./profile.js";
5
5
  import { KeyEntrySchema } from "./key-entry.js";
6
6
  import { InkTransportSchema, AgentCardVisibilitySchema } from "./ink-handshake.js";
7
7
  export const ThirdPartyAuditServiceSchema = z.object({
8
- endpoint: z.string().url(),
9
- did: z.string(),
10
- publicKey: z.string(),
8
+ endpoint: z.string().max(2048).url(),
9
+ did: z.string().max(512),
10
+ publicKey: z.string().max(256),
11
11
  });
12
12
  export const AgentCardSchema = z.object({
13
13
  protocol: z.literal("ink/0.1"),
14
- agentId: z.string(),
15
- ownerDid: z.string().optional(),
16
- ownerHandle: z.string().optional(),
17
- atprotoRecordUri: z.string().optional(),
18
- handle: z.string(),
14
+ agentId: z.string().max(512),
15
+ ownerDid: z.string().max(512).optional(),
16
+ ownerHandle: z.string().max(256).optional(),
17
+ atprotoRecordUri: z.string().max(2048).optional(),
18
+ handle: z.string().max(256),
19
19
  displayName: z.string().max(200),
20
20
  /**
21
21
  * Inbound message endpoint URL. Required.
@@ -27,36 +27,36 @@ export const AgentCardSchema = z.object({
27
27
  * MAY emit `inboxEndpoint` alongside it. The runtime helper
28
28
  * `resolveAgentInbox(card)` returns whichever value is present.
29
29
  */
30
- endpoint: z.string().url(),
31
- inboxEndpoint: z.string().url().optional(),
32
- publicKeyMultibase: z.string().startsWith("z"),
30
+ endpoint: z.string().max(2048).url(),
31
+ inboxEndpoint: z.string().max(2048).url().optional(),
32
+ publicKeyMultibase: z.string().startsWith("z").max(128),
33
33
  // (other fields below; the `inboxEndpoint === endpoint` invariant
34
34
  // is enforced by the .superRefine() at the bottom of this schema.)
35
35
  profileSnapshot: ProfileSnapshotSchema.optional(),
36
36
  capabilities: z.object({
37
- intentsAccepted: z.array(IntentTypeSchema),
38
- intentsSent: z.array(IntentTypeSchema),
37
+ intentsAccepted: z.array(IntentTypeSchema).max(32),
38
+ intentsSent: z.array(IntentTypeSchema).max(32),
39
39
  receipts: z.object({
40
40
  send: z.boolean(),
41
- dispositions: z.array(InkReceiptDispositionSchema),
41
+ dispositions: z.array(InkReceiptDispositionSchema).max(16),
42
42
  }).optional(),
43
43
  auditExchange: z.boolean().optional(),
44
44
  thirdPartyAudit: z.object({
45
- services: z.array(ThirdPartyAuditServiceSchema),
45
+ services: z.array(ThirdPartyAuditServiceSchema).max(16),
46
46
  submitPolicy: z.enum(["all", "high_value", "none"]),
47
47
  }).optional(),
48
48
  }),
49
49
  availability: z.object({
50
- timezone: z.string(),
51
- meetingHours: z.string().optional(),
52
- responseSla: z.string().optional(),
50
+ timezone: z.string().max(64),
51
+ meetingHours: z.string().max(200).optional(),
52
+ responseSla: z.string().max(200).optional(),
53
53
  }),
54
54
  keys: z.object({
55
- signing: z.array(KeyEntrySchema),
56
- encryption: z.array(KeyEntrySchema),
55
+ signing: z.array(KeyEntrySchema).max(32),
56
+ encryption: z.array(KeyEntrySchema).max(32),
57
57
  }).optional(),
58
- currentSigningKeyId: z.string().optional(),
59
- currentEncryptionKeyId: z.string().optional(),
58
+ currentSigningKeyId: z.string().max(128).optional(),
59
+ currentEncryptionKeyId: z.string().max(128).optional(),
60
60
  keySetVersion: z.number().int().positive().optional(),
61
61
  // Message protocol versions this agent's receiver can verify on the
62
62
  // body signature. When absent, assume ink/0.1 only. A sender MUST NOT