@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.
- package/CHANGELOG.md +56 -5
- package/CODE_OF_CONDUCT.md +1 -1
- package/README.md +7 -5
- package/SECURITY.md +1 -1
- package/bin/verify-inclusion-impl.mjs +4 -1
- package/dist/audit/inclusion-receipt.d.ts +142 -0
- package/dist/audit/inclusion-receipt.js +496 -0
- package/dist/crypto/ink.d.ts +178 -0
- package/dist/crypto/ink.js +915 -0
- package/dist/crypto/keys.d.ts +42 -0
- package/dist/crypto/keys.js +179 -0
- package/dist/crypto/multi-key-verify.d.ts +29 -0
- package/dist/crypto/multi-key-verify.js +153 -0
- package/dist/crypto/sign.d.ts +17 -0
- package/dist/crypto/sign.js +152 -0
- package/dist/crypto/verify.js +1 -0
- package/dist/discovery/agent-card.d.ts +83 -0
- package/dist/discovery/agent-card.js +545 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +15 -0
- package/dist/ink/checkpoint.d.ts +19 -0
- package/dist/ink/checkpoint.js +69 -0
- package/dist/ink/discovery-gating.d.ts +237 -0
- package/dist/ink/discovery-gating.js +91 -0
- package/dist/ink/handshake-budget.d.ts +90 -0
- package/dist/ink/handshake-budget.js +397 -0
- package/dist/ink/receipts.d.ts +31 -0
- package/dist/ink/receipts.js +89 -0
- package/dist/ink/transport-auth.d.ts +47 -0
- package/dist/ink/transport-auth.js +77 -0
- package/dist/middleware/ink-auth.d.ts +68 -0
- package/dist/middleware/ink-auth.js +214 -0
- package/dist/models/agent-card.d.ts +154 -0
- package/dist/models/agent-card.js +59 -0
- package/dist/models/ink-audit.d.ts +344 -0
- package/dist/models/ink-audit.js +167 -0
- package/dist/models/ink-handshake.d.ts +129 -0
- package/dist/models/ink-handshake.js +89 -0
- package/dist/models/intent.d.ts +437 -0
- package/dist/models/intent.js +172 -0
- package/dist/models/key-entry.d.ts +60 -0
- package/dist/models/key-entry.js +13 -0
- package/dist/models/profile.d.ts +61 -0
- package/dist/models/profile.js +24 -0
- package/docs/maturity.md +3 -3
- package/docs/threat-model.md +1 -1
- package/package.json +17 -13
- package/specs/ink-auditability.md +37 -12
- package/specs/ink-compliance-checklist.md +9 -1
- package/src/audit/inclusion-receipt.ts +0 -268
- package/src/crypto/ink.ts +0 -902
- package/src/crypto/keys.ts +0 -210
- package/src/crypto/multi-key-verify.ts +0 -170
- package/src/crypto/sign.ts +0 -155
- package/src/discovery/agent-card.ts +0 -508
- package/src/index.ts +0 -67
- package/src/ink/checkpoint.ts +0 -75
- package/src/ink/discovery-gating.ts +0 -147
- package/src/ink/handshake-budget.ts +0 -413
- package/src/ink/receipts.ts +0 -114
- package/src/ink/transport-auth.ts +0 -96
- package/src/middleware/ink-auth.ts +0 -263
- package/src/models/agent-card.ts +0 -63
- package/src/models/ink-audit.ts +0 -205
- package/src/models/ink-handshake.ts +0 -123
- package/src/models/intent.ts +0 -201
- package/src/models/key-entry.ts +0 -52
- package/src/models/profile.ts +0 -31
- /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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|