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