@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.
- package/CHANGELOG.md +24 -0
- 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/package.json +15 -11
- package/src/audit/inclusion-receipt.ts +0 -604
- package/src/crypto/ink.ts +0 -1046
- 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 -73
- 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,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
|
+
}
|