@adastracomputing/ink 0.1.0-alpha.3 → 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 (61) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/audit/inclusion-receipt.d.ts +142 -0
  3. package/dist/audit/inclusion-receipt.js +496 -0
  4. package/dist/crypto/ink.d.ts +178 -0
  5. package/dist/crypto/ink.js +915 -0
  6. package/dist/crypto/keys.d.ts +42 -0
  7. package/dist/crypto/keys.js +179 -0
  8. package/dist/crypto/multi-key-verify.d.ts +29 -0
  9. package/dist/crypto/multi-key-verify.js +153 -0
  10. package/dist/crypto/sign.d.ts +17 -0
  11. package/dist/crypto/sign.js +152 -0
  12. package/dist/crypto/verify.js +1 -0
  13. package/dist/discovery/agent-card.d.ts +83 -0
  14. package/dist/discovery/agent-card.js +545 -0
  15. package/dist/index.d.ts +12 -0
  16. package/dist/index.js +15 -0
  17. package/dist/ink/checkpoint.d.ts +19 -0
  18. package/dist/ink/checkpoint.js +69 -0
  19. package/dist/ink/discovery-gating.d.ts +237 -0
  20. package/dist/ink/discovery-gating.js +91 -0
  21. package/dist/ink/handshake-budget.d.ts +90 -0
  22. package/dist/ink/handshake-budget.js +397 -0
  23. package/dist/ink/receipts.d.ts +31 -0
  24. package/dist/ink/receipts.js +89 -0
  25. package/dist/ink/transport-auth.d.ts +47 -0
  26. package/dist/ink/transport-auth.js +77 -0
  27. package/dist/middleware/ink-auth.d.ts +68 -0
  28. package/dist/middleware/ink-auth.js +214 -0
  29. package/dist/models/agent-card.d.ts +154 -0
  30. package/dist/models/agent-card.js +59 -0
  31. package/dist/models/ink-audit.d.ts +344 -0
  32. package/dist/models/ink-audit.js +167 -0
  33. package/dist/models/ink-handshake.d.ts +129 -0
  34. package/dist/models/ink-handshake.js +89 -0
  35. package/dist/models/intent.d.ts +437 -0
  36. package/dist/models/intent.js +172 -0
  37. package/dist/models/key-entry.d.ts +60 -0
  38. package/dist/models/key-entry.js +13 -0
  39. package/dist/models/profile.d.ts +61 -0
  40. package/dist/models/profile.js +24 -0
  41. package/package.json +15 -11
  42. package/src/audit/inclusion-receipt.ts +0 -604
  43. package/src/crypto/ink.ts +0 -1046
  44. package/src/crypto/keys.ts +0 -210
  45. package/src/crypto/multi-key-verify.ts +0 -170
  46. package/src/crypto/sign.ts +0 -155
  47. package/src/discovery/agent-card.ts +0 -508
  48. package/src/index.ts +0 -73
  49. package/src/ink/checkpoint.ts +0 -75
  50. package/src/ink/discovery-gating.ts +0 -147
  51. package/src/ink/handshake-budget.ts +0 -413
  52. package/src/ink/receipts.ts +0 -114
  53. package/src/ink/transport-auth.ts +0 -96
  54. package/src/middleware/ink-auth.ts +0 -263
  55. package/src/models/agent-card.ts +0 -63
  56. package/src/models/ink-audit.ts +0 -205
  57. package/src/models/ink-handshake.ts +0 -123
  58. package/src/models/intent.ts +0 -201
  59. package/src/models/key-entry.ts +0 -52
  60. package/src/models/profile.ts +0 -31
  61. /package/{src/crypto/verify.ts → dist/crypto/verify.d.ts} +0 -0
