@adastracomputing/ink 0.1.0-alpha.2 → 0.1.0-alpha.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -5
- package/CODE_OF_CONDUCT.md +1 -1
- package/README.md +7 -5
- package/SECURITY.md +1 -1
- package/bin/verify-inclusion-impl.mjs +4 -1
- package/dist/audit/inclusion-receipt.d.ts +142 -0
- package/dist/audit/inclusion-receipt.js +496 -0
- package/dist/crypto/ink.d.ts +178 -0
- package/dist/crypto/ink.js +915 -0
- package/dist/crypto/keys.d.ts +42 -0
- package/dist/crypto/keys.js +179 -0
- package/dist/crypto/multi-key-verify.d.ts +29 -0
- package/dist/crypto/multi-key-verify.js +153 -0
- package/dist/crypto/sign.d.ts +17 -0
- package/dist/crypto/sign.js +152 -0
- package/dist/crypto/verify.js +1 -0
- package/dist/discovery/agent-card.d.ts +83 -0
- package/dist/discovery/agent-card.js +545 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +15 -0
- package/dist/ink/checkpoint.d.ts +19 -0
- package/dist/ink/checkpoint.js +69 -0
- package/dist/ink/discovery-gating.d.ts +237 -0
- package/dist/ink/discovery-gating.js +91 -0
- package/dist/ink/handshake-budget.d.ts +90 -0
- package/dist/ink/handshake-budget.js +397 -0
- package/dist/ink/receipts.d.ts +31 -0
- package/dist/ink/receipts.js +89 -0
- package/dist/ink/transport-auth.d.ts +47 -0
- package/dist/ink/transport-auth.js +77 -0
- package/dist/middleware/ink-auth.d.ts +68 -0
- package/dist/middleware/ink-auth.js +214 -0
- package/dist/models/agent-card.d.ts +154 -0
- package/dist/models/agent-card.js +59 -0
- package/dist/models/ink-audit.d.ts +344 -0
- package/dist/models/ink-audit.js +167 -0
- package/dist/models/ink-handshake.d.ts +129 -0
- package/dist/models/ink-handshake.js +89 -0
- package/dist/models/intent.d.ts +437 -0
- package/dist/models/intent.js +172 -0
- package/dist/models/key-entry.d.ts +60 -0
- package/dist/models/key-entry.js +13 -0
- package/dist/models/profile.d.ts +61 -0
- package/dist/models/profile.js +24 -0
- package/docs/maturity.md +3 -3
- package/docs/threat-model.md +1 -1
- package/package.json +17 -13
- package/specs/ink-auditability.md +37 -12
- package/specs/ink-compliance-checklist.md +9 -1
- package/src/audit/inclusion-receipt.ts +0 -268
- package/src/crypto/ink.ts +0 -902
- package/src/crypto/keys.ts +0 -210
- package/src/crypto/multi-key-verify.ts +0 -170
- package/src/crypto/sign.ts +0 -155
- package/src/discovery/agent-card.ts +0 -508
- package/src/index.ts +0 -67
- package/src/ink/checkpoint.ts +0 -75
- package/src/ink/discovery-gating.ts +0 -147
- package/src/ink/handshake-budget.ts +0 -413
- package/src/ink/receipts.ts +0 -114
- package/src/ink/transport-auth.ts +0 -96
- package/src/middleware/ink-auth.ts +0 -263
- package/src/models/agent-card.ts +0 -63
- package/src/models/ink-audit.ts +0 -205
- package/src/models/ink-handshake.ts +0 -123
- package/src/models/intent.ts +0 -201
- package/src/models/key-entry.ts +0 -52
- package/src/models/profile.ts +0 -31
- /package/{src/crypto/verify.ts → dist/crypto/verify.d.ts} +0 -0
|
@@ -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
|
-
}
|
package/src/ink/receipts.ts
DELETED
|
@@ -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
|
-
}
|