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

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.
Files changed (69) hide show
  1. package/CHANGELOG.md +56 -5
  2. package/CODE_OF_CONDUCT.md +1 -1
  3. package/README.md +7 -5
  4. package/SECURITY.md +1 -1
  5. package/bin/verify-inclusion-impl.mjs +4 -1
  6. package/dist/audit/inclusion-receipt.d.ts +142 -0
  7. package/dist/audit/inclusion-receipt.js +496 -0
  8. package/dist/crypto/ink.d.ts +178 -0
  9. package/dist/crypto/ink.js +915 -0
  10. package/dist/crypto/keys.d.ts +42 -0
  11. package/dist/crypto/keys.js +179 -0
  12. package/dist/crypto/multi-key-verify.d.ts +29 -0
  13. package/dist/crypto/multi-key-verify.js +153 -0
  14. package/dist/crypto/sign.d.ts +17 -0
  15. package/dist/crypto/sign.js +152 -0
  16. package/dist/crypto/verify.js +1 -0
  17. package/dist/discovery/agent-card.d.ts +83 -0
  18. package/dist/discovery/agent-card.js +545 -0
  19. package/dist/index.d.ts +12 -0
  20. package/dist/index.js +15 -0
  21. package/dist/ink/checkpoint.d.ts +19 -0
  22. package/dist/ink/checkpoint.js +69 -0
  23. package/dist/ink/discovery-gating.d.ts +237 -0
  24. package/dist/ink/discovery-gating.js +91 -0
  25. package/dist/ink/handshake-budget.d.ts +90 -0
  26. package/dist/ink/handshake-budget.js +397 -0
  27. package/dist/ink/receipts.d.ts +31 -0
  28. package/dist/ink/receipts.js +89 -0
  29. package/dist/ink/transport-auth.d.ts +47 -0
  30. package/dist/ink/transport-auth.js +77 -0
  31. package/dist/middleware/ink-auth.d.ts +68 -0
  32. package/dist/middleware/ink-auth.js +214 -0
  33. package/dist/models/agent-card.d.ts +154 -0
  34. package/dist/models/agent-card.js +59 -0
  35. package/dist/models/ink-audit.d.ts +344 -0
  36. package/dist/models/ink-audit.js +167 -0
  37. package/dist/models/ink-handshake.d.ts +129 -0
  38. package/dist/models/ink-handshake.js +89 -0
  39. package/dist/models/intent.d.ts +437 -0
  40. package/dist/models/intent.js +172 -0
  41. package/dist/models/key-entry.d.ts +60 -0
  42. package/dist/models/key-entry.js +13 -0
  43. package/dist/models/profile.d.ts +61 -0
  44. package/dist/models/profile.js +24 -0
  45. package/docs/maturity.md +3 -3
  46. package/docs/threat-model.md +1 -1
  47. package/package.json +17 -13
  48. package/specs/ink-auditability.md +37 -12
  49. package/specs/ink-compliance-checklist.md +9 -1
  50. package/src/audit/inclusion-receipt.ts +0 -268
  51. package/src/crypto/ink.ts +0 -902
  52. package/src/crypto/keys.ts +0 -210
  53. package/src/crypto/multi-key-verify.ts +0 -170
  54. package/src/crypto/sign.ts +0 -155
  55. package/src/discovery/agent-card.ts +0 -508
  56. package/src/index.ts +0 -67
  57. package/src/ink/checkpoint.ts +0 -75
  58. package/src/ink/discovery-gating.ts +0 -147
  59. package/src/ink/handshake-budget.ts +0 -413
  60. package/src/ink/receipts.ts +0 -114
  61. package/src/ink/transport-auth.ts +0 -96
  62. package/src/middleware/ink-auth.ts +0 -263
  63. package/src/models/agent-card.ts +0 -63
  64. package/src/models/ink-audit.ts +0 -205
  65. package/src/models/ink-handshake.ts +0 -123
  66. package/src/models/intent.ts +0 -201
  67. package/src/models/key-entry.ts +0 -52
  68. package/src/models/profile.ts +0 -31
  69. /package/{src/crypto/verify.ts → dist/crypto/verify.d.ts} +0 -0