@@ -0,0 +1,397 @@
1
+ /**
2
+ * Handshake flood resistance — per-correlation and per-sender budget tracking.
3
+ *
4
+ * Implements §5 of the INK Containment spec:
5
+ * - Per-correlation budgets: max challenges, terminal states, total transitions, TTL
6
+ * - Per-sender rate limits: sliding window for intents and total handshake messages
7
+ * - First violation returns typed rejection with backoff hint
8
+ * - Subsequent violations are silent drops
9
+ */
10
+ // ── Budget constants ──
11
+ const DEFAULT_MAX_CHALLENGES = 3;
12
+ const DEFAULT_MAX_TOTAL_TRANSITIONS = 5;
13
+ const DEFAULT_MAX_INTENTS_PER_MINUTE = 10;
14
+ const DEFAULT_MAX_HANDSHAKE_MSGS_PER_MINUTE = 30;
15
+ const DEFAULT_MAX_CORRELATIONS = 10_000;
16
+ const DEFAULT_MAX_SENDERS = 1_000;
17
+ const DEFAULT_MAX_REJECTION_ENTRIES = 5_000;
18
+ const DEFAULT_PRUNE_INTERVAL = 100;
19
+ const DEFAULT_HANDSHAKE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
20
+ // Cap correlationId and fromDid lengths to prevent memory exhaustion via large IDs.
21
+ // Real correlation IDs are UUIDs (~36 chars); agent DIDs are ~50-100 chars.
22
+ // 256 is generous headroom while bounding per-entry memory cost.
23
+ const MAX_ID_LENGTH = 256;
24
+ const SENDER_REJECTION_WINDOW_MS = 60_000;
25
+ const TERMINAL_TYPES = new Set(["rejection", "resolution"]);
26
+ // ── Budget tracker ──
27
+ export class HandshakeBudgetTracker {
28
+ correlations = new Map();
29
+ senders = new Map();
30
+ rejectionsSent = new Set(); // "${correlationId}:${fromDid}"
31
+ // Tracks when a sender last received a rate-limit rejection. Subsequent
32
+ // violations within SENDER_REJECTION_WINDOW_MS are silent-dropped to prevent
33
+ // an over-limit sender from forcing repeated reject/backoff responses.
34
+ senderRejectionsSent = new Map();
35
+ maxChallenges;
36
+ maxTotalTransitions;
37
+ maxIntentsPerMinute;
38
+ maxHandshakeMsgsPerMinute;
39
+ maxCorrelations;
40
+ maxSenders;
41
+ maxRejectionEntries;
42
+ checkCounter = 0;
43
+ constructor(config = {}) {
44
+ const pos = (v, def, cap) => {
45
+ if (typeof v !== "number" || !Number.isFinite(v) || v <= 0)
46
+ return def;
47
+ return Math.min(Math.floor(v), cap);
48
+ };
49
+ this.maxChallenges = pos(config.maxChallenges, DEFAULT_MAX_CHALLENGES, 1_000);
50
+ this.maxTotalTransitions = pos(config.maxTotalTransitions, DEFAULT_MAX_TOTAL_TRANSITIONS, 10_000);
51
+ this.maxIntentsPerMinute = pos(config.maxIntentsPerMinute, DEFAULT_MAX_INTENTS_PER_MINUTE, 100_000);
52
+ this.maxHandshakeMsgsPerMinute = pos(config.maxHandshakeMsgsPerMinute, DEFAULT_MAX_HANDSHAKE_MSGS_PER_MINUTE, 100_000);
53
+ this.maxCorrelations = pos(config.maxCorrelations, DEFAULT_MAX_CORRELATIONS, 1_000_000);
54
+ this.maxSenders = pos(config.maxSenders, DEFAULT_MAX_SENDERS, 1_000_000);
55
+ this.maxRejectionEntries = pos(config.maxRejectionEntries, DEFAULT_MAX_REJECTION_ENTRIES, 1_000_000);
56
+ }
57
+ /**
58
+ * Side-effect-free budget check. Returns the same BudgetCheckResult as
59
+ * checkAndRecord without mutating any internal state: no sender activity
60
+ * recorded, no correlation row created for an unknown intentRef, no
61
+ * transition counter incremented, no terminal flag set, and no rejection
62
+ * bookkeeping (rejectionsSent / senderRejectionsSent) advanced. Use this
63
+ * when you want to gate on budget BEFORE running expensive validation
64
+ * (signature verification, state-machine), then call recordAccepted()
65
+ * only on acceptance.
66
+ *
67
+ * Without the split, an invalid signed envelope for a known intentRef
68
+ * could pass the budget check, mark the correlation as terminal, then
69
+ * fail downstream validation — blocking later legitimate traffic for
70
+ * that correlation. Integrators that want the original eager-commit
71
+ * semantics can keep calling checkAndRecord.
72
+ */
73
+ check(params) {
74
+ return this.checkOrRecord(params, /* commit */ false);
75
+ }
76
+ /**
77
+ * Commit a previously-checked acceptance. The check() return value is
78
+ * not load-bearing here — this method runs the same checks again and
79
+ * applies the mutation. Callers are responsible for not re-running
80
+ * recordAccepted() on the same message; a nonce cache typically
81
+ * prevents that path.
82
+ */
83
+ recordAccepted(params) {
84
+ return this.checkOrRecord(params, /* commit */ true);
85
+ }
86
+ checkAndRecord(params) {
87
+ return this.checkOrRecord(params, /* commit */ true);
88
+ }
89
+ checkOrRecord(params, commit) {
90
+ const { correlationId, fromDid, messageType, intentExpiresAt } = params;
91
+ const now = Date.now();
92
+ // Reject unbounded IDs before they hit Map keys / Set entries — caps the
93
+ // per-entry memory cost regardless of the maxCorrelations / maxSenders
94
+ // count caps. Stringifying the whole pair amplifies the attack surface.
95
+ if (typeof correlationId !== "string" || correlationId.length > MAX_ID_LENGTH ||
96
+ typeof fromDid !== "string" || fromDid.length > MAX_ID_LENGTH) {
97
+ return { allowed: false, reason: "handshake_budget_exhausted", silentDrop: true };
98
+ }
99
+ // Use JSON encoding to prevent key collisions when IDs contain colons.
100
+ // e.g. correlationId="a:b", fromDid="c" vs correlationId="a", fromDid="b:c"
101
+ // would both produce "a:b:c" with naive string concatenation.
102
+ const pairKey = JSON.stringify([correlationId, fromDid]);
103
+ // Periodic pruning of expired state
104
+ this.checkCounter++;
105
+ if (this.checkCounter >= DEFAULT_PRUNE_INTERVAL) {
106
+ this.checkCounter = 0;
107
+ this.pruneExpired();
108
+ }
109
+ // Check per-sender rate limits first (applies across all correlations)
110
+ const senderResult = this.checkSenderLimits(fromDid, messageType, now, commit);
111
+ if (!senderResult.allowed) {
112
+ return senderResult;
113
+ }
114
+ // Record sender activity FIRST so per-sender limits accumulate on every
115
+ // syntactically valid attempt — including ones that fail downstream
116
+ // budget checks. Otherwise an attacker varying correlationId can trigger
117
+ // unlimited typed rejections without ever hitting the per-sender cap.
118
+ // Skip in check-only mode so the public check() is side-effect free.
119
+ if (commit) {
120
+ this.recordSenderActivity(fromDid, messageType, now);
121
+ }
122
+ // Check TTL from intent expiry.
123
+ // Distinguish "field absent" (undefined) from "field present but
124
+ // malformed/empty". An intent that supplies `intentExpiresAt: ""`
125
+ // is malformed — treat it as a rejected handshake instead of
126
+ // falling through to the default 24h TTL (which would let
127
+ // attacker-supplied empty expiries retain state longer than the
128
+ // sender's claimed window). Also guard against NaN: new
129
+ // Date("garbage").getTime() returns NaN and NaN <= now is false.
130
+ let parsedExpiryMs = null;
131
+ if (intentExpiresAt !== undefined) {
132
+ // Length cap matches the timestamp cap used everywhere else in
133
+ // INK (64 chars, well above any real ISO 8601 string). Without
134
+ // this, a sender can submit a multi-megabyte expiry string and
135
+ // force the JS engine into a long Date parser run before the
136
+ // budget tracker rejects.
137
+ if (typeof intentExpiresAt !== "string" ||
138
+ intentExpiresAt.length === 0 ||
139
+ intentExpiresAt.length > 64) {
140
+ return this.makeRejection(pairKey, "handshake_budget_exhausted", {
141
+ backoffClass: "intent_ref",
142
+ }, commit);
143
+ }
144
+ const expiryMs = new Date(intentExpiresAt).getTime();
145
+ if (!Number.isFinite(expiryMs) || expiryMs <= now) {
146
+ return this.makeRejection(pairKey, "handshake_budget_exhausted", {
147
+ backoffClass: "intent_ref",
148
+ }, commit);
149
+ }
150
+ parsedExpiryMs = expiryMs;
151
+ }
152
+ // Get or create correlation state. KEY BY pairKey, not correlationId,
153
+ // so two senders that happen to use the same correlationId can't
154
+ // consume each other's transitions or set terminal state on each
155
+ // other's handshake.
156
+ let state = this.correlations.get(pairKey);
157
+ if (!state) {
158
+ if (!commit) {
159
+ // Check-only mode does NOT create a row for an unknown
160
+ // correlation — that would itself be a side effect and would
161
+ // let unauthenticated traffic balloon the in-memory map.
162
+ return { allowed: true };
163
+ }
164
+ // Enforce memory bounds before creating new entry
165
+ this.enforceMemoryBounds();
166
+ // Reuse the parsed expiry from above instead of re-parsing.
167
+ const ttl = parsedExpiryMs !== null
168
+ ? Math.min(parsedExpiryMs, now + DEFAULT_HANDSHAKE_TTL_MS)
169
+ : now + DEFAULT_HANDSHAKE_TTL_MS;
170
+ state = {
171
+ challenges: 0,
172
+ totalTransitions: 0,
173
+ terminal: false,
174
+ expiresAt: ttl,
175
+ createdAt: now,
176
+ };
177
+ this.correlations.set(pairKey, state);
178
+ }
179
+ // Check if correlation has expired
180
+ if (state.expiresAt <= now) {
181
+ return this.makeRejection(pairKey, "handshake_budget_exhausted", {
182
+ backoffClass: "intent_ref",
183
+ }, commit);
184
+ }
185
+ // Check if terminal state was already reached
186
+ if (state.terminal) {
187
+ return this.makeRejection(pairKey, "handshake_budget_exhausted", {
188
+ backoffClass: "intent_ref",
189
+ }, commit);
190
+ }
191
+ // Check total transitions
192
+ if (state.totalTransitions >= this.maxTotalTransitions) {
193
+ return this.makeRejection(pairKey, "handshake_budget_exhausted", {
194
+ backoffClass: "intent_ref",
195
+ }, commit);
196
+ }
197
+ // Check per-type limits
198
+ if (messageType === "challenge" && state.challenges >= this.maxChallenges) {
199
+ return this.makeRejection(pairKey, "handshake_budget_exhausted", {
200
+ backoffClass: "intent_ref",
201
+ }, commit);
202
+ }
203
+ if (!commit) {
204
+ return { allowed: true };
205
+ }
206
+ // Record the message
207
+ state.totalTransitions++;
208
+ if (messageType === "challenge") {
209
+ state.challenges++;
210
+ }
211
+ if (TERMINAL_TYPES.has(messageType)) {
212
+ state.terminal = true;
213
+ }
214
+ // Sender activity was already recorded above (before the budget checks)
215
+ // so per-sender limits accumulate even on rejected attempts.
216
+ return { allowed: true };
217
+ }
218
+ pruneExpired() {
219
+ const now = Date.now();
220
+ for (const [pairKey, state] of this.correlations) {
221
+ if (state.expiresAt <= now) {
222
+ this.correlations.delete(pairKey);
223
+ // Rejection tracking is keyed by the same pairKey, so we can drop
224
+ // it directly.
225
+ this.rejectionsSent.delete(pairKey);
226
+ }
227
+ }
228
+ // Prune stale sender windows
229
+ const oneMinuteAgo = now - 60_000;
230
+ for (const [did, state] of this.senders) {
231
+ state.intentTimestamps = state.intentTimestamps.filter((t) => t > oneMinuteAgo);
232
+ state.handshakeTimestamps = state.handshakeTimestamps.filter((t) => t > oneMinuteAgo);
233
+ if (state.intentTimestamps.length === 0 && state.handshakeTimestamps.length === 0) {
234
+ this.senders.delete(did);
235
+ }
236
+ }
237
+ // Prune sender-rejection records older than the silent-drop window.
238
+ for (const [did, ts] of this.senderRejectionsSent) {
239
+ if (now - ts >= SENDER_REJECTION_WINDOW_MS) {
240
+ this.senderRejectionsSent.delete(did);
241
+ }
242
+ }
243
+ }
244
+ checkSenderLimits(fromDid, messageType, now, commit) {
245
+ const state = this.senders.get(fromDid);
246
+ if (!state)
247
+ return { allowed: true };
248
+ const oneMinuteAgo = now - 60_000;
249
+ // Check per-minute intent limit
250
+ if (messageType === "intent") {
251
+ const recentIntents = state.intentTimestamps.filter((t) => t > oneMinuteAgo);
252
+ if (recentIntents.length >= this.maxIntentsPerMinute) {
253
+ return this.makeSenderRejection(fromDid, now, commit);
254
+ }
255
+ }
256
+ // Check per-minute total handshake message limit
257
+ const recentHandshake = state.handshakeTimestamps.filter((t) => t > oneMinuteAgo);
258
+ if (recentHandshake.length >= this.maxHandshakeMsgsPerMinute) {
259
+ return this.makeSenderRejection(fromDid, now, commit);
260
+ }
261
+ return { allowed: true };
262
+ }
263
+ // Sender-level rate-limit rejection. Sends a typed reject (with backoff hint)
264
+ // the first time a sender crosses the limit in the current window; silent-drops
265
+ // subsequent violations until the window resets. Mirrors the per-correlation
266
+ // makeRejection pattern from §5 of the INK Containment spec.
267
+ //
268
+ // When commit=false the rejection shape is computed without touching
269
+ // senderRejectionsSent, so check() can peek at the rate-limit state
270
+ // without burning the "typed rejection" budget for that sender.
271
+ makeSenderRejection(fromDid, now, commit) {
272
+ const lastSent = this.senderRejectionsSent.get(fromDid);
273
+ if (lastSent !== undefined && now - lastSent < SENDER_REJECTION_WINDOW_MS) {
274
+ return { allowed: false, reason: "sender_rate_limited", silentDrop: true };
275
+ }
276
+ if (!commit) {
277
+ // Observation-only: would have been a typed rejection on a commit
278
+ // path, but the silent-drop state machine is not advanced here.
279
+ return {
280
+ allowed: false,
281
+ reason: "sender_rate_limited",
282
+ backoffHint: { retryAfterSeconds: 60, backoffClass: "sender" },
283
+ silentDrop: false,
284
+ };
285
+ }
286
+ // Bound the map by maxSenders to prevent attacker-driven growth. Evict the
287
+ // oldest record if at capacity; the pruneExpired pass also cleans up
288
+ // records older than the silent-drop window.
289
+ if (this.senderRejectionsSent.size >= this.maxSenders &&
290
+ !this.senderRejectionsSent.has(fromDid)) {
291
+ let oldestDid = null;
292
+ let oldestTs = Infinity;
293
+ for (const [d, t] of this.senderRejectionsSent) {
294
+ if (t < oldestTs) {
295
+ oldestTs = t;
296
+ oldestDid = d;
297
+ }
298
+ }
299
+ if (oldestDid)
300
+ this.senderRejectionsSent.delete(oldestDid);
301
+ }
302
+ this.senderRejectionsSent.set(fromDid, now);
303
+ return {
304
+ allowed: false,
305
+ reason: "sender_rate_limited",
306
+ backoffHint: { retryAfterSeconds: 60, backoffClass: "sender" },
307
+ silentDrop: false,
308
+ };
309
+ }
310
+ recordSenderActivity(fromDid, messageType, now) {
311
+ let state = this.senders.get(fromDid);
312
+ if (!state) {
313
+ // Enforce sender cap before adding a new entry
314
+ this.enforceSenderBounds();
315
+ state = { intentTimestamps: [], handshakeTimestamps: [], lastActivity: now };
316
+ this.senders.set(fromDid, state);
317
+ }
318
+ state.lastActivity = now;
319
+ if (messageType === "intent") {
320
+ state.intentTimestamps.push(now);
321
+ }
322
+ state.handshakeTimestamps.push(now);
323
+ }
324
+ makeRejection(pairKey, reason, backoffHint, commit) {
325
+ if (this.rejectionsSent.has(pairKey)) {
326
+ return { allowed: false, reason, silentDrop: true };
327
+ }
328
+ if (!commit) {
329
+ // Observation-only: would have been the typed first-rejection on a
330
+ // commit path. rejectionsSent is left alone so that a subsequent
331
+ // recordAccepted (or checkAndRecord) for the same pairKey still
332
+ // gets the "first typed" response — otherwise check() would burn
333
+ // the typed-rejection budget for any pairKey it ever probed.
334
+ return { allowed: false, reason, backoffHint, silentDrop: false };
335
+ }
336
+ this.rejectionsSent.add(pairKey);
337
+ this.enforceRejectionBounds();
338
+ return { allowed: false, reason, backoffHint, silentDrop: false };
339
+ }
340
+ enforceRejectionBounds() {
341
+ if (this.rejectionsSent.size <= this.maxRejectionEntries)
342
+ return;
343
+ // Prune rejection entries whose backing correlation no longer exists
344
+ // (already expired or evicted). Rejection keys and correlation keys are
345
+ // both pairKeys now, so the lookup is direct.
346
+ const now = Date.now();
347
+ for (const key of this.rejectionsSent) {
348
+ const state = this.correlations.get(key);
349
+ if (!state || state.expiresAt <= now) {
350
+ this.rejectionsSent.delete(key);
351
+ }
352
+ }
353
+ // If still over limit, clear the oldest half (Set maintains insertion order)
354
+ if (this.rejectionsSent.size > this.maxRejectionEntries) {
355
+ const entries = [...this.rejectionsSent];
356
+ const keepFrom = Math.floor(entries.length / 2);
357
+ this.rejectionsSent.clear();
358
+ for (let i = keepFrom; i < entries.length; i++) {
359
+ this.rejectionsSent.add(entries[i]);
360
+ }
361
+ }
362
+ }
363
+ enforceSenderBounds() {
364
+ if (this.senders.size < this.maxSenders)
365
+ return;
366
+ // Evict sender with oldest lastActivity
367
+ let oldestDid = null;
368
+ let oldestTime = Infinity;
369
+ for (const [did, state] of this.senders) {
370
+ if (state.lastActivity < oldestTime) {
371
+ oldestTime = state.lastActivity;
372
+ oldestDid = did;
373
+ }
374
+ }
375
+ if (oldestDid) {
376
+ this.senders.delete(oldestDid);
377
+ }
378
+ }
379
+ enforceMemoryBounds() {
380
+ if (this.correlations.size < this.maxCorrelations)
381
+ return;
382
+ // Evict oldest entry (by createdAt)
383
+ let oldestKey = null;
384
+ let oldestTime = Infinity;
385
+ for (const [key, state] of this.correlations) {
386
+ if (state.createdAt < oldestTime) {
387
+ oldestTime = state.createdAt;
388
+ oldestKey = key;
389
+ }
390
+ }
391
+ if (oldestKey) {
392
+ this.correlations.delete(oldestKey);
393
+ // Rejection tracking is keyed by the same pairKey, so drop directly.
394
+ this.rejectionsSent.delete(oldestKey);
395
+ }
396
+ }
397
+ }
@@ -0,0 +1,31 @@
1
+ import type { InkReceipt } from "../models/ink-audit.js";
2
+ export interface BuildReceiptInput {
3
+ from: string;
4
+ to: string;
5
+ messageId: string;
6
+ messageBody: Record<string, unknown>;
7
+ disposition: "received" | "delivered" | "acted" | "rejected";
8
+ note?: string;
9
+ privateKey: Uint8Array;
10
+ }
11
+ /** Build a signed INK receipt envelope. */
12
+ export declare function buildReceipt(input: BuildReceiptInput): Promise<InkReceipt>;
13
+ export declare function shouldSendReceipt(intentOrType: string): boolean;
14
+ export interface SendReceiptOptions {
15
+ /** Allow endpoints whose hostname is loopback / private / link-local /
16
+ * IANA special-use. Off by default — flip on only for tests or for
17
+ * intentional intranet deployments where peer endpoints are trusted. */
18
+ allowPrivateHosts?: boolean;
19
+ }
20
+ /** Fire-and-forget POST of a receipt with INK request signature. Never throws.
21
+ *
22
+ * Endpoint MUST be an absolute `https://` URL. Other schemes (file://, data:,
23
+ * blob:, http://) are rejected silently to prevent SSRF and local-file
24
+ * exfiltration when integrators pass peer-supplied URLs without sanitising.
25
+ *
26
+ * Mirrors the SSRF defenses in fetchAgentCard: https-only, no userinfo,
27
+ * literal-hostname allowlist excluding private/loopback/special-use, no
28
+ * redirect following, request timeout. DNS rebinding is still the
29
+ * integrator's responsibility — pass a connect-time-pinning `fetchFn`
30
+ * when the endpoint is not fully trusted. */
31
+ export declare function sendReceiptFireAndForget(endpoint: string, receipt: InkReceipt, privateKey: Uint8Array, fetchFn?: typeof fetch, signingKeyId?: string, options?: SendReceiptOptions): Promise<void>;
@@ -0,0 +1,89 @@
1
+ import { computeMessageHash, signInkMessage, buildAuthHeader } from "../crypto/ink.js";
2
+ import { signMessage } from "../crypto/sign.js";
3
+ import { isPrivateHostname } from "../discovery/agent-card.js";
4
+ /** Build a signed INK receipt envelope. */
5
+ export async function buildReceipt(input) {
6
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
7
+ throw new Error("input must be a non-null object");
8
+ }
9
+ if (!(input.privateKey instanceof Uint8Array) || input.privateKey.length !== 32) {
10
+ throw new Error("input.privateKey must be a 32-byte Uint8Array");
11
+ }
12
+ const now = new Date().toISOString();
13
+ const messageHash = await computeMessageHash(input.messageBody);
14
+ const nonce = crypto.randomUUID().replace(/-/g, "");
15
+ const unsigned = {
16
+ protocol: "ink/0.1",
17
+ type: "network.tulpa.receipt",
18
+ from: input.from,
19
+ to: input.to,
20
+ messageId: input.messageId,
21
+ disposition: input.disposition,
22
+ dispositionAt: now,
23
+ messageHash,
24
+ nonce,
25
+ timestamp: now,
26
+ ...(input.note ? { note: input.note } : {}),
27
+ };
28
+ const signature = await signMessage(unsigned, input.privateKey);
29
+ return { ...unsigned, signature };
30
+ }
31
+ /** Loop prevention: don't send receipts for receipts or audit messages. */
32
+ const NO_RECEIPT_TYPES = new Set([
33
+ "network.tulpa.receipt",
34
+ "network.tulpa.audit_query",
35
+ "network.tulpa.audit_response",
36
+ "network.tulpa.audit_submit",
37
+ "network.tulpa.audit_inclusion",
38
+ ]);
39
+ export function shouldSendReceipt(intentOrType) {
40
+ return !NO_RECEIPT_TYPES.has(intentOrType);
41
+ }
42
+ /** Fire-and-forget POST of a receipt with INK request signature. Never throws.
43
+ *
44
+ * Endpoint MUST be an absolute `https://` URL. Other schemes (file://, data:,
45
+ * blob:, http://) are rejected silently to prevent SSRF and local-file
46
+ * exfiltration when integrators pass peer-supplied URLs without sanitising.
47
+ *
48
+ * Mirrors the SSRF defenses in fetchAgentCard: https-only, no userinfo,
49
+ * literal-hostname allowlist excluding private/loopback/special-use, no
50
+ * redirect following, request timeout. DNS rebinding is still the
51
+ * integrator's responsibility — pass a connect-time-pinning `fetchFn`
52
+ * when the endpoint is not fully trusted. */
53
+ export async function sendReceiptFireAndForget(endpoint, receipt, privateKey, fetchFn = globalThis.fetch, signingKeyId, options) {
54
+ try {
55
+ let url;
56
+ try {
57
+ url = new URL(endpoint);
58
+ }
59
+ catch {
60
+ return;
61
+ }
62
+ if (url.protocol !== "https:")
63
+ return;
64
+ if (url.username || url.password)
65
+ return;
66
+ if (!options?.allowPrivateHosts && isPrivateHostname(url.hostname))
67
+ return;
68
+ const sig = await signInkMessage({
69
+ method: "POST",
70
+ path: url.pathname,
71
+ recipientDid: receipt.to,
72
+ body: receipt,
73
+ timestamp: receipt.timestamp,
74
+ }, privateKey);
75
+ await fetchFn(endpoint, {
76
+ method: "POST",
77
+ headers: {
78
+ "Content-Type": "application/json",
79
+ "Authorization": buildAuthHeader(sig, signingKeyId),
80
+ },
81
+ body: JSON.stringify(receipt),
82
+ redirect: "manual",
83
+ signal: AbortSignal.timeout(5000),
84
+ });
85
+ }
86
+ catch {
87
+ // Fire-and-forget — swallow errors
88
+ }
89
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Transport-bound authorization for INK delegation chains.
3
+ *
4
+ * Ensures delegation tokens are only valid on the transports they were
5
+ * issued for. Implements §7 of the INK Containment spec with version-gated
6
+ * migration for legacy tokens.
7
+ */
8
+ import type { InkTransport } from "../models/ink-handshake.js";
9
+ /**
10
+ * Permissive transport set for legacy tokens during the 90-day migration window.
11
+ * Matches the set of transports that existed before transport scoping was introduced.
12
+ */
13
+ export declare const LEGACY_MIGRATION_TRANSPORTS: InkTransport[];
14
+ /**
15
+ * Hard deadline for the legacy transport migration window.
16
+ * After this date, tokens without tokenVersion get the strict default.
17
+ */
18
+ export declare const LEGACY_MIGRATION_DEADLINE: Date;
19
+ /**
20
+ * Resolve the effective allowed transports for a delegation token.
21
+ *
22
+ * Rules (per spec §1.2):
23
+ * - Explicit allowedTransports always wins
24
+ * - v0.3+ tokens without allowedTransports default to ["ink_http"]
25
+ * - Legacy tokens (no tokenVersion) default to permissive set before the
26
+ * migration deadline (2026-07-01), then ["ink_http"] after
27
+ */
28
+ export declare function resolveEffectiveTransports(allowedTransports: InkTransport[] | undefined, tokenVersion: string | undefined, now?: Date): InkTransport[];
29
+ /**
30
+ * Check if the current invocation transport is allowed by the delegation token.
31
+ */
32
+ export declare function checkTransportAllowed(currentTransport: InkTransport, allowedTransports: InkTransport[]): {
33
+ allowed: true;
34
+ } | {
35
+ allowed: false;
36
+ reason: "transport_scope_violation";
37
+ };
38
+ /**
39
+ * Check that a child delegation hop's transports are a subset of the parent's.
40
+ * Each hop can only narrow, never widen the transport scope.
41
+ */
42
+ export declare function checkTransportAttenuation(parentTransports: InkTransport[], childTransports: InkTransport[]): {
43
+ valid: true;
44
+ } | {
45
+ valid: false;
46
+ addedTransports: InkTransport[];
47
+ };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Transport-bound authorization for INK delegation chains.
3
+ *
4
+ * Ensures delegation tokens are only valid on the transports they were
5
+ * issued for. Implements §7 of the INK Containment spec with version-gated
6
+ * migration for legacy tokens.
7
+ */
8
+ /**
9
+ * Permissive transport set for legacy tokens during the 90-day migration window.
10
+ * Matches the set of transports that existed before transport scoping was introduced.
11
+ */
12
+ export const LEGACY_MIGRATION_TRANSPORTS = [
13
+ "ink_http",
14
+ "extension_api",
15
+ "voice",
16
+ "line_phone",
17
+ ];
18
+ /**
19
+ * Hard deadline for the legacy transport migration window.
20
+ * After this date, tokens without tokenVersion get the strict default.
21
+ */
22
+ export const LEGACY_MIGRATION_DEADLINE = new Date("2026-07-01T00:00:00Z");
23
+ /**
24
+ * Resolve the effective allowed transports for a delegation token.
25
+ *
26
+ * Rules (per spec §1.2):
27
+ * - Explicit allowedTransports always wins
28
+ * - v0.3+ tokens without allowedTransports default to ["ink_http"]
29
+ * - Legacy tokens (no tokenVersion) default to permissive set before the
30
+ * migration deadline (2026-07-01), then ["ink_http"] after
31
+ */
32
+ export function resolveEffectiveTransports(allowedTransports, tokenVersion, now = new Date()) {
33
+ // Distinguish "field absent" from "field present and empty". An
34
+ // explicit empty array is a "this token allows no transports"
35
+ // statement — it MUST stay empty, never fall through to the legacy
36
+ // permissive set. Treating [] as "absent" would broaden a token that
37
+ // its issuer intended to deny.
38
+ if (Array.isArray(allowedTransports)) {
39
+ return [...allowedTransports];
40
+ }
41
+ // v0.3+ token without explicit transports: strict default.
42
+ // Distinguish "field absent" (undefined) from "field present but
43
+ // malformed/empty". A token that supplies `tokenVersion: ""` is not
44
+ // a legacy token — it is a malformed new token. Treat anything
45
+ // present-and-not-undefined as a new token so a bad version string
46
+ // can't broaden transport scope during the migration window.
47
+ if (tokenVersion !== undefined) {
48
+ return ["ink_http"];
49
+ }
50
+ // Legacy token (tokenVersion truly absent): check against hard
51
+ // migration deadline.
52
+ if (now >= LEGACY_MIGRATION_DEADLINE) {
53
+ return ["ink_http"];
54
+ }
55
+ return [...LEGACY_MIGRATION_TRANSPORTS];
56
+ }
57
+ /**
58
+ * Check if the current invocation transport is allowed by the delegation token.
59
+ */
60
+ export function checkTransportAllowed(currentTransport, allowedTransports) {
61
+ if (allowedTransports.includes(currentTransport)) {
62
+ return { allowed: true };
63
+ }
64
+ return { allowed: false, reason: "transport_scope_violation" };
65
+ }
66
+ /**
67
+ * Check that a child delegation hop's transports are a subset of the parent's.
68
+ * Each hop can only narrow, never widen the transport scope.
69
+ */
70
+ export function checkTransportAttenuation(parentTransports, childTransports) {
71
+ const parentSet = new Set(parentTransports);
72
+ const added = childTransports.filter((t) => !parentSet.has(t));
73
+ if (added.length > 0) {
74
+ return { valid: false, addedTransports: added };
75
+ }
76
+ return { valid: true };
77
+ }