@adastracomputing/ink 0.1.0-alpha.3 → 0.1.1

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 (64) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +15 -3
  3. package/dist/audit/inclusion-receipt.d.ts +142 -0
  4. package/dist/audit/inclusion-receipt.js +496 -0
  5. package/dist/crypto/ink.d.ts +178 -0
  6. package/dist/crypto/ink.js +915 -0
  7. package/dist/crypto/keys.d.ts +42 -0
  8. package/dist/crypto/keys.js +179 -0
  9. package/dist/crypto/multi-key-verify.d.ts +29 -0
  10. package/dist/crypto/multi-key-verify.js +153 -0
  11. package/dist/crypto/sign.d.ts +17 -0
  12. package/dist/crypto/sign.js +152 -0
  13. package/dist/crypto/verify.js +1 -0
  14. package/dist/discovery/agent-card.d.ts +83 -0
  15. package/dist/discovery/agent-card.js +545 -0
  16. package/dist/index.d.ts +13 -0
  17. package/dist/index.js +16 -0
  18. package/dist/ink/checkpoint.d.ts +19 -0
  19. package/dist/ink/checkpoint.js +69 -0
  20. package/dist/ink/discovery-gating.d.ts +247 -0
  21. package/dist/ink/discovery-gating.js +94 -0
  22. package/dist/ink/handshake-budget.d.ts +90 -0
  23. package/dist/ink/handshake-budget.js +397 -0
  24. package/dist/ink/receipts.d.ts +31 -0
  25. package/dist/ink/receipts.js +89 -0
  26. package/dist/ink/transport-auth.d.ts +47 -0
  27. package/dist/ink/transport-auth.js +77 -0
  28. package/dist/middleware/ink-auth.d.ts +68 -0
  29. package/dist/middleware/ink-auth.js +214 -0
  30. package/dist/models/agent-card.d.ts +170 -0
  31. package/dist/models/agent-card.js +107 -0
  32. package/dist/models/ink-audit.d.ts +344 -0
  33. package/dist/models/ink-audit.js +167 -0
  34. package/dist/models/ink-handshake.d.ts +129 -0
  35. package/dist/models/ink-handshake.js +89 -0
  36. package/dist/models/intent.d.ts +437 -0
  37. package/dist/models/intent.js +172 -0
  38. package/dist/models/key-entry.d.ts +60 -0
  39. package/dist/models/key-entry.js +13 -0
  40. package/dist/models/profile.d.ts +61 -0
  41. package/dist/models/profile.js +24 -0
  42. package/package.json +15 -11
  43. package/specs/ink-auditability.md +2 -2
  44. package/specs/ink-containment-phase1-implementation-spec.md +1 -1
  45. package/src/audit/inclusion-receipt.ts +0 -604
  46. package/src/crypto/ink.ts +0 -1046
  47. package/src/crypto/keys.ts +0 -210
  48. package/src/crypto/multi-key-verify.ts +0 -170
  49. package/src/crypto/sign.ts +0 -155
  50. package/src/discovery/agent-card.ts +0 -508
  51. package/src/index.ts +0 -73
  52. package/src/ink/checkpoint.ts +0 -75
  53. package/src/ink/discovery-gating.ts +0 -147
  54. package/src/ink/handshake-budget.ts +0 -413
  55. package/src/ink/receipts.ts +0 -114
  56. package/src/ink/transport-auth.ts +0 -96
  57. package/src/middleware/ink-auth.ts +0 -263
  58. package/src/models/agent-card.ts +0 -63
  59. package/src/models/ink-audit.ts +0 -205
  60. package/src/models/ink-handshake.ts +0 -123
  61. package/src/models/intent.ts +0 -201
  62. package/src/models/key-entry.ts +0 -52
  63. package/src/models/profile.ts +0 -31
  64. /package/{src/crypto/verify.ts → dist/crypto/verify.d.ts} +0 -0