@@ -0,0 +1,545 @@
1
+ import { AgentCardSchema } from "../models/agent-card.js";
2
+ import { decodePublicKeyMultibase } from "../crypto/keys.js";
3
+ /** Same cap used by multi-key verification — applied early to bound the
4
+ * cost of the base58 decode loop on poisoned cards with thousands of entries. */
5
+ const MAX_PARSE_KEYS = 20;
6
+ /** True if the URL passes the same SSRF gate as baseUrl: https only, no
7
+ * userinfo, no literal private/loopback/IANA-special hostnames. Used to
8
+ * vet URL-shaped fields inside a fetched card before returning it. */
9
+ function isSafePublicUrl(rawUrl, allowPrivate) {
10
+ if (typeof rawUrl !== "string" || rawUrl.length === 0)
11
+ return false;
12
+ let u;
13
+ try {
14
+ u = new URL(rawUrl);
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ if (u.protocol !== "https:")
20
+ return false;
21
+ if (u.username || u.password)
22
+ return false;
23
+ if (!allowPrivate && isPrivateHostname(u.hostname))
24
+ return false;
25
+ return true;
26
+ }
27
+ /** Stream-read a Response body with a hard byte cap. Aborts after the cap is
28
+ * exceeded so a chunked-transfer response without Content-Length cannot
29
+ * force unbounded buffering. Returns null on cap-exceeded. */
30
+ async function readResponseBodyWithCap(res, capBytes) {
31
+ if (!res.body)
32
+ return "";
33
+ const reader = res.body.getReader();
34
+ const chunks = [];
35
+ let total = 0;
36
+ try {
37
+ while (true) {
38
+ const { value, done } = await reader.read();
39
+ if (done)
40
+ break;
41
+ if (value) {
42
+ total += value.byteLength;
43
+ if (total > capBytes) {
44
+ try {
45
+ await reader.cancel();
46
+ }
47
+ catch { /* ignore */ }
48
+ return null;
49
+ }
50
+ chunks.push(value);
51
+ }
52
+ }
53
+ }
54
+ finally {
55
+ try {
56
+ reader.releaseLock();
57
+ }
58
+ catch { /* ignore */ }
59
+ }
60
+ const merged = new Uint8Array(total);
61
+ let off = 0;
62
+ for (const c of chunks) {
63
+ merged.set(c, off);
64
+ off += c.byteLength;
65
+ }
66
+ return new TextDecoder().decode(merged);
67
+ }
68
+ /** Reject hostnames that resolve (statically) to loopback, private, or
69
+ * link-local addresses. This is an SSRF defense for integrators that may
70
+ * pass user-controlled baseUrl values. Returns true if the hostname is
71
+ * a literal IP in a reserved range, a loopback name, or an IPv6 unique
72
+ * local / link-local address.
73
+ *
74
+ * Note: this does NOT defend against DNS rebinding — a public hostname
75
+ * that resolves to 127.0.0.1 at fetch time will still hit loopback.
76
+ * That defense lives at the runtime / platform layer. */
77
+ export function isPrivateHostname(hostname) {
78
+ let h = hostname.toLowerCase();
79
+ // Strip trailing dots (FQDN form) so `localhost.` doesn't bypass.
80
+ while (h.endsWith("."))
81
+ h = h.slice(0, -1);
82
+ if (!h)
83
+ return true;
84
+ if (h === "localhost" || h.endsWith(".localhost"))
85
+ return true;
86
+ // IPv6 in brackets — WHATWG URL canonicalizes bracketed v6 to lowercase
87
+ // with `::` collapsed, but a caller might still pass an un-collapsed form.
88
+ const bare = h.startsWith("[") && h.endsWith("]") ? h.slice(1, -1) : h;
89
+ // IPv4-mapped IPv6 in dotted form (::ffff:1.2.3.4) — checked before
90
+ // general IPv6 so the v4 octet checks apply.
91
+ const v4m = bare.match(/^::ffff:(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
92
+ if (v4m)
93
+ return isPrivateIPv4(Number(v4m[1]), Number(v4m[2]), Number(v4m[3]), Number(v4m[4]));
94
+ // General IPv6 literal: parse and apply v6 special-use ranges. We use a
95
+ // real expansion (not string prefix matches) so that, e.g., a 5-group
96
+ // hex address with `fc` in the high byte of the *last* segment doesn't
97
+ // false-positive as ULA. Unparseable v6 → reject (refuse to fetch
98
+ // something we can't classify).
99
+ if (bare.includes(":")) {
100
+ const groups = expandIPv6(bare);
101
+ if (!groups)
102
+ return true;
103
+ // IPv4-mapped (::ffff:HHHH:HHHH) — extract embedded v4 and use v4 rules.
104
+ if (groups[0] === 0 && groups[1] === 0 && groups[2] === 0 &&
105
+ groups[3] === 0 && groups[4] === 0 && groups[5] === 0xffff) {
106
+ const hi = groups[6], lo = groups[7];
107
+ return isPrivateIPv4((hi >>> 8) & 0xff, hi & 0xff, (lo >>> 8) & 0xff, lo & 0xff);
108
+ }
109
+ return isPrivateIPv6Groups(groups);
110
+ }
111
+ // Dotted-quad IPv4 (decimal only — common encodings)
112
+ const dq = bare.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
113
+ if (dq)
114
+ return isPrivateIPv4(Number(dq[1]), Number(dq[2]), Number(dq[3]), Number(dq[4]));
115
+ // Single-segment numeric forms (e.g. "2130706433") are suspicious — reject.
116
+ if (/^\d+$/.test(bare))
117
+ return true;
118
+ return false;
119
+ }
120
+ /** Classify an IPv6 address (8 16-bit groups) against the IANA special-use
121
+ * registry. Returns true if the address falls in any non-global block.
122
+ * Public addresses (e.g. 2606:4700:: Cloudflare) return false. */
123
+ function isPrivateIPv6Groups(g) {
124
+ if (g.length !== 8)
125
+ return true;
126
+ // ::/128 unspecified + ::1/128 loopback
127
+ if (g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0 &&
128
+ g[4] === 0 && g[5] === 0 && g[6] === 0 && (g[7] === 0 || g[7] === 1))
129
+ return true;
130
+ const high = g[0];
131
+ // fe80::/10 link-local: first 10 bits = 1111 1110 10
132
+ if ((high & 0xffc0) === 0xfe80)
133
+ return true;
134
+ // fc00::/7 unique-local (ULA): first 7 bits = 1111 110
135
+ if ((high & 0xfe00) === 0xfc00)
136
+ return true;
137
+ // ff00::/8 multicast
138
+ if ((high & 0xff00) === 0xff00)
139
+ return true;
140
+ // 2001:*/* IANA special-use blocks within 2001::/16
141
+ if (high === 0x2001) {
142
+ if (g[1] === 0x0000)
143
+ return true; // 2001::/32 Teredo
144
+ if (g[1] === 0x0002 && g[2] === 0)
145
+ return true; // 2001:2::/48 BMWG benchmarking
146
+ if ((g[1] & 0xfff0) === 0x0010)
147
+ return true; // 2001:10::/28 ORCHID (deprecated)
148
+ if ((g[1] & 0xfff0) === 0x0020)
149
+ return true; // 2001:20::/28 ORCHIDv2
150
+ if (g[1] === 0x0db8)
151
+ return true; // 2001:db8::/32 documentation
152
+ }
153
+ // 2002::/16 6to4 — embeds a v4 address in groups[1] and groups[2].
154
+ // If the embedded v4 is in a private/special-use block, treat the whole
155
+ // v6 as private. (RFC 7526 deprecated 6to4 anycast 192.88.99/24 — already
156
+ // blocked separately — but tunneled 6to4 traffic to a private v4 is still
157
+ // an SSRF vector.)
158
+ if (high === 0x2002) {
159
+ const a = (g[1] >>> 8) & 0xff;
160
+ const b = g[1] & 0xff;
161
+ const c = (g[2] >>> 8) & 0xff;
162
+ const d = g[2] & 0xff;
163
+ if (isPrivateIPv4(a, b, c, d))
164
+ return true;
165
+ }
166
+ // 64:ff9b::/96 NAT64 well-known prefix
167
+ if (high === 0x0064 && g[1] === 0xff9b && g[2] === 0 && g[3] === 0 &&
168
+ g[4] === 0 && g[5] === 0)
169
+ return true;
170
+ // 64:ff9b:1::/48 local-use IPv4/IPv6 translation (RFC 8215, not globally reachable)
171
+ if (high === 0x0064 && g[1] === 0xff9b && g[2] === 0x0001)
172
+ return true;
173
+ // 100::/64 discard-only address block
174
+ if (high === 0x0100 && g[1] === 0 && g[2] === 0 && g[3] === 0)
175
+ return true;
176
+ // 100:0:0:1::/64 dummy IPv6 prefix (RFC 7600)
177
+ if (high === 0x0100 && g[1] === 0 && g[2] === 0 && g[3] === 0x0001)
178
+ return true;
179
+ // 3fff::/20 BMWG IPv6 benchmarking (RFC 9637)
180
+ if ((high & 0xfff0) === 0x3ff0)
181
+ return true;
182
+ // 5f00::/16 Segment Routing SRv6 SIDs (RFC 9602)
183
+ if (high === 0x5f00)
184
+ return true;
185
+ // NOTE on partial coverage of certain IANA-listed prefixes:
186
+ // ::ffff:0:0/96 IPv4-mapped — checked per-embedded-v4 above; the
187
+ // block is in the registry because mapped addresses
188
+ // must not appear on the wire, but the bytes can be
189
+ // global IPv4 routes. We block the private subset.
190
+ // 2002::/16 6to4 — same pattern; we block when the embedded v4
191
+ // is private, allow when it's public.
192
+ // 2001::/23 IETF Protocol Assignments aggregate — we list the
193
+ // specific non-global subblocks above (Teredo, BMWG,
194
+ // ORCHID(v2), docs) rather than blanket-blocking the
195
+ // aggregate, which would break legitimate v6 routing
196
+ // that uses other 2001:* allocations.
197
+ return false;
198
+ }
199
+ /** Expand an IPv6 address with optional `::` into 8 16-bit groups.
200
+ * Returns null on malformed input. */
201
+ function expandIPv6(addr) {
202
+ // Reject scope IDs (zone identifiers) — strip them off for our purposes.
203
+ const noScope = addr.split("%")[0];
204
+ const dcIdx = noScope.indexOf("::");
205
+ let leftStr;
206
+ let rightStr;
207
+ if (dcIdx === -1) {
208
+ leftStr = noScope;
209
+ rightStr = "";
210
+ }
211
+ else {
212
+ leftStr = noScope.slice(0, dcIdx);
213
+ rightStr = noScope.slice(dcIdx + 2);
214
+ if (leftStr.includes("::") || rightStr.includes("::"))
215
+ return null;
216
+ }
217
+ const leftParts = leftStr ? leftStr.split(":") : [];
218
+ const rightParts = rightStr ? rightStr.split(":") : [];
219
+ const fill = 8 - (leftParts.length + rightParts.length);
220
+ if (fill < 0)
221
+ return null;
222
+ if (dcIdx === -1 && fill !== 0)
223
+ return null;
224
+ const parts = [
225
+ ...leftParts,
226
+ ...new Array(fill).fill("0"),
227
+ ...rightParts,
228
+ ];
229
+ if (parts.length !== 8)
230
+ return null;
231
+ const out = [];
232
+ for (const p of parts) {
233
+ if (!/^[0-9a-f]{1,4}$/.test(p))
234
+ return null;
235
+ out.push(parseInt(p, 16));
236
+ }
237
+ return out;
238
+ }
239
+ /** Refuse any IPv4 that isn't a globally-routable unicast address.
240
+ * Covers the full IANA Special-Purpose IPv4 Address Registry (RFC 6890
241
+ * + later updates) using exact /CIDR-block checks on all 4 octets. */
242
+ function isPrivateIPv4(a, b, c, _d) {
243
+ if (a === 0)
244
+ return true; // 0.0.0.0/8 this-network
245
+ if (a === 10)
246
+ return true; // 10.0.0.0/8 private
247
+ if (a === 100 && b >= 64 && b <= 127)
248
+ return true; // 100.64.0.0/10 CGNAT
249
+ if (a === 127)
250
+ return true; // 127.0.0.0/8 loopback
251
+ if (a === 169 && b === 254)
252
+ return true; // 169.254.0.0/16 link-local + cloud metadata
253
+ if (a === 172 && b >= 16 && b <= 31)
254
+ return true; // 172.16.0.0/12 private
255
+ if (a === 192 && b === 0 && c === 0)
256
+ return true; // 192.0.0.0/24 IETF protocol assignments
257
+ if (a === 192 && b === 0 && c === 2)
258
+ return true; // 192.0.2.0/24 TEST-NET-1
259
+ if (a === 192 && b === 31 && c === 196)
260
+ return true; // 192.31.196.0/24 AS112-v4
261
+ if (a === 192 && b === 52 && c === 193)
262
+ return true; // 192.52.193.0/24 AMT
263
+ if (a === 192 && b === 88 && c === 99)
264
+ return true; // 192.88.99.0/24 6to4 relay (deprecated)
265
+ if (a === 192 && b === 168)
266
+ return true; // 192.168.0.0/16 private
267
+ if (a === 192 && b === 175 && c === 48)
268
+ return true; // 192.175.48.0/24 Direct Delegation AS112
269
+ if (a === 198 && (b === 18 || b === 19))
270
+ return true; // 198.18.0.0/15 benchmarking
271
+ if (a === 198 && b === 51 && c === 100)
272
+ return true; // 198.51.100.0/24 TEST-NET-2
273
+ if (a === 203 && b === 0 && c === 113)
274
+ return true; // 203.0.113.0/24 TEST-NET-3
275
+ if (a >= 224)
276
+ return true; // 224.0.0.0/4 multicast + 240.0.0.0/4 reserved + 255.255.255.255 broadcast
277
+ return false;
278
+ }
279
+ /**
280
+ * Fetch an Agent Card from a remote INK endpoint.
281
+ * Convention: GET /ink/v1/:agentId/agent.json
282
+ *
283
+ * SECURITY: this function applies several SSRF defenses by default —
284
+ * https-only baseUrl, no userinfo, literal-hostname allowlist excluding
285
+ * loopback / private / link-local / IANA special-use blocks (both v4 and
286
+ * v4-mapped v6), no redirect following, body-size cap, identity binding.
287
+ *
288
+ * It does NOT defend against DNS rebinding: a public hostname that
289
+ * resolves to a private IP at fetch time will still be reached. The
290
+ * runtime-agnostic library cannot solve this on its own — pass
291
+ * `options.fetch` with a connect-time IP-filtering implementation
292
+ * (undici dispatcher on Node, `cf.resolveOverride` on Cloudflare
293
+ * Workers, an egress proxy, etc.) when the baseUrl is not fully trusted.
294
+ */
295
+ export async function fetchAgentCard(agentId, baseUrl, options) {
296
+ // Reject any baseUrl that isn't a plain https:// URL. Without this guard,
297
+ // a caller that takes baseUrl from user input could fetch http://,
298
+ // file://, or javascript: URLs — or send credentials to a path under an
299
+ // attacker-chosen origin.
300
+ let parsedBase;
301
+ try {
302
+ parsedBase = new URL(baseUrl);
303
+ }
304
+ catch {
305
+ return null;
306
+ }
307
+ if (parsedBase.protocol !== "https:")
308
+ return null;
309
+ if (parsedBase.username || parsedBase.password)
310
+ return null;
311
+ // SSRF defense: reject baseUrls pointing at loopback / private / link-local
312
+ // hosts. Opt-in override for tests + intentional intranet deployments.
313
+ if (!options?.allowPrivateHosts && isPrivateHostname(parsedBase.hostname)) {
314
+ return null;
315
+ }
316
+ // Reject obviously-bad agentIds before they hit encodeURIComponent and
317
+ // URL construction:
318
+ // - non-strings (defensive, the type system already requires string)
319
+ // - oversized values (real agentIds are ~50-100 chars; cap matches the
320
+ // limits used by the middleware so we don't allocate giant URLs)
321
+ // - dot-segments (".", "..", or anything containing "/" or "\") which
322
+ // would let the WHATWG URL pathname setter normalise the fetch
323
+ // target away from /ink/v1/<id>/agent.json
324
+ if (typeof agentId !== "string" || agentId.length === 0 || agentId.length > 256) {
325
+ return null;
326
+ }
327
+ if (agentId === "." || agentId === ".." || agentId.includes("/") || agentId.includes("\\")) {
328
+ return null;
329
+ }
330
+ // Build the URL from the parsed object, not the raw string. Otherwise a
331
+ // baseUrl containing URL-encoded CRLF or other parser/serializer edge
332
+ // cases could pass validation but still produce the original raw fetch
333
+ // string. Using URL.pathname / URL.toString() runs through the WHATWG
334
+ // serializer, which normalizes and re-encodes.
335
+ const trimmedPath = parsedBase.pathname.replace(/\/$/, "");
336
+ const built = new URL(parsedBase.origin);
337
+ const expectedSegment = `/ink/v1/${encodeURIComponent(agentId)}/agent.json`;
338
+ built.pathname = `${trimmedPath}${expectedSegment}`;
339
+ // Belt-and-braces: confirm the WHATWG serializer didn't normalise away
340
+ // any segment we intended to be present (e.g. via attacker-supplied
341
+ // unicode that decomposes to "..").
342
+ if (!built.pathname.endsWith(expectedSegment)) {
343
+ return null;
344
+ }
345
+ const url = built.toString();
346
+ // Strict mode: reject before any network work if the caller asked for a
347
+ // safe fetch but didn't supply one. The default global fetch cannot do
348
+ // connect-time IP filtering, which is what closes the DNS-rebinding
349
+ // window. Fail closed.
350
+ if (options?.requireSafeFetch && !options.fetch) {
351
+ return null;
352
+ }
353
+ const fetchImpl = options?.fetch ?? fetch;
354
+ try {
355
+ const res = await fetchImpl(url, {
356
+ headers: { Accept: "application/json" },
357
+ signal: AbortSignal.timeout(5000),
358
+ // Refuse to follow redirects. Without this, a validated https://
359
+ // baseUrl could redirect to http://internal/, http://169.254.169.254/,
360
+ // or any other origin — bypassing the protocol/userinfo checks above.
361
+ // The INK convention serves the card at a fixed path, so any redirect
362
+ // is treated as an SSRF attempt.
363
+ redirect: "manual",
364
+ });
365
+ if (!res.ok)
366
+ return null;
367
+ // Cap card body size with a STREAM-READ. res.text() would buffer the
368
+ // entire body before any check fires; a chunked response without
369
+ // Content-Length could exhaust memory pre-validation.
370
+ const MAX_CARD_BYTES = 64 * 1024;
371
+ const lenHeader = parseInt(res.headers.get("Content-Length") ?? "0", 10);
372
+ if (!isNaN(lenHeader) && lenHeader > MAX_CARD_BYTES)
373
+ return null;
374
+ const text = await readResponseBodyWithCap(res, MAX_CARD_BYTES);
375
+ if (text === null)
376
+ return null;
377
+ let parsed;
378
+ try {
379
+ parsed = JSON.parse(text);
380
+ }
381
+ catch {
382
+ return null;
383
+ }
384
+ // Validate the card shape AT RUNTIME via Zod, not just by a TS cast.
385
+ // Without this, attacker-controlled fields like `card.endpoint` could
386
+ // carry http:// or private-IP URLs that SSRF-careless callers would
387
+ // pass straight into fetch. The schema also enforces required fields
388
+ // (protocol, agentId, publicKeyMultibase, capabilities, etc.).
389
+ const parseResult = AgentCardSchema.safeParse(parsed);
390
+ if (!parseResult.success)
391
+ return null;
392
+ const card = parseResult.data;
393
+ // Defense-in-depth (schema would also catch these via z.literal):
394
+ if (card.protocol !== "ink/0.1" || !card.agentId || !card.publicKeyMultibase) {
395
+ return null;
396
+ }
397
+ // Identity binding: reject cards whose agentId does not match the requested agentId.
398
+ // Without this check a compromised registry could return a different agent's card,
399
+ // allowing key-confusion attacks.
400
+ if (card.agentId !== agentId) {
401
+ return null;
402
+ }
403
+ // Endpoint hardening: any URL field inside the card that a downstream
404
+ // caller might pass to fetch must pass the same SSRF gate as baseUrl
405
+ // (https-only, no userinfo, no literal private/loopback/IANA-special).
406
+ // Without this, a compromised registry could return
407
+ // `endpoint: "http://169.254.169.254/..."` (or stash the same in
408
+ // `capabilities.thirdPartyAudit.services[].endpoint`) and SSRF
409
+ // anyone who reads the field.
410
+ const allowPrivate = options?.allowPrivateHosts === true;
411
+ if (!isSafePublicUrl(card.endpoint, allowPrivate))
412
+ return null;
413
+ const auditSvcs = card.capabilities?.thirdPartyAudit?.services;
414
+ if (auditSvcs) {
415
+ for (const svc of auditSvcs) {
416
+ if (!isSafePublicUrl(svc.endpoint, allowPrivate))
417
+ return null;
418
+ }
419
+ }
420
+ return card;
421
+ }
422
+ catch {
423
+ return null;
424
+ }
425
+ }
426
+ /**
427
+ * Extract candidate signing keys from an Agent Card.
428
+ *
429
+ * Authority rule: presence of `keys.signing` (even when empty) is
430
+ * authoritative. Callers MUST treat the returned set as the complete list
431
+ * of acceptable signers — including the empty set, which means "key set
432
+ * published, no usable keys" and forbids any legacy bootstrap fallback.
433
+ *
434
+ * - `keys.signing` absent → fall back to legacy `publicKeyMultibase`
435
+ * - `keys.signing: []` → return [] (authoritative empty)
436
+ * - `keys.signing: [k..]` → parse each entry independently; malformed
437
+ * entries are skipped so a single bad entry
438
+ * cannot collapse the whole set to "legacy"
439
+ * and let a rotated-away bootstrap key pass.
440
+ */
441
+ export function extractCandidateKeys(card) {
442
+ if (card === null || typeof card !== "object" || Array.isArray(card)) {
443
+ return [];
444
+ }
445
+ const signing = card.keys?.signing;
446
+ if (signing !== undefined) {
447
+ // Runtime type guard: a malformed card where `signing` is an object/
448
+ // string would otherwise throw on `.slice()` and collapse to the
449
+ // legacy bootstrap fallback at the caller — defeating key rotation.
450
+ // Present-but-invalid → authoritative empty.
451
+ if (!Array.isArray(signing))
452
+ return [];
453
+ // Cap to MAX_PARSE_KEYS BEFORE the decode loop — base58 decode on
454
+ // poisoned cards with thousands of entries would otherwise burn CPU
455
+ // even though only the first 20 are ever used at verification time.
456
+ const limited = signing.slice(0, MAX_PARSE_KEYS);
457
+ const out = [];
458
+ for (const rawEntry of limited) {
459
+ // Each entry must be a plain object with string keyId/publicKeyMultibase
460
+ // and an allowlisted status. Anything else is skipped — never thrown —
461
+ // so a single malformed entry can't collapse the whole set.
462
+ if (rawEntry === null || typeof rawEntry !== "object" || Array.isArray(rawEntry))
463
+ continue;
464
+ const entry = rawEntry;
465
+ if (typeof entry.keyId !== "string" || typeof entry.publicKeyMultibase !== "string")
466
+ continue;
467
+ if (entry.status !== "active" && entry.status !== "retired" && entry.status !== "revoked") {
468
+ continue;
469
+ }
470
+ // Carry validity-window fields through to the verifier so it can
471
+ // reject messages whose timestamp is outside the window. Each
472
+ // window field is OPTIONAL but if present it must be a non-empty
473
+ // parseable ISO 8601 datetime string. A present-but-malformed
474
+ // window field on the card is suspicious — it could be a
475
+ // deliberate attempt to "blank out" an expiry — so we skip the
476
+ // WHOLE entry instead of dropping just the field. The verifier's
477
+ // own defense-in-depth check would also refuse it, but rejecting
478
+ // here means downstream consumers never see a degraded key.
479
+ const accept = (x) => {
480
+ if (x === undefined)
481
+ return true;
482
+ // 64-char cap mirrors every other timestamp gate in INK and
483
+ // keeps Date.parse cost bounded for adversarial cards.
484
+ return (typeof x === "string" &&
485
+ x.length > 0 &&
486
+ x.length <= 64 &&
487
+ Number.isFinite(Date.parse(x)));
488
+ };
489
+ if (!accept(entry.validFrom) || !accept(entry.validUntil) || !accept(entry.revokedAt)) {
490
+ continue;
491
+ }
492
+ try {
493
+ out.push({
494
+ keyId: entry.keyId,
495
+ publicKey: decodePublicKeyMultibase(entry.publicKeyMultibase),
496
+ status: entry.status,
497
+ validFrom: typeof entry.validFrom === "string" ? entry.validFrom : undefined,
498
+ validUntil: typeof entry.validUntil === "string" ? entry.validUntil : undefined,
499
+ revokedAt: typeof entry.revokedAt === "string" ? entry.revokedAt : undefined,
500
+ });
501
+ }
502
+ catch {
503
+ // Skip malformed entry; do not collapse the whole set to legacy.
504
+ }
505
+ }
506
+ return out;
507
+ }
508
+ // Legacy card (no `keys.signing` block at all): single key.
509
+ // Wrap the decode so a malformed legacy `publicKeyMultibase` returns []
510
+ // instead of throwing — callers processing an untrusted card would
511
+ // otherwise crash. [] is the correct "no usable keys" signal here
512
+ // because the card itself was observed (presence of `card`); callers
513
+ // treat that as authoritative and won't fall back to bootstrap.
514
+ if (typeof card.publicKeyMultibase !== "string")
515
+ return [];
516
+ try {
517
+ return [
518
+ {
519
+ keyId: "legacy",
520
+ publicKey: decodePublicKeyMultibase(card.publicKeyMultibase),
521
+ status: "active",
522
+ },
523
+ ];
524
+ }
525
+ catch {
526
+ return [];
527
+ }
528
+ }
529
+ /**
530
+ * Resolve a well-known discovery base URL for an agent handle.
531
+ *
532
+ * INK does not mandate a single discovery origin — handle → base URL
533
+ * resolution is integrator-specific. Implementations typically use one of:
534
+ *
535
+ * - DNS TXT record at `_ink.<handle>` (planned)
536
+ * - HTTPS .well-known lookup at `https://<handle>/.well-known/ink/agent.json`
537
+ * - A platform-specific registry maintained by a host service
538
+ *
539
+ * Pass a `resolveBase` callback at integration time. Returning null defers
540
+ * to the caller's fallback (e.g. an explicit endpoint in the Agent Card
541
+ * itself).
542
+ */
543
+ export function resolveBaseUrl(handle, resolveBase) {
544
+ return resolveBase ? resolveBase(handle) : null;
545
+ }
@@ -0,0 +1,12 @@
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
+ export { signMessage, verifyMessage } from "./crypto/sign.js";
3
+ export { verifyInkSignatureWithKeys } from "./crypto/multi-key-verify.js";
4
+ export { generateKeypair, generateEncryptionKeypair, deriveAgentId, encodePublicKeyMultibase, decodePublicKeyMultibase, extractPublicKeyFromAgentId, } from "./crypto/keys.js";
5
+ export { fetchAgentCard, extractCandidateKeys, resolveBaseUrl, } from "./discovery/agent-card.js";
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";
8
+ export { HandshakeBudgetTracker } from "./ink/handshake-budget.js";
9
+ export type { InkSignInput } from "./crypto/ink.js";
10
+ export type { CandidateKey } from "./models/key-entry.js";
11
+ export type { AgentCard } from "./models/agent-card.js";
12
+ export type { BudgetCheckResult, HandshakeBudgetConfig, } from "./ink/handshake-budget.js";
package/dist/index.js ADDED
@@ -0,0 +1,15 @@
1
+ // Public entry point for the @adastracomputing/ink package.
2
+ // Re-exports the stable surface so consumers can import from the package root.
3
+ // Crypto: signing, verification, key encoding
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
+ export { signMessage, verifyMessage } from "./crypto/sign.js";
6
+ export { verifyInkSignatureWithKeys } from "./crypto/multi-key-verify.js";
7
+ export { generateKeypair, generateEncryptionKeypair, deriveAgentId, encodePublicKeyMultibase, decodePublicKeyMultibase, extractPublicKeyFromAgentId, } from "./crypto/keys.js";
8
+ // Discovery: Agent Card fetch + candidate-key extraction
9
+ export { fetchAgentCard, extractCandidateKeys, resolveBaseUrl, } from "./discovery/agent-card.js";
10
+ // Middleware: transport-level INK auth
11
+ export { verifyInkAuth } from "./middleware/ink-auth.js";
12
+ // Audit: inclusion-receipt + audit-query-response verification
13
+ export { verifyInclusionReceipt, verifyAuditQueryResponse, } from "./audit/inclusion-receipt.js";
14
+ // Optional containment / governance primitives
15
+ export { HandshakeBudgetTracker } from "./ink/handshake-budget.js";
@@ -0,0 +1,19 @@
1
+ /**
2
+ * INK Checkpoint formatting (C2SP tlog-checkpoint compatible).
3
+ * Used for the public checkpoint endpoint (INK Auditability §7.7).
4
+ */
5
+ export interface CheckpointData {
6
+ origin: string;
7
+ treeSize: number;
8
+ rootHash: string;
9
+ }
10
+ /**
11
+ * Format a checkpoint body per C2SP tlog-checkpoint spec:
12
+ * line 1: origin (log identity)
13
+ * line 2: tree size (decimal)
14
+ * line 3: root hash (hex)
15
+ * line 4: empty (trailing newline)
16
+ */
17
+ export declare function formatCheckpoint(data: CheckpointData): string;
18
+ /** Parse a checkpoint body. Returns null if invalid. */
19
+ export declare function parseCheckpoint(body: string): CheckpointData | null;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * INK Checkpoint formatting (C2SP tlog-checkpoint compatible).
3
+ * Used for the public checkpoint endpoint (INK Auditability §7.7).
4
+ */
5
+ /**
6
+ * Format a checkpoint body per C2SP tlog-checkpoint spec:
7
+ * line 1: origin (log identity)
8
+ * line 2: tree size (decimal)
9
+ * line 3: root hash (hex)
10
+ * line 4: empty (trailing newline)
11
+ */
12
+ export function formatCheckpoint(data) {
13
+ return `${data.origin}\n${data.treeSize}\n${data.rootHash}\n`;
14
+ }
15
+ /** Maximum input size for parseCheckpoint. A real checkpoint is:
16
+ * origin (up to ~256 chars) + "\n" + treeSize (up to 16 chars) + "\n"
17
+ * + rootHash (exactly 64 chars) + "\n" + final "" => ≤ ~340 chars.
18
+ * 1024 leaves comfortable headroom while bounding the body cap so a
19
+ * caller that hands us an attacker-controlled checkpoint blob can't
20
+ * force String.split / regex / parseInt to scan megabytes before
21
+ * rejecting. The 256-char per-line cap below is defense-in-depth. */
22
+ const MAX_CHECKPOINT_BODY = 1024;
23
+ const MAX_CHECKPOINT_LINE = 256;
24
+ /** Parse a checkpoint body. Returns null if invalid. */
25
+ export function parseCheckpoint(body) {
26
+ // Reject oversized input BEFORE String.split allocates a partition
27
+ // array. A caller that fetches a checkpoint from an attacker-
28
+ // controlled witness should not pay megabyte allocation costs to
29
+ // discover it is malformed.
30
+ if (typeof body !== "string" || body.length === 0 || body.length > MAX_CHECKPOINT_BODY) {
31
+ return null;
32
+ }
33
+ const lines = body.split("\n");
34
+ // Expect exactly: origin, treeSize, rootHash, trailing newline (produces 4 parts).
35
+ // Strict equality (=== 4) rejects bodies with extra trailing junk or
36
+ // additional blank lines, eliminating parser differential with stricter
37
+ // verifiers (e.g. C2SP tlog-checkpoint reference implementations).
38
+ if (lines.length !== 4)
39
+ return null;
40
+ // The 4th part is the empty string after the final newline.
41
+ if (lines[3] !== "")
42
+ return null;
43
+ const origin = lines[0];
44
+ const treeSizeLine = lines[1];
45
+ const rootHash = lines[2];
46
+ // Per-line caps: each line must fit the per-line bound BEFORE its
47
+ // regex or parseInt scan. Without this, a single huge line that
48
+ // still split into the right number of parts could force regex
49
+ // catastrophic-backtracking-class work pre-reject.
50
+ if (origin.length > MAX_CHECKPOINT_LINE)
51
+ return null;
52
+ if (treeSizeLine.length > MAX_CHECKPOINT_LINE)
53
+ return null;
54
+ if (rootHash.length > MAX_CHECKPOINT_LINE)
55
+ return null;
56
+ // Origin must be non-empty
57
+ if (!origin)
58
+ return null;
59
+ // Tree size must be a non-negative safe integer with no trailing junk
60
+ if (!/^\d+$/.test(treeSizeLine))
61
+ return null;
62
+ const treeSize = parseInt(treeSizeLine, 10);
63
+ if (isNaN(treeSize) || treeSize < 0 || treeSize > Number.MAX_SAFE_INTEGER)
64
+ return null;
65
+ // Root hash must be exactly 64 lowercase hex chars
66
+ if (!/^[0-9a-f]{64}$/.test(rootHash))
67
+ return null;
68
+ return { origin, treeSize, rootHash };
69
+ }