@@ -1,147 +0,0 @@
1
- /**
2
- * Capability-gated Agent Card discovery.
3
- *
4
- * Implements §6 of the INK Containment spec:
5
- * - Redacted cards for unauthenticated requests on non-public visibility
6
- * - Authenticated card query endpoint schemas
7
- * - Access denial response schemas
8
- */
9
-
10
- import { z } from "zod";
11
- import { AgentCardSchema, type AgentCard } from "../models/agent-card.js";
12
- import type { AgentCardVisibility } from "../models/ink-handshake.js";
13
-
14
- export { AgentCardVisibilitySchema, type AgentCardVisibility } from "../models/ink-handshake.js";
15
-
16
- // ── Redacted card ──
17
-
18
- export interface RedactedAgentCard {
19
- type: "tulpa.agent.card";
20
- version: "1.0";
21
- agentId: string;
22
- displayName?: string;
23
- visibility: "network_only" | "capability_gated";
24
- supportsInk: true;
25
- discoveryMode: "authenticate_for_details";
26
- /**
27
- * Bootstrap / legacy single public key. Preserved so peers can still verify
28
- * Ed25519 signatures from cards without a full keys block.
29
- */
30
- publicKeyMultibase: string;
31
- /**
32
- * Authoritative signing key set (rotation). Public material only — revealing
33
- * these is safe and necessary so peers can discover key rotations even when
34
- * the rest of the Card is gated. Without this, an agent that rotated to a
35
- * new key under network_only / capability_gated visibility would be
36
- * unverifiable to first-contact peers.
37
- */
38
- keys?: {
39
- signing: Array<{
40
- keyId: string;
41
- publicKeyMultibase: string;
42
- status: "active" | "retired" | "revoked";
43
- /** Validity-window fields are preserved in the redacted card so a
44
- * first-contact peer that only ever sees the redacted form still
45
- * enforces the same `[validFrom, validUntil]` bound the full card
46
- * would. Stripping them creates a softer verification path. */
47
- validFrom?: string;
48
- validUntil?: string;
49
- revokedAt?: string;
50
- }>;
51
- };
52
- updatedAt: string;
53
- }
54
-
55
- /**
56
- * Build a redacted Agent Card from a full card.
57
- *
58
- * Strips capabilities, endpoints, availability, profile, and other
59
- * sensitive-on-a-closed-card fields. **Public signing keys are preserved**:
60
- * Ed25519 public keys are not secrets — they are the identity. Hiding them
61
- * would prevent peers from verifying signatures across key rotation when the
62
- * full Card is gated.
63
- */
64
- export function buildRedactedCard(card: AgentCard): RedactedAgentCard {
65
- // `visibility` is typed on AgentCard but a malformed runtime value can
66
- // still slip through (e.g. a card deserialised without schema validation).
67
- // Default to `capability_gated` — the least-privilege visibility — when
68
- // the field is anything other than the known enum members.
69
- const visibility: "network_only" | "capability_gated" =
70
- card.visibility === "network_only" ? "network_only" : "capability_gated";
71
- const out: RedactedAgentCard = {
72
- type: "tulpa.agent.card",
73
- version: "1.0",
74
- agentId: card.agentId,
75
- displayName: card.displayName,
76
- supportsInk: true,
77
- discoveryMode: "authenticate_for_details",
78
- visibility,
79
- publicKeyMultibase: card.publicKeyMultibase,
80
- updatedAt: new Date().toISOString(),
81
- };
82
- // Preserve `keys.signing` on PRESENCE, not on truthiness/length.
83
- // An empty signing array is an authoritative "no usable signing
84
- // keys" statement (e.g. all keys revoked) and the verifier's key
85
- // rotation authority rule treats it as a reject-all signal. Dropping
86
- // the field here would let peers fall back to publicKeyMultibase or
87
- // bootstrap derivation, undoing the rotation. See
88
- // multi-key-verify and middleware/ink-auth for the authority rule.
89
- if (Array.isArray(card.keys?.signing)) {
90
- out.keys = {
91
- signing: card.keys.signing.map((k) => ({
92
- keyId: k.keyId,
93
- publicKeyMultibase: k.publicKeyMultibase,
94
- status: k.status,
95
- // Preserve validity / revocation metadata: these are public
96
- // facts about the key, not secrets, and a redacted card must
97
- // not be weaker than the full card for signature verification.
98
- validFrom: k.validFrom,
99
- validUntil: k.validUntil,
100
- revokedAt: k.revokedAt,
101
- })),
102
- };
103
- }
104
- return out;
105
- }
106
-
107
- // ── Query/Response schemas ──
108
-
109
- export const AgentCardQuerySchema = z.object({
110
- protocol: z.literal("ink/0.1"),
111
- type: z.literal("network.tulpa.agent_card_query"),
112
- from: z.string(),
113
- nonce: z.string(),
114
- timestamp: z.string().datetime(),
115
- requestedFields: z.array(z.string()).optional(),
116
- });
117
-
118
- export type AgentCardQuery = z.infer<typeof AgentCardQuerySchema>;
119
-
120
- export const AgentCardResponseSchema = z.object({
121
- protocol: z.literal("ink/0.1"),
122
- type: z.literal("network.tulpa.agent_card_response"),
123
- card: AgentCardSchema,
124
- grantedFields: z.array(z.string()),
125
- timestamp: z.string().datetime(),
126
- });
127
-
128
- export type AgentCardResponse = z.infer<typeof AgentCardResponseSchema>;
129
-
130
- export const AgentCardDeniedSchema = z.object({
131
- protocol: z.literal("ink/0.1"),
132
- type: z.literal("network.tulpa.agent_card_denied"),
133
- reason: z.enum(["unknown_requester", "insufficient_trust", "not_connected"]),
134
- timestamp: z.string().datetime(),
135
- });
136
-
137
- export type AgentCardDenied = z.infer<typeof AgentCardDeniedSchema>;
138
-
139
- // ── Visibility check ──
140
-
141
- /**
142
- * Determine whether an unauthenticated GET should return a full card
143
- * or a redacted card based on visibility setting.
144
- */
145
- export function shouldRedactOnGet(visibility: AgentCardVisibility): boolean {
146
- return visibility !== "public";
147
- }
@@ -1,413 +0,0 @@
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
-
11
- import type { InkBackoffHint } from "../models/ink-handshake.js";
12
-
13
- // ── Budget constants ──
14
-
15
- const DEFAULT_MAX_CHALLENGES = 3;
16
- const DEFAULT_MAX_TOTAL_TRANSITIONS = 5;
17
- const DEFAULT_MAX_INTENTS_PER_MINUTE = 10;
18
- const DEFAULT_MAX_HANDSHAKE_MSGS_PER_MINUTE = 30;
19
- const DEFAULT_MAX_CORRELATIONS = 10_000;
20
- const DEFAULT_MAX_SENDERS = 1_000;
21
- const DEFAULT_MAX_REJECTION_ENTRIES = 5_000;
22
- const DEFAULT_PRUNE_INTERVAL = 100;
23
- const DEFAULT_HANDSHAKE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
24
- // Cap correlationId and fromDid lengths to prevent memory exhaustion via large IDs.
25
- // Real correlation IDs are UUIDs (~36 chars); agent DIDs are ~50-100 chars.
26
- // 256 is generous headroom while bounding per-entry memory cost.
27
- const MAX_ID_LENGTH = 256;
28
- const SENDER_REJECTION_WINDOW_MS = 60_000;
29
-
30
- const TERMINAL_TYPES = new Set(["rejection", "resolution"]);
31
-
32
- // ── Types ──
33
-
34
- interface CorrelationState {
35
- challenges: number;
36
- totalTransitions: number;
37
- terminal: boolean;
38
- expiresAt: number; // epoch ms
39
- createdAt: number;
40
- }
41
-
42
- interface SenderState {
43
- intentTimestamps: number[];
44
- handshakeTimestamps: number[];
45
- lastActivity: number; // epoch ms — used for LRU eviction
46
- }
47
-
48
- export interface BudgetCheckResult {
49
- allowed: boolean;
50
- reason?: string;
51
- backoffHint?: InkBackoffHint;
52
- silentDrop?: boolean;
53
- }
54
-
55
- export interface HandshakeBudgetConfig {
56
- maxChallenges?: number;
57
- maxTotalTransitions?: number;
58
- maxIntentsPerMinute?: number;
59
- maxHandshakeMsgsPerMinute?: number;
60
- maxCorrelations?: number;
61
- maxSenders?: number;
62
- maxRejectionEntries?: number;
63
- }
64
-
65
- // ── Budget tracker ──
66
-
67
- export class HandshakeBudgetTracker {
68
- private correlations = new Map<string, CorrelationState>();
69
- private senders = new Map<string, SenderState>();
70
- private rejectionsSent = new Set<string>(); // "${correlationId}:${fromDid}"
71
- // Tracks when a sender last received a rate-limit rejection. Subsequent
72
- // violations within SENDER_REJECTION_WINDOW_MS are silent-dropped to prevent
73
- // an over-limit sender from forcing repeated reject/backoff responses.
74
- private senderRejectionsSent = new Map<string, number>();
75
-
76
- private readonly maxChallenges: number;
77
- private readonly maxTotalTransitions: number;
78
- private readonly maxIntentsPerMinute: number;
79
- private readonly maxHandshakeMsgsPerMinute: number;
80
- private readonly maxCorrelations: number;
81
- private readonly maxSenders: number;
82
- private readonly maxRejectionEntries: number;
83
- private checkCounter = 0;
84
-
85
- constructor(config: HandshakeBudgetConfig = {}) {
86
- const pos = (v: number | undefined, def: number, cap: number): number => {
87
- if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) return def;
88
- return Math.min(Math.floor(v), cap);
89
- };
90
- this.maxChallenges = pos(config.maxChallenges, DEFAULT_MAX_CHALLENGES, 1_000);
91
- this.maxTotalTransitions = pos(config.maxTotalTransitions, DEFAULT_MAX_TOTAL_TRANSITIONS, 10_000);
92
- this.maxIntentsPerMinute = pos(config.maxIntentsPerMinute, DEFAULT_MAX_INTENTS_PER_MINUTE, 100_000);
93
- this.maxHandshakeMsgsPerMinute = pos(config.maxHandshakeMsgsPerMinute, DEFAULT_MAX_HANDSHAKE_MSGS_PER_MINUTE, 100_000);
94
- this.maxCorrelations = pos(config.maxCorrelations, DEFAULT_MAX_CORRELATIONS, 1_000_000);
95
- this.maxSenders = pos(config.maxSenders, DEFAULT_MAX_SENDERS, 1_000_000);
96
- this.maxRejectionEntries = pos(config.maxRejectionEntries, DEFAULT_MAX_REJECTION_ENTRIES, 1_000_000);
97
- }
98
-
99
- checkAndRecord(params: {
100
- correlationId: string;
101
- fromDid: string;
102
- messageType: "intent" | "challenge" | "rejection" | "resolution";
103
- intentExpiresAt?: string;
104
- }): BudgetCheckResult {
105
- const { correlationId, fromDid, messageType, intentExpiresAt } = params;
106
- const now = Date.now();
107
- // Reject unbounded IDs before they hit Map keys / Set entries — caps the
108
- // per-entry memory cost regardless of the maxCorrelations / maxSenders
109
- // count caps. Stringifying the whole pair amplifies the attack surface.
110
- if (typeof correlationId !== "string" || correlationId.length > MAX_ID_LENGTH ||
111
- typeof fromDid !== "string" || fromDid.length > MAX_ID_LENGTH) {
112
- return { allowed: false, reason: "handshake_budget_exhausted", silentDrop: true };
113
- }
114
- // Use JSON encoding to prevent key collisions when IDs contain colons.
115
- // e.g. correlationId="a:b", fromDid="c" vs correlationId="a", fromDid="b:c"
116
- // would both produce "a:b:c" with naive string concatenation.
117
- const pairKey = JSON.stringify([correlationId, fromDid]);
118
-
119
- // Periodic pruning of expired state
120
- this.checkCounter++;
121
- if (this.checkCounter >= DEFAULT_PRUNE_INTERVAL) {
122
- this.checkCounter = 0;
123
- this.pruneExpired();
124
- }
125
-
126
- // Check per-sender rate limits first (applies across all correlations)
127
- const senderResult = this.checkSenderLimits(fromDid, messageType, now);
128
- if (!senderResult.allowed) {
129
- return senderResult;
130
- }
131
-
132
- // Record sender activity FIRST so per-sender limits accumulate on every
133
- // syntactically valid attempt — including ones that fail downstream
134
- // budget checks. Otherwise an attacker varying correlationId can trigger
135
- // unlimited typed rejections without ever hitting the per-sender cap.
136
- this.recordSenderActivity(fromDid, messageType, now);
137
-
138
- // Check TTL from intent expiry.
139
- // Distinguish "field absent" (undefined) from "field present but
140
- // malformed/empty". An intent that supplies `intentExpiresAt: ""`
141
- // is malformed — treat it as a rejected handshake instead of
142
- // falling through to the default 24h TTL (which would let
143
- // attacker-supplied empty expiries retain state longer than the
144
- // sender's claimed window). Also guard against NaN: new
145
- // Date("garbage").getTime() returns NaN and NaN <= now is false.
146
- let parsedExpiryMs: number | null = null;
147
- if (intentExpiresAt !== undefined) {
148
- // Length cap matches the timestamp cap used everywhere else in
149
- // INK (64 chars, well above any real ISO 8601 string). Without
150
- // this, a sender can submit a multi-megabyte expiry string and
151
- // force the JS engine into a long Date parser run before the
152
- // budget tracker rejects.
153
- if (
154
- typeof intentExpiresAt !== "string" ||
155
- intentExpiresAt.length === 0 ||
156
- intentExpiresAt.length > 64
157
- ) {
158
- return this.makeRejection(pairKey, "handshake_budget_exhausted", {
159
- backoffClass: "intent_ref",
160
- });
161
- }
162
- const expiryMs = new Date(intentExpiresAt).getTime();
163
- if (!Number.isFinite(expiryMs) || expiryMs <= now) {
164
- return this.makeRejection(pairKey, "handshake_budget_exhausted", {
165
- backoffClass: "intent_ref",
166
- });
167
- }
168
- parsedExpiryMs = expiryMs;
169
- }
170
-
171
- // Get or create correlation state. KEY BY pairKey, not correlationId,
172
- // so two senders that happen to use the same correlationId can't
173
- // consume each other's transitions or set terminal state on each
174
- // other's handshake.
175
- let state = this.correlations.get(pairKey);
176
- if (!state) {
177
- // Enforce memory bounds before creating new entry
178
- this.enforceMemoryBounds();
179
-
180
- // Reuse the parsed expiry from above instead of re-parsing.
181
- const ttl = parsedExpiryMs !== null
182
- ? Math.min(parsedExpiryMs, now + DEFAULT_HANDSHAKE_TTL_MS)
183
- : now + DEFAULT_HANDSHAKE_TTL_MS;
184
-
185
- state = {
186
- challenges: 0,
187
- totalTransitions: 0,
188
- terminal: false,
189
- expiresAt: ttl,
190
- createdAt: now,
191
- };
192
- this.correlations.set(pairKey, state);
193
- }
194
-
195
- // Check if correlation has expired
196
- if (state.expiresAt <= now) {
197
- return this.makeRejection(pairKey, "handshake_budget_exhausted", {
198
- backoffClass: "intent_ref",
199
- });
200
- }
201
-
202
- // Check if terminal state was already reached
203
- if (state.terminal) {
204
- return this.makeRejection(pairKey, "handshake_budget_exhausted", {
205
- backoffClass: "intent_ref",
206
- });
207
- }
208
-
209
- // Check total transitions
210
- if (state.totalTransitions >= this.maxTotalTransitions) {
211
- return this.makeRejection(pairKey, "handshake_budget_exhausted", {
212
- backoffClass: "intent_ref",
213
- });
214
- }
215
-
216
- // Check per-type limits
217
- if (messageType === "challenge" && state.challenges >= this.maxChallenges) {
218
- return this.makeRejection(pairKey, "handshake_budget_exhausted", {
219
- backoffClass: "intent_ref",
220
- });
221
- }
222
-
223
- // Record the message
224
- state.totalTransitions++;
225
- if (messageType === "challenge") {
226
- state.challenges++;
227
- }
228
- if (TERMINAL_TYPES.has(messageType)) {
229
- state.terminal = true;
230
- }
231
-
232
- // Sender activity was already recorded above (before the budget checks)
233
- // so per-sender limits accumulate even on rejected attempts.
234
-
235
- return { allowed: true };
236
- }
237
-
238
- pruneExpired(): void {
239
- const now = Date.now();
240
- for (const [pairKey, state] of this.correlations) {
241
- if (state.expiresAt <= now) {
242
- this.correlations.delete(pairKey);
243
- // Rejection tracking is keyed by the same pairKey, so we can drop
244
- // it directly.
245
- this.rejectionsSent.delete(pairKey);
246
- }
247
- }
248
-
249
- // Prune stale sender windows
250
- const oneMinuteAgo = now - 60_000;
251
- for (const [did, state] of this.senders) {
252
- state.intentTimestamps = state.intentTimestamps.filter((t) => t > oneMinuteAgo);
253
- state.handshakeTimestamps = state.handshakeTimestamps.filter((t) => t > oneMinuteAgo);
254
- if (state.intentTimestamps.length === 0 && state.handshakeTimestamps.length === 0) {
255
- this.senders.delete(did);
256
- }
257
- }
258
-
259
- // Prune sender-rejection records older than the silent-drop window.
260
- for (const [did, ts] of this.senderRejectionsSent) {
261
- if (now - ts >= SENDER_REJECTION_WINDOW_MS) {
262
- this.senderRejectionsSent.delete(did);
263
- }
264
- }
265
- }
266
-
267
- private checkSenderLimits(
268
- fromDid: string,
269
- messageType: string,
270
- now: number,
271
- ): BudgetCheckResult {
272
- const state = this.senders.get(fromDid);
273
- if (!state) return { allowed: true };
274
-
275
- const oneMinuteAgo = now - 60_000;
276
-
277
- // Check per-minute intent limit
278
- if (messageType === "intent") {
279
- const recentIntents = state.intentTimestamps.filter((t) => t > oneMinuteAgo);
280
- if (recentIntents.length >= this.maxIntentsPerMinute) {
281
- return this.makeSenderRejection(fromDid, now);
282
- }
283
- }
284
-
285
- // Check per-minute total handshake message limit
286
- const recentHandshake = state.handshakeTimestamps.filter((t) => t > oneMinuteAgo);
287
- if (recentHandshake.length >= this.maxHandshakeMsgsPerMinute) {
288
- return this.makeSenderRejection(fromDid, now);
289
- }
290
-
291
- return { allowed: true };
292
- }
293
-
294
- // Sender-level rate-limit rejection. Sends a typed reject (with backoff hint)
295
- // the first time a sender crosses the limit in the current window; silent-drops
296
- // subsequent violations until the window resets. Mirrors the per-correlation
297
- // makeRejection pattern from §5 of the INK Containment spec.
298
- private makeSenderRejection(fromDid: string, now: number): BudgetCheckResult {
299
- const lastSent = this.senderRejectionsSent.get(fromDid);
300
- if (lastSent !== undefined && now - lastSent < SENDER_REJECTION_WINDOW_MS) {
301
- return { allowed: false, reason: "sender_rate_limited", silentDrop: true };
302
- }
303
- // Bound the map by maxSenders to prevent attacker-driven growth. Evict the
304
- // oldest record if at capacity; the pruneExpired pass also cleans up
305
- // records older than the silent-drop window.
306
- if (this.senderRejectionsSent.size >= this.maxSenders &&
307
- !this.senderRejectionsSent.has(fromDid)) {
308
- let oldestDid: string | null = null;
309
- let oldestTs = Infinity;
310
- for (const [d, t] of this.senderRejectionsSent) {
311
- if (t < oldestTs) { oldestTs = t; oldestDid = d; }
312
- }
313
- if (oldestDid) this.senderRejectionsSent.delete(oldestDid);
314
- }
315
- this.senderRejectionsSent.set(fromDid, now);
316
- return {
317
- allowed: false,
318
- reason: "sender_rate_limited",
319
- backoffHint: { retryAfterSeconds: 60, backoffClass: "sender" },
320
- silentDrop: false,
321
- };
322
- }
323
-
324
- private recordSenderActivity(fromDid: string, messageType: string, now: number): void {
325
- let state = this.senders.get(fromDid);
326
- if (!state) {
327
- // Enforce sender cap before adding a new entry
328
- this.enforceSenderBounds();
329
- state = { intentTimestamps: [], handshakeTimestamps: [], lastActivity: now };
330
- this.senders.set(fromDid, state);
331
- }
332
-
333
- state.lastActivity = now;
334
- if (messageType === "intent") {
335
- state.intentTimestamps.push(now);
336
- }
337
- state.handshakeTimestamps.push(now);
338
- }
339
-
340
- private makeRejection(
341
- pairKey: string,
342
- reason: string,
343
- backoffHint: InkBackoffHint,
344
- ): BudgetCheckResult {
345
- if (this.rejectionsSent.has(pairKey)) {
346
- return { allowed: false, reason, silentDrop: true };
347
- }
348
- this.rejectionsSent.add(pairKey);
349
- this.enforceRejectionBounds();
350
- return { allowed: false, reason, backoffHint, silentDrop: false };
351
- }
352
-
353
- private enforceRejectionBounds(): void {
354
- if (this.rejectionsSent.size <= this.maxRejectionEntries) return;
355
-
356
- // Prune rejection entries whose backing correlation no longer exists
357
- // (already expired or evicted). Rejection keys and correlation keys are
358
- // both pairKeys now, so the lookup is direct.
359
- const now = Date.now();
360
- for (const key of this.rejectionsSent) {
361
- const state = this.correlations.get(key);
362
- if (!state || state.expiresAt <= now) {
363
- this.rejectionsSent.delete(key);
364
- }
365
- }
366
-
367
- // If still over limit, clear the oldest half (Set maintains insertion order)
368
- if (this.rejectionsSent.size > this.maxRejectionEntries) {
369
- const entries = [...this.rejectionsSent];
370
- const keepFrom = Math.floor(entries.length / 2);
371
- this.rejectionsSent.clear();
372
- for (let i = keepFrom; i < entries.length; i++) {
373
- this.rejectionsSent.add(entries[i]!);
374
- }
375
- }
376
- }
377
-
378
- private enforceSenderBounds(): void {
379
- if (this.senders.size < this.maxSenders) return;
380
-
381
- // Evict sender with oldest lastActivity
382
- let oldestDid: string | null = null;
383
- let oldestTime = Infinity;
384
- for (const [did, state] of this.senders) {
385
- if (state.lastActivity < oldestTime) {
386
- oldestTime = state.lastActivity;
387
- oldestDid = did;
388
- }
389
- }
390
- if (oldestDid) {
391
- this.senders.delete(oldestDid);
392
- }
393
- }
394
-
395
- private enforceMemoryBounds(): void {
396
- if (this.correlations.size < this.maxCorrelations) return;
397
-
398
- // Evict oldest entry (by createdAt)
399
- let oldestKey: string | null = null;
400
- let oldestTime = Infinity;
401
- for (const [key, state] of this.correlations) {
402
- if (state.createdAt < oldestTime) {
403
- oldestTime = state.createdAt;
404
- oldestKey = key;
405
- }
406
- }
407
- if (oldestKey) {
408
- this.correlations.delete(oldestKey);
409
- // Rejection tracking is keyed by the same pairKey, so drop directly.
410
- this.rejectionsSent.delete(oldestKey);
411
- }
412
- }
413
- }
@@ -1,114 +0,0 @@
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
- import type { InkReceipt } from "../models/ink-audit.js";
5
-
6
- export interface BuildReceiptInput {
7
- from: string;
8
- to: string;
9
- messageId: string;
10
- messageBody: Record<string, unknown>;
11
- disposition: "received" | "delivered" | "acted" | "rejected";
12
- note?: string;
13
- privateKey: Uint8Array;
14
- }
15
-
16
- /** Build a signed INK receipt envelope. */
17
- export async function buildReceipt(input: BuildReceiptInput): Promise<InkReceipt> {
18
- if (input === null || typeof input !== "object" || Array.isArray(input)) {
19
- throw new Error("input must be a non-null object");
20
- }
21
- if (!(input.privateKey instanceof Uint8Array) || input.privateKey.length !== 32) {
22
- throw new Error("input.privateKey must be a 32-byte Uint8Array");
23
- }
24
- const now = new Date().toISOString();
25
- const messageHash = await computeMessageHash(input.messageBody);
26
- const nonce = crypto.randomUUID().replace(/-/g, "");
27
-
28
- const unsigned = {
29
- protocol: "ink/0.1" as const,
30
- type: "network.tulpa.receipt" as const,
31
- from: input.from,
32
- to: input.to,
33
- messageId: input.messageId,
34
- disposition: input.disposition,
35
- dispositionAt: now,
36
- messageHash,
37
- nonce,
38
- timestamp: now,
39
- ...(input.note ? { note: input.note } : {}),
40
- };
41
-
42
- const signature = await signMessage(unsigned as unknown as Record<string, unknown>, input.privateKey);
43
-
44
- return { ...unsigned, signature };
45
- }
46
-
47
- /** Loop prevention: don't send receipts for receipts or audit messages. */
48
- const NO_RECEIPT_TYPES = new Set([
49
- "network.tulpa.receipt",
50
- "network.tulpa.audit_query",
51
- "network.tulpa.audit_response",
52
- "network.tulpa.audit_submit",
53
- "network.tulpa.audit_inclusion",
54
- ]);
55
-
56
- export function shouldSendReceipt(intentOrType: string): boolean {
57
- return !NO_RECEIPT_TYPES.has(intentOrType);
58
- }
59
-
60
- export interface SendReceiptOptions {
61
- /** Allow endpoints whose hostname is loopback / private / link-local /
62
- * IANA special-use. Off by default — flip on only for tests or for
63
- * intentional intranet deployments where peer endpoints are trusted. */
64
- allowPrivateHosts?: boolean;
65
- }
66
-
67
- /** Fire-and-forget POST of a receipt with INK request signature. Never throws.
68
- *
69
- * Endpoint MUST be an absolute `https://` URL. Other schemes (file://, data:,
70
- * blob:, http://) are rejected silently to prevent SSRF and local-file
71
- * exfiltration when integrators pass peer-supplied URLs without sanitising.
72
- *
73
- * Mirrors the SSRF defenses in fetchAgentCard: https-only, no userinfo,
74
- * literal-hostname allowlist excluding private/loopback/special-use, no
75
- * redirect following, request timeout. DNS rebinding is still the
76
- * integrator's responsibility — pass a connect-time-pinning `fetchFn`
77
- * when the endpoint is not fully trusted. */
78
- export async function sendReceiptFireAndForget(
79
- endpoint: string,
80
- receipt: InkReceipt,
81
- privateKey: Uint8Array,
82
- fetchFn: typeof fetch = globalThis.fetch,
83
- signingKeyId?: string,
84
- options?: SendReceiptOptions,
85
- ): Promise<void> {
86
- try {
87
- let url: URL;
88
- try { url = new URL(endpoint); } catch { return; }
89
- if (url.protocol !== "https:") return;
90
- if (url.username || url.password) return;
91
- if (!options?.allowPrivateHosts && isPrivateHostname(url.hostname)) return;
92
-
93
- const sig = await signInkMessage({
94
- method: "POST",
95
- path: url.pathname,
96
- recipientDid: receipt.to,
97
- body: receipt as unknown as Record<string, unknown>,
98
- timestamp: receipt.timestamp,
99
- }, privateKey);
100
-
101
- await fetchFn(endpoint, {
102
- method: "POST",
103
- headers: {
104
- "Content-Type": "application/json",
105
- "Authorization": buildAuthHeader(sig, signingKeyId),
106
- },
107
- body: JSON.stringify(receipt),
108
- redirect: "manual",
109
- signal: AbortSignal.timeout(5000),
110
- });
111
- } catch {
112
- // Fire-and-forget — swallow errors
113
- }
114
- }