@adastracomputing/ink 0.1.0-alpha.0

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 (48) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/CODE_OF_CONDUCT.md +42 -0
  3. package/LICENSE-APACHE +201 -0
  4. package/LICENSE-MIT +21 -0
  5. package/README.md +133 -0
  6. package/SECURITY.md +57 -0
  7. package/docs/key-rotation-rule.md +108 -0
  8. package/docs/logo.svg +8 -0
  9. package/docs/maturity.md +81 -0
  10. package/docs/threat-model.md +150 -0
  11. package/package.json +72 -0
  12. package/specs/ink-agent-containment-and-governance-extension-spec.md +508 -0
  13. package/specs/ink-auditability.md +652 -0
  14. package/specs/ink-authorization-chain.md +242 -0
  15. package/specs/ink-compatibility-policy.md +263 -0
  16. package/specs/ink-compliance-checklist.md +309 -0
  17. package/specs/ink-containment-phase1-implementation-spec.md +593 -0
  18. package/specs/ink-introduction-receipts-extension.md +501 -0
  19. package/specs/ink-key-rotation-spec.md +535 -0
  20. package/src/crypto/ink.ts +902 -0
  21. package/src/crypto/keys.ts +211 -0
  22. package/src/crypto/multi-key-verify.ts +170 -0
  23. package/src/crypto/sign.ts +155 -0
  24. package/src/crypto/verify.ts +1 -0
  25. package/src/discovery/agent-card.ts +508 -0
  26. package/src/index.ts +59 -0
  27. package/src/ink/checkpoint.ts +75 -0
  28. package/src/ink/discovery-gating.ts +147 -0
  29. package/src/ink/handshake-budget.ts +413 -0
  30. package/src/ink/receipts.ts +114 -0
  31. package/src/ink/transport-auth.ts +96 -0
  32. package/src/middleware/ink-auth.ts +263 -0
  33. package/src/models/agent-card.ts +63 -0
  34. package/src/models/ink-audit.ts +205 -0
  35. package/src/models/ink-handshake.ts +123 -0
  36. package/src/models/intent.ts +201 -0
  37. package/src/models/key-entry.ts +52 -0
  38. package/src/models/profile.ts +31 -0
  39. package/test-vectors/README.md +129 -0
  40. package/test-vectors/encryption.json +90 -0
  41. package/test-vectors/handshake.json +482 -0
  42. package/test-vectors/jcs.json +30 -0
  43. package/test-vectors/key-rotation.json +101 -0
  44. package/test-vectors/keys.json +32 -0
  45. package/test-vectors/receipts-and-audit.json +142 -0
  46. package/test-vectors/replay.json +88 -0
  47. package/test-vectors/signing.json +61 -0
  48. package/test-vectors/witness.json +394 -0
@@ -0,0 +1,902 @@
1
+ import * as ed from "@noble/ed25519";
2
+ import { x25519 } from "@noble/curves/ed25519.js";
3
+ import canonicalize from "canonicalize";
4
+
5
+ // ── Encoding helpers ──
6
+
7
+ const MAX_ENCODE_INPUT_BYTES = 2_000_000;
8
+
9
+ function base64urlEncode(bytes: Uint8Array): string {
10
+ if (!(bytes instanceof Uint8Array)) {
11
+ throw new Error("base64urlEncode: input must be a Uint8Array");
12
+ }
13
+ if (bytes.length > MAX_ENCODE_INPUT_BYTES) {
14
+ throw new Error(`base64urlEncode: input exceeds maximum of ${MAX_ENCODE_INPUT_BYTES} bytes`);
15
+ }
16
+ const binString = Array.from(bytes, (b) => String.fromCharCode(b)).join("");
17
+ const base64 = btoa(binString);
18
+ return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
19
+ }
20
+
21
+ const MAX_BASE64URL_INPUT_LEN = 2_000_000;
22
+
23
+ function base64urlDecode(str: string): Uint8Array {
24
+ if (typeof str !== "string") {
25
+ throw new Error("base64urlDecode: input must be a string");
26
+ }
27
+ if (str.length > MAX_BASE64URL_INPUT_LEN) {
28
+ throw new Error(`base64urlDecode: input exceeds maximum length of ${MAX_BASE64URL_INPUT_LEN}`);
29
+ }
30
+ if (!/^[A-Za-z0-9_-]*$/.test(str)) {
31
+ throw new Error("base64urlDecode: invalid base64url character");
32
+ }
33
+ const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
34
+ const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
35
+ const binString = atob(padded);
36
+ return Uint8Array.from(binString, (c) => c.charCodeAt(0));
37
+ }
38
+
39
+ /** Defense-in-depth cap on hex input length. The longest legitimate input
40
+ * the package decodes is a 64-byte hex string (Ed25519 keypair concat); the
41
+ * cap is set generously above that so an attacker-supplied multi-megabyte
42
+ * hex string can't drive an O(n) regex scan and a multi-megabyte
43
+ * Uint8Array allocation before the downstream length check fires. */
44
+ const MAX_HEX_INPUT_LEN = 4096;
45
+
46
+ function hexToBytes(hex: string): Uint8Array {
47
+ if (typeof hex !== "string") {
48
+ throw new Error("hexToBytes: input must be a string");
49
+ }
50
+ if (hex.length > MAX_HEX_INPUT_LEN) {
51
+ throw new Error(`hex input exceeds maximum length of ${MAX_HEX_INPUT_LEN}`);
52
+ }
53
+ if (hex.length % 2 !== 0) throw new Error(`Invalid hex string length: ${hex.length}`);
54
+ if (!/^[0-9a-fA-F]*$/.test(hex)) throw new Error("Invalid hex character in string");
55
+ const bytes = new Uint8Array(hex.length / 2);
56
+ for (let i = 0; i < hex.length; i += 2) {
57
+ bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
58
+ }
59
+ return bytes;
60
+ }
61
+
62
+ function bytesToHex(bytes: Uint8Array): string {
63
+ if (!(bytes instanceof Uint8Array)) {
64
+ throw new Error("bytesToHex: input must be a Uint8Array");
65
+ }
66
+ if (bytes.length > MAX_ENCODE_INPUT_BYTES) {
67
+ throw new Error(`bytesToHex: input exceeds maximum of ${MAX_ENCODE_INPUT_BYTES} bytes`);
68
+ }
69
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
70
+ }
71
+
72
+ // ── JCS Canonicalization (RFC 8785) ──
73
+
74
+ function jcsCanonicalize(obj: unknown): string {
75
+ if (!isWithinCanonicalizeBounds(obj)) {
76
+ throw new Error("Input exceeds maximum allowed complexity");
77
+ }
78
+ const result = canonicalize(obj);
79
+ if (result === undefined) throw new Error("Failed to canonicalize");
80
+ if (result.length > MAX_SIGBASE_BODY_BYTES) {
81
+ throw new Error("Canonical output exceeds maximum allowed size");
82
+ }
83
+ return result;
84
+ }
85
+
86
+ // ── INK v0.1 Signing (§3.3) ──
87
+
88
+ export interface InkSignInput {
89
+ method: string;
90
+ path: string;
91
+ recipientDid: string;
92
+ body: Record<string, unknown>;
93
+ timestamp: string;
94
+ }
95
+
96
+ /**
97
+ * Construct the INK v0.1 signature base string per §3.3:
98
+ * ink/0.1\nMETHOD\nPATH\nrecipientDid\nJCS(body)\ntimestamp
99
+ *
100
+ * The protocol version prefix prevents cross-version signature replay.
101
+ *
102
+ * Newlines (CR or LF) are forbidden in all scalar fields. Because the base
103
+ * string is newline-delimited, a field containing \n could shift field
104
+ * boundaries and allow two distinct logical inputs to produce the same
105
+ * signed bytes (a signature-base collision).
106
+ */
107
+ /** Defense-in-depth cap on the canonicalized body size used to build the
108
+ * INK signature base. Callers are expected to validate input size at the
109
+ * transport boundary (the hosting HTTP layer typically caps total request
110
+ * body size; INK-aware endpoints should additionally cap submit/query
111
+ * bodies). This is an internal upper limit in case any caller forgets —
112
+ * protects against canonicalize-then-encode burning CPU/memory on unbounded
113
+ * `input.body`. 1 MB is well above any realistic signed payload. */
114
+ const MAX_SIGBASE_BODY_BYTES = 1_048_576;
115
+
116
+ /** Hard caps for the cheap pre-canonicalize bound walk. These are well above
117
+ * any realistic INK body (signing payloads are typically <50 keys and ≤6
118
+ * levels deep) but small enough that the walk itself remains O(n) on tiny
119
+ * structures and bails fast on adversarial ones. The shape of the limits
120
+ * mirrors what jcsCanonicalize would have to traverse anyway, so an attacker
121
+ * cannot get past the pre-check and then explode inside canonicalize.
122
+ *
123
+ * MAX_PRECHECK_CHARS bounds aggregate string content (keys + string values)
124
+ * so a single huge string can't slip past the node-count cap. Set slightly
125
+ * above MAX_SIGBASE_BODY_BYTES so the post-canonicalize byte cap stays the
126
+ * authoritative reject, but the pre-check stops `JSON.stringify` / the
127
+ * recursive `canonicalize` from ever allocating that much in the first
128
+ * place. The aggregate counter is approximate (counts JS string length not
129
+ * UTF-8 bytes) but is intentionally a cheap upper-bound — the precise byte
130
+ * count happens after canonicalize. */
131
+ const MAX_PRECHECK_NODES = 10_000;
132
+ const MAX_PRECHECK_DEPTH = 32;
133
+ const MAX_PRECHECK_CHARS = 1_200_000;
134
+
135
+ /**
136
+ * Cheap depth/node-count/byte walk over a value before it is handed to
137
+ * jcsCanonicalize. Returns true if the value is within bounds. The goal is
138
+ * NOT to validate the value; it is to bail BEFORE canonicalize() does its
139
+ * recursive sort+serialize on something that should be rejected anyway.
140
+ * Non-throwing — the caller decides what to do with `false`.
141
+ *
142
+ * The byte counter accumulates every string value and every object key.
143
+ * Without it, an attacker can pass the node check with a single value
144
+ * like `{data: "x".repeat(100_000_000)}` (1 node, gigabytes of memory).
145
+ */
146
+ function isWithinCanonicalizeBounds(value: unknown): boolean {
147
+ let nodes = 0;
148
+ let chars = 0;
149
+ function walk(v: unknown, depth: number): boolean {
150
+ if (depth > MAX_PRECHECK_DEPTH) return false;
151
+ if (++nodes > MAX_PRECHECK_NODES) return false;
152
+ if (v === null || typeof v !== "object") {
153
+ if (typeof v === "string") {
154
+ chars += v.length;
155
+ if (chars > MAX_PRECHECK_CHARS) return false;
156
+ }
157
+ return true;
158
+ }
159
+ if (Array.isArray(v)) {
160
+ for (const item of v) {
161
+ if (!walk(item, depth + 1)) return false;
162
+ }
163
+ return true;
164
+ }
165
+ for (const key of Object.keys(v as Record<string, unknown>)) {
166
+ if (++nodes > MAX_PRECHECK_NODES) return false;
167
+ chars += key.length;
168
+ if (chars > MAX_PRECHECK_CHARS) return false;
169
+ if (!walk((v as Record<string, unknown>)[key], depth + 1)) return false;
170
+ }
171
+ return true;
172
+ }
173
+ return walk(value, 0);
174
+ }
175
+
176
+
177
+ export function buildSignatureBase(input: InkSignInput): string {
178
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
179
+ throw new Error("Invalid signature-base input");
180
+ }
181
+ // Validate scalar shape FIRST: each field is a non-empty string within
182
+ // a reasonable cap. An attacker who reaches this with a 100 MB path or
183
+ // recipientDid would otherwise force large TextEncoder allocations and
184
+ // a worst-case regex scan before signature failure.
185
+ // Caps:
186
+ // method: 16 chars (HTTP verb)
187
+ // path: 2048 chars (URI Section 3.3 practical bound)
188
+ // recipientDid: 256 chars (same as middleware senderDid cap)
189
+ // timestamp: 64 chars (ISO 8601 with subsecond + timezone)
190
+ const isScalar = (x: unknown, max: number): x is string =>
191
+ typeof x === "string" && x.length > 0 && x.length <= max;
192
+ if (!isScalar(input.method, 16)) throw new Error("Invalid signature-base method");
193
+ if (!isScalar(input.path, 2048)) throw new Error("Invalid signature-base path");
194
+ if (!isScalar(input.recipientDid, 256)) throw new Error("Invalid signature-base recipientDid");
195
+ if (!isScalar(input.timestamp, 64)) throw new Error("Invalid signature-base timestamp");
196
+
197
+ // Guard against newline injection in each scalar field.
198
+ // CR (\r) is included because \r\n is a common line-ending and would
199
+ // produce the same boundary-shift as \n alone.
200
+ const crlf = /[\r\n]/;
201
+ if (crlf.test(input.method)) throw new Error("Invalid character in method: newline or CR not allowed");
202
+ if (crlf.test(input.path)) throw new Error("Invalid character in path: newline or CR not allowed");
203
+ if (crlf.test(input.recipientDid)) throw new Error("Invalid character in recipientDid: newline or CR not allowed");
204
+ if (crlf.test(input.timestamp)) throw new Error("Invalid character in timestamp: newline or CR not allowed");
205
+
206
+ // Bound the cost of the canonicalize step BEFORE invoking it. Without
207
+ // this, an attacker can submit a syntactically valid body that bloats
208
+ // the recursive sort+serialize work inside jcsCanonicalize and then
209
+ // gets rejected by the size cap below — burning CPU/memory pre-reject.
210
+ if (!isWithinCanonicalizeBounds(input.body)) {
211
+ throw new Error("Signature base body exceeds maximum allowed complexity");
212
+ }
213
+ const canonical = jcsCanonicalize(input.body);
214
+ if (canonical.length > MAX_SIGBASE_BODY_BYTES) {
215
+ throw new Error("Signature base body exceeds maximum allowed size");
216
+ }
217
+ return `ink/0.1\n${input.method}\n${input.path}\n${input.recipientDid}\n${canonical}\n${input.timestamp}`;
218
+ }
219
+
220
+ /**
221
+ * Sign an INK message. Returns the base64url-encoded Ed25519 signature.
222
+ */
223
+ export async function signInkMessage(
224
+ input: InkSignInput,
225
+ privateKey: Uint8Array,
226
+ ): Promise<string> {
227
+ const sigBase = buildSignatureBase(input);
228
+ const bytes = new TextEncoder().encode(sigBase);
229
+ const sig = await ed.signAsync(bytes, privateKey);
230
+ return base64urlEncode(sig);
231
+ }
232
+
233
+ /**
234
+ * Verify an INK message signature.
235
+ * Returns false (never throws) for malformed or wrong-length signatures.
236
+ */
237
+ export async function verifyInkSignature(
238
+ input: InkSignInput,
239
+ signatureBase64url: string,
240
+ publicKey: Uint8Array,
241
+ ): Promise<boolean> {
242
+ // Reject obviously-malformed signatures BEFORE canonicalizing the body.
243
+ // canonicalize() walks the entire body to sort keys; doing that work
244
+ // for a request with a junk signature lets attackers burn CPU/memory
245
+ // on the verifier without ever supplying a valid signature.
246
+ if (!/^[A-Za-z0-9_-]{86}$/.test(signatureBase64url)) return false;
247
+ let sigBase: string;
248
+ try {
249
+ sigBase = buildSignatureBase(input);
250
+ } catch {
251
+ return false;
252
+ }
253
+ const bytes = new TextEncoder().encode(sigBase);
254
+ try {
255
+ const sig = base64urlDecode(signatureBase64url);
256
+ return await ed.verifyAsync(sig, bytes, publicKey);
257
+ } catch {
258
+ return false;
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Build the Authorization header value for an INK request.
264
+ * Optionally includes keyId for key-rotation-aware verification.
265
+ *
266
+ * Both values are validated against the same grammar the receiver uses so that
267
+ * invalid characters (including CR/LF that could cause header injection) are
268
+ * rejected before they reach the HTTP layer.
269
+ */
270
+ export function buildAuthHeader(signatureBase64url: string, keyId?: string): string {
271
+ // Ed25519 signatures are exactly 64 bytes which encode to exactly 86 unpadded base64url chars.
272
+ // Reject any other length at the builder so callers get an early error rather than sending
273
+ // a syntactically-valid but semantically-wrong Authorization header.
274
+ if (!/^[A-Za-z0-9_-]{86}$/.test(signatureBase64url)) {
275
+ throw new Error("Invalid signature for Authorization header: must be exactly 86 base64url characters (Ed25519)");
276
+ }
277
+ if (keyId !== undefined) {
278
+ // keyId must match the verifier's grammar — alphanumeric plus safe punctuation, no CR/LF or spaces.
279
+ if (!/^[A-Za-z0-9_:.-]{1,128}$/.test(keyId)) {
280
+ throw new Error("Invalid keyId for Authorization header: must be 1-128 chars [A-Za-z0-9_:.-]");
281
+ }
282
+ return `INK-Ed25519 ${signatureBase64url} keyId=${keyId}`;
283
+ }
284
+ return `INK-Ed25519 ${signatureBase64url}`;
285
+ }
286
+
287
+ // ── INK v0.1 Encryption (§3.4 — ECIES) ──
288
+
289
+ export interface InkEncryptedEnvelope {
290
+ protocol: "ink/0.1";
291
+ type: "network.tulpa.encrypted";
292
+ from: string;
293
+ ephemeralKey: string;
294
+ nonce: string;
295
+ ciphertext: string;
296
+ timestamp: string;
297
+ messageNonce: string;
298
+ }
299
+
300
+ export interface InkEncryptResult {
301
+ envelope: InkEncryptedEnvelope;
302
+ ephemeralPublicKey: Uint8Array;
303
+ }
304
+
305
+ /**
306
+ * Encrypt an INK message payload using ECIES:
307
+ * 1. Generate ephemeral X25519 keypair (or accept one for deterministic tests)
308
+ * 2. ECDH with recipient's X25519 public key
309
+ * 3. HKDF-SHA256(sharedSecret, salt="ink/0.1", info="ink/0.1/encrypt") → 32-byte AES key
310
+ * 4. AES-256-GCM encrypt the JSON-serialized plaintext
311
+ * 5. Pack into outer envelope
312
+ */
313
+ export async function encryptInkPayload(
314
+ plaintext: Record<string, unknown>,
315
+ senderDid: string,
316
+ recipientEncryptionKeyHex: string,
317
+ timestamp: string,
318
+ messageNonce: string,
319
+ options?: {
320
+ ephemeralPrivateKey?: Uint8Array;
321
+ aesNonce?: Uint8Array;
322
+ },
323
+ ): Promise<InkEncryptResult> {
324
+ // Pre-AAD scalar caps. AAD is canonicalized and TextEncoder-allocated;
325
+ // unbounded sender DID / timestamp / messageNonce values would force
326
+ // the encrypt path to spend CPU/memory before any GCM work. These
327
+ // caps mirror the decrypt-side guards so encrypt cannot mint AAD that
328
+ // a conformant decrypter would refuse.
329
+ if (typeof senderDid !== "string" || senderDid.length === 0 || senderDid.length > 512) {
330
+ throw new Error("Invalid senderDid");
331
+ }
332
+ if (typeof timestamp !== "string" || timestamp.length === 0 || timestamp.length > 64) {
333
+ throw new Error("Invalid timestamp");
334
+ }
335
+ if (typeof messageNonce !== "string" || messageNonce.length === 0 || messageNonce.length > 256) {
336
+ throw new Error("Invalid messageNonce");
337
+ }
338
+ // 1. Ephemeral X25519 keypair.
339
+ // Test-supplied overrides must be the right length to produce a clean
340
+ // error instead of an opaque crypto exception.
341
+ if (options?.ephemeralPrivateKey && options.ephemeralPrivateKey.length !== 32) {
342
+ throw new Error("ephemeralPrivateKey must be exactly 32 bytes");
343
+ }
344
+ const ephPriv = options?.ephemeralPrivateKey ?? crypto.getRandomValues(new Uint8Array(32));
345
+ const ephPub = x25519.getPublicKey(ephPriv);
346
+
347
+ // 2. ECDH shared secret. Explicit 32-byte length check on the decoded
348
+ // recipient public key so we surface a clean error rather than an
349
+ // opaque noble-curves exception (matches the ephemeralPrivateKey path
350
+ // guard above).
351
+ const recipientPub = hexToBytes(recipientEncryptionKeyHex);
352
+ if (recipientPub.length !== 32) {
353
+ throw new Error("recipientEncryptionKeyHex must decode to exactly 32 bytes");
354
+ }
355
+ const sharedSecret = x25519.getSharedSecret(ephPriv, recipientPub);
356
+
357
+ // Refuse all-zero shared secrets. A low-order recipient public key (a
358
+ // 32-byte value in the small subgroup) forces every X25519 ECDH to
359
+ // produce an all-zero shared secret. Without this check, the encrypt
360
+ // path would derive a deterministic, publicly-known AES key from HKDF,
361
+ // making the ciphertext decryptable by anyone. The decrypt path has the
362
+ // mirrored guard at the all-zeros check below.
363
+ if (sharedSecret.every((b) => b === 0)) {
364
+ throw new Error("Invalid recipient public key: ECDH shared secret is all zeros");
365
+ }
366
+
367
+ // 3. HKDF-SHA256 → AES key
368
+ const hkdfKey = await crypto.subtle.importKey(
369
+ "raw", sharedSecret, "HKDF", false, ["deriveBits"],
370
+ );
371
+ const symmetricBits = await crypto.subtle.deriveBits(
372
+ { name: "HKDF", hash: "SHA-256", salt: new TextEncoder().encode("ink/0.1"), info: new TextEncoder().encode("ink/0.1/encrypt") },
373
+ hkdfKey, 256,
374
+ );
375
+ const symmetricKey = new Uint8Array(symmetricBits);
376
+
377
+ // 4. AES-256-GCM
378
+ if (options?.aesNonce && options.aesNonce.length !== 12) {
379
+ throw new Error("aesNonce must be exactly 12 bytes");
380
+ }
381
+ const aesNonce = options?.aesNonce ?? crypto.getRandomValues(new Uint8Array(12));
382
+
383
+ // Bound the plaintext BEFORE JSON.stringify and TextEncoder.encode so
384
+ // a caller asked to encrypt attacker-supplied data can't be forced
385
+ // into large allocations. Decrypt already caps the resulting
386
+ // ciphertext; we mirror that here so encrypt cannot mint envelopes a
387
+ // conformant decryptor would refuse. Cheap node walk first, then
388
+ // string-length cap on the encoded bytes.
389
+ if (!isWithinCanonicalizeBounds(plaintext)) {
390
+ throw new Error("Plaintext exceeds maximum allowed complexity");
391
+ }
392
+ const plaintextJson = JSON.stringify(plaintext);
393
+ if (plaintextJson.length > MAX_SIGBASE_BODY_BYTES) {
394
+ throw new Error("Plaintext exceeds maximum allowed size");
395
+ }
396
+ const plaintextBytes = new TextEncoder().encode(plaintextJson);
397
+
398
+ const aesKey = await crypto.subtle.importKey("raw", symmetricKey, "AES-GCM", false, ["encrypt"]);
399
+ // AAD binds the ciphertext to all security-relevant outer envelope fields using
400
+ // an unambiguous JSON-canonical representation. This prevents an attacker from
401
+ // replaying the same ciphertext with modified outer metadata (timestamp, nonce, etc.)
402
+ // or reattributing the ciphertext to a different sender.
403
+ // Fields bound: protocol, type, from (sender), ephemeralKey, AES nonce (base64url),
404
+ // timestamp, messageNonce. Including protocol and type prevents type-confusion attacks
405
+ // where an attacker reinterprets a valid encrypted envelope as a different message type.
406
+ const aadObject = {
407
+ protocol: "ink/0.1",
408
+ type: "network.tulpa.encrypted",
409
+ from: senderDid,
410
+ ephemeralKey: base64urlEncode(ephPub),
411
+ nonce: base64urlEncode(aesNonce),
412
+ timestamp,
413
+ messageNonce,
414
+ };
415
+ const aadString = `ink/0.1:envelope\n${jcsCanonicalize(aadObject)}`;
416
+ const aad = new TextEncoder().encode(aadString);
417
+ const ciphertextWithTag = new Uint8Array(
418
+ await crypto.subtle.encrypt({ name: "AES-GCM", iv: aesNonce, additionalData: aad }, aesKey, plaintextBytes),
419
+ );
420
+
421
+ // 5. Outer envelope
422
+ const envelope: InkEncryptedEnvelope = {
423
+ protocol: "ink/0.1",
424
+ type: "network.tulpa.encrypted",
425
+ from: senderDid,
426
+ ephemeralKey: base64urlEncode(ephPub),
427
+ nonce: base64urlEncode(aesNonce),
428
+ ciphertext: base64urlEncode(ciphertextWithTag),
429
+ timestamp,
430
+ messageNonce,
431
+ };
432
+
433
+ return { envelope, ephemeralPublicKey: ephPub };
434
+ }
435
+
436
+ /**
437
+ * Decrypt an INK encrypted envelope using the recipient's X25519 private key.
438
+ * Returns the decrypted inner envelope and verifies inner/outer consistency.
439
+ */
440
+ export async function decryptInkPayload(
441
+ envelope: InkEncryptedEnvelope,
442
+ recipientEncryptionPrivateKeyHex: string,
443
+ recipientDid?: string,
444
+ ): Promise<Record<string, unknown>> {
445
+ if (envelope === null || typeof envelope !== "object" || Array.isArray(envelope)) {
446
+ throw new Error("envelope must be a non-null object");
447
+ }
448
+ if (envelope.protocol !== "ink/0.1") {
449
+ throw new Error("Unsupported protocol version");
450
+ }
451
+ if (envelope.type !== "network.tulpa.encrypted") {
452
+ throw new Error("Invalid encrypted envelope type");
453
+ }
454
+
455
+ // Pre-auth length caps on AAD fields. These all flow into JCS canonicalize
456
+ // + TextEncoder allocation before the AES-GCM tag check, so unbounded
457
+ // attacker-supplied strings would burn CPU/memory pre-verification.
458
+ // Non-empty check mirrors encryptInkPayload's input validation so
459
+ // encrypt and decrypt accept exactly the same scalar set — without
460
+ // the matching `length === 0` reject, decrypt would accept an
461
+ // envelope that encrypt could never have produced.
462
+ if (
463
+ typeof envelope.from !== "string" ||
464
+ envelope.from.length === 0 ||
465
+ envelope.from.length > 512
466
+ ) {
467
+ throw new Error("Invalid envelope from");
468
+ }
469
+ if (
470
+ typeof envelope.timestamp !== "string" ||
471
+ envelope.timestamp.length === 0 ||
472
+ envelope.timestamp.length > 64
473
+ ) {
474
+ throw new Error("Invalid envelope timestamp");
475
+ }
476
+ if (
477
+ typeof envelope.messageNonce !== "string" ||
478
+ envelope.messageNonce.length === 0 ||
479
+ envelope.messageNonce.length > 256
480
+ ) {
481
+ throw new Error("Invalid envelope messageNonce");
482
+ }
483
+
484
+ // 1. Decode and validate ephemeral public key from envelope.
485
+ // X25519 public keys are exactly 32 bytes = 43 unpadded base64url chars.
486
+ // Pre-check the encoded length BEFORE decoding so a 100 MB ephemeralKey
487
+ // field doesn't get fully decoded into a ~75 MB Uint8Array before the
488
+ // length === 32 check fires — same memory-exhaustion class the
489
+ // ciphertext cap below defends against.
490
+ if (typeof envelope.ephemeralKey !== "string" || envelope.ephemeralKey.length > 64) {
491
+ throw new Error("Invalid ephemeral key");
492
+ }
493
+ const ephPub = base64urlDecode(envelope.ephemeralKey);
494
+ if (ephPub.length !== 32) {
495
+ throw new Error("Invalid ephemeral key length");
496
+ }
497
+
498
+ // 2. ECDH shared secret. Explicit 32-byte length check on the decoded
499
+ // recipient private key (matches the encrypt path).
500
+ const recipientPriv = hexToBytes(recipientEncryptionPrivateKeyHex);
501
+ if (recipientPriv.length !== 32) {
502
+ throw new Error("recipientEncryptionPrivateKeyHex must decode to exactly 32 bytes");
503
+ }
504
+ const sharedSecret = x25519.getSharedSecret(recipientPriv, ephPub);
505
+
506
+ // Reject low-order / malicious ephemeral keys that produce an all-zero shared secret.
507
+ // An all-zero ECDH output is cryptographically invalid and would allow an attacker
508
+ // to construct ciphertexts decryptable by any recipient.
509
+ if (sharedSecret.every((b) => b === 0)) {
510
+ throw new Error("Invalid ephemeral key: ECDH shared secret is all zeros");
511
+ }
512
+
513
+ // 3. HKDF-SHA256 → AES key
514
+ const hkdfKey = await crypto.subtle.importKey(
515
+ "raw", sharedSecret, "HKDF", false, ["deriveBits"],
516
+ );
517
+ const symmetricBits = await crypto.subtle.deriveBits(
518
+ { name: "HKDF", hash: "SHA-256", salt: new TextEncoder().encode("ink/0.1"), info: new TextEncoder().encode("ink/0.1/encrypt") },
519
+ hkdfKey, 256,
520
+ );
521
+ const symmetricKey = new Uint8Array(symmetricBits);
522
+
523
+ // 4. AES-256-GCM decrypt.
524
+ // AES-GCM nonce is exactly 12 bytes = 16 unpadded base64url chars.
525
+ // Pre-check the encoded length BEFORE decoding to avoid allocating a
526
+ // large Uint8Array for an attacker-supplied oversized nonce field.
527
+ if (typeof envelope.nonce !== "string" || envelope.nonce.length > 32) {
528
+ throw new Error("Invalid AES-GCM nonce");
529
+ }
530
+ const aesNonce = base64urlDecode(envelope.nonce);
531
+ // AES-GCM requires a 12-byte IV. Reject any other length explicitly so callers
532
+ // get a clean error rather than an opaque WebCrypto exception.
533
+ if (aesNonce.length !== 12) {
534
+ throw new Error(`Invalid AES-GCM nonce length: expected 12 bytes, got ${aesNonce.length}`);
535
+ }
536
+ // Cap ciphertext size before base64url decode + AES-GCM allocation. Without
537
+ // this, a ~100 MB ciphertext would be decoded into ~75 MB Uint8Array and
538
+ // sent through GCM before the auth tag rejects it. 1 MB easily fits any
539
+ // realistic INK message payload while bounding memory under adversarial load.
540
+ const MAX_CIPHERTEXT_B64URL = 1_400_000;
541
+ if (typeof envelope.ciphertext !== "string" || envelope.ciphertext.length > MAX_CIPHERTEXT_B64URL) {
542
+ throw new Error("Ciphertext exceeds maximum allowed size");
543
+ }
544
+ const ciphertextWithTag = base64urlDecode(envelope.ciphertext);
545
+
546
+ const aesKey = await crypto.subtle.importKey("raw", symmetricKey, "AES-GCM", false, ["decrypt"]);
547
+ // AAD must match what was used during encryption — same unambiguous JSON-canonical format.
548
+ // protocol and type are now included to bind the ciphertext to this specific envelope type.
549
+ const aadObject = {
550
+ protocol: "ink/0.1",
551
+ type: "network.tulpa.encrypted",
552
+ from: envelope.from,
553
+ ephemeralKey: envelope.ephemeralKey,
554
+ nonce: envelope.nonce,
555
+ timestamp: envelope.timestamp,
556
+ messageNonce: envelope.messageNonce,
557
+ };
558
+ const aadString = `ink/0.1:envelope\n${jcsCanonicalize(aadObject)}`;
559
+ const aad = new TextEncoder().encode(aadString);
560
+ const plaintextBytes = new Uint8Array(
561
+ await crypto.subtle.decrypt({ name: "AES-GCM", iv: aesNonce, additionalData: aad }, aesKey, ciphertextWithTag),
562
+ );
563
+
564
+ // Plaintext is now AES-GCM-authenticated, so any well-formed JSON object
565
+ // here came from the sender. Still type-check before property access so
566
+ // a sender posting `null`/array/scalar payloads gets a clean validation
567
+ // error instead of a TypeError on `.from`.
568
+ const decryptedRaw = JSON.parse(new TextDecoder().decode(plaintextBytes));
569
+ if (decryptedRaw === null || typeof decryptedRaw !== "object" || Array.isArray(decryptedRaw)) {
570
+ throw new Error("Inner envelope must be a JSON object");
571
+ }
572
+ const decrypted = decryptedRaw as Record<string, unknown>;
573
+
574
+ // 5. Verify inner/outer consistency
575
+ if (decrypted.from !== envelope.from) {
576
+ throw new Error("Inner envelope 'from' does not match outer envelope");
577
+ }
578
+ // recipientDid is optional, but if the caller supplies it we MUST
579
+ // bind. Using `recipientDid &&` would silently skip the check on an
580
+ // empty string — an integrator passing `process.env.AGENT_DID ?? ""`
581
+ // would think they were binding and not be. Be explicit:
582
+ if (recipientDid !== undefined) {
583
+ if (typeof recipientDid !== "string" || recipientDid.length === 0) {
584
+ throw new Error("recipientDid must be a non-empty string when provided");
585
+ }
586
+ if (decrypted.to !== recipientDid) {
587
+ throw new Error("Inner envelope 'to' does not match recipient DID");
588
+ }
589
+ }
590
+
591
+ return decrypted;
592
+ }
593
+
594
+ // ── INK v0.1 Replay Protection (§3.5) ──
595
+
596
+ export interface ReplayCheckInput {
597
+ messageTimestamp: string;
598
+ receiverClock: string;
599
+ nonce: string;
600
+ previouslySeenNonces: string[];
601
+ }
602
+
603
+ export interface ReplayCheckResult {
604
+ accepted: boolean;
605
+ errorCode?: "expired_message" | "duplicate_nonce";
606
+ }
607
+
608
+ export const MAX_TIMESTAMP_AGE_MS = 5 * 60 * 1000; // 5 minutes
609
+ export const MAX_FUTURE_TIMESTAMP_MS = 30 * 1000; // 30 seconds
610
+
611
+ /**
612
+ * Check whether an INK message should be accepted or rejected
613
+ * based on timestamp freshness and nonce deduplication (§3.5).
614
+ */
615
+ export function checkReplay(input: ReplayCheckInput): ReplayCheckResult {
616
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
617
+ return { accepted: false, errorCode: "expired_message" };
618
+ }
619
+ if (
620
+ typeof input.nonce !== "string" ||
621
+ input.nonce.length < 16 ||
622
+ input.nonce.length > 256 ||
623
+ !/^[A-Za-z0-9_-]+$/.test(input.nonce)
624
+ ) {
625
+ return { accepted: false, errorCode: "expired_message" };
626
+ }
627
+ if (!Array.isArray(input.previouslySeenNonces) || input.previouslySeenNonces.length > 10_000) {
628
+ return { accepted: false, errorCode: "expired_message" };
629
+ }
630
+
631
+ // Length-cap both timestamp strings before handing them to Date()
632
+ // so a multi-megabyte value can't burn CPU in the engine's date
633
+ // parser before the finite-time check rejects. 64 chars matches the
634
+ // cap used elsewhere in INK (ISO 8601 fits in ~30 chars).
635
+ if (
636
+ typeof input.messageTimestamp !== "string" ||
637
+ input.messageTimestamp.length === 0 ||
638
+ input.messageTimestamp.length > 64 ||
639
+ typeof input.receiverClock !== "string" ||
640
+ input.receiverClock.length === 0 ||
641
+ input.receiverClock.length > 64
642
+ ) {
643
+ return { accepted: false, errorCode: "expired_message" };
644
+ }
645
+ // Parse timestamps — NaN values would cause all drift comparisons to return
646
+ // false (NaN > x and NaN < x are both false), allowing any timestamp to pass.
647
+ // Explicitly reject non-finite results.
648
+ const msgTime = new Date(input.messageTimestamp).getTime();
649
+ const recvTime = new Date(input.receiverClock).getTime();
650
+ if (!Number.isFinite(msgTime) || !Number.isFinite(recvTime)) {
651
+ return { accepted: false, errorCode: "expired_message" };
652
+ }
653
+
654
+ const drift = msgTime - recvTime;
655
+
656
+ // Reject if timestamp is too far in the future
657
+ if (drift > MAX_FUTURE_TIMESTAMP_MS) {
658
+ return { accepted: false, errorCode: "expired_message" };
659
+ }
660
+
661
+ // Reject if timestamp is too old
662
+ if (-drift > MAX_TIMESTAMP_AGE_MS) {
663
+ return { accepted: false, errorCode: "expired_message" };
664
+ }
665
+
666
+ // Reject if nonce was already seen
667
+ if (input.previouslySeenNonces.includes(input.nonce)) {
668
+ return { accepted: false, errorCode: "duplicate_nonce" };
669
+ }
670
+
671
+ return { accepted: true };
672
+ }
673
+
674
+ // ── INK Audit Crypto (Auditability §2) ──
675
+
676
+ /**
677
+ * Compute SHA-256 hash of JCS-canonicalized body. Returns hex string.
678
+ * Used for messageHash in receipts and previousEventHash in audit chains.
679
+ */
680
+ export async function computeMessageHash(body: Record<string, unknown>): Promise<string> {
681
+ // Mirrors the sign/verify-side guards. messageHash is bound into
682
+ // receipts; a poisoned receipt body would otherwise burn CPU inside
683
+ // canonicalize before the receipt verifier ever rejects it.
684
+ if (!isWithinCanonicalizeBounds(body)) {
685
+ throw new Error("Message body exceeds maximum allowed complexity");
686
+ }
687
+ const canonical = jcsCanonicalize(body);
688
+ if (canonical.length > MAX_SIGBASE_BODY_BYTES) {
689
+ throw new Error("Message body exceeds maximum allowed size");
690
+ }
691
+ const bytes = new TextEncoder().encode(canonical);
692
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
693
+ return bytesToHex(new Uint8Array(digest));
694
+ }
695
+
696
+ /**
697
+ * Sign an INK audit event. Returns base64url-encoded Ed25519 signature.
698
+ * Signs the JCS-canonicalized event with the agentSignature field excluded.
699
+ */
700
+ export async function signAuditEvent(
701
+ event: Record<string, unknown>,
702
+ privateKey: Uint8Array,
703
+ ): Promise<string> {
704
+ if (event === null || typeof event !== "object" || Array.isArray(event)) {
705
+ throw new Error("event must be a non-null object");
706
+ }
707
+ // Remove agentSignature before canonicalizing
708
+ const { agentSignature: _, ...eventWithoutSig } = event;
709
+ // Mirror the verify-side guards: refuse pathological events at sign
710
+ // time so a service can't be coerced into burning CPU/memory minting
711
+ // a signature over an event no verifier would accept.
712
+ if (!isWithinCanonicalizeBounds(eventWithoutSig)) {
713
+ throw new Error("Audit event exceeds maximum allowed complexity");
714
+ }
715
+ const canonical = jcsCanonicalize(eventWithoutSig);
716
+ if (canonical.length > MAX_SIGBASE_BODY_BYTES) {
717
+ throw new Error("Audit event exceeds maximum allowed size");
718
+ }
719
+ // Domain separation: prefix prevents cross-protocol signature replay
720
+ const prefixed = `ink/audit-event\n${canonical}`;
721
+ const bytes = new TextEncoder().encode(prefixed);
722
+ const sig = await ed.signAsync(bytes, privateKey);
723
+ return base64urlEncode(sig);
724
+ }
725
+
726
+ /**
727
+ * Verify an INK audit event signature.
728
+ * Returns false (never throws) for malformed or wrong-length signatures.
729
+ */
730
+ export async function verifyAuditEventSignature(
731
+ event: Record<string, unknown>,
732
+ publicKey: Uint8Array,
733
+ ): Promise<boolean> {
734
+ if (event === null || typeof event !== "object" || Array.isArray(event)) return false;
735
+ const signature = event.agentSignature as string;
736
+ if (typeof signature !== "string") return false;
737
+ // Ed25519 signatures are exactly 64 bytes = 86 unpadded base64url chars.
738
+ if (!/^[A-Za-z0-9_-]{86}$/.test(signature)) return false;
739
+ const { agentSignature: _, ...eventWithoutSig } = event;
740
+ // Pre-canonicalize complexity cap: bail before jcsCanonicalize walks an
741
+ // attacker-supplied object that would only get rejected by the size cap
742
+ // below. Cheap enough that it adds no cost for real events.
743
+ if (!isWithinCanonicalizeBounds(eventWithoutSig)) return false;
744
+ const canonical = jcsCanonicalize(eventWithoutSig);
745
+ // Defense-in-depth: cap canonicalized body size to bound pre-verify work.
746
+ if (canonical.length > MAX_SIGBASE_BODY_BYTES) return false;
747
+ // Domain separation: must match signAuditEvent prefix
748
+ const prefixed = `ink/audit-event\n${canonical}`;
749
+ const bytes = new TextEncoder().encode(prefixed);
750
+ try {
751
+ const sig = base64urlDecode(signature);
752
+ return await ed.verifyAsync(sig, bytes, publicKey);
753
+ } catch {
754
+ // Malformed signature (wrong length, invalid chars, bad key) — treat as invalid
755
+ return false;
756
+ }
757
+ }
758
+
759
+ /**
760
+ * Compute SHA-256 hash of JCS-canonicalized audit event (excluding agentSignature).
761
+ * Used for previousEventHash chain linkage.
762
+ */
763
+ export async function computeEventHash(event: Record<string, unknown>): Promise<string> {
764
+ if (event === null || typeof event !== "object" || Array.isArray(event)) {
765
+ throw new Error("event must be a non-null object");
766
+ }
767
+ const { agentSignature: _, ...eventWithoutSig } = event;
768
+ // Mirrors the sign/verify-side guards: previousEventHash flows from
769
+ // this function into hash-chained audit logs, so a poisoned event
770
+ // could otherwise burn CPU/memory inside canonicalize before the
771
+ // chain insertion path notices the size.
772
+ if (!isWithinCanonicalizeBounds(eventWithoutSig)) {
773
+ throw new Error("Audit event exceeds maximum allowed complexity");
774
+ }
775
+ const canonical = jcsCanonicalize(eventWithoutSig);
776
+ if (canonical.length > MAX_SIGBASE_BODY_BYTES) {
777
+ throw new Error("Audit event exceeds maximum allowed size");
778
+ }
779
+ const bytes = new TextEncoder().encode(canonical);
780
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
781
+ return bytesToHex(new Uint8Array(digest));
782
+ }
783
+
784
+ /**
785
+ * Sign an INK audit response. Returns base64url-encoded Ed25519 signature.
786
+ * Domain-separated: signs "ink/audit-response\n" + JCS(events) to prevent
787
+ * cross-protocol signature replay.
788
+ */
789
+ export async function signAuditResponse(
790
+ events: unknown[],
791
+ privateKey: Uint8Array,
792
+ ): Promise<string> {
793
+ // Pre-canonicalize complexity cap — mirrors verifyAuditResponseSignature
794
+ // so a peer requesting an audit response cannot make the responder
795
+ // burn CPU/memory inside jcsCanonicalize before the length cap below.
796
+ if (!isWithinCanonicalizeBounds(events)) {
797
+ throw new Error("Audit response events exceed maximum allowed complexity");
798
+ }
799
+ const canonical = jcsCanonicalize(events);
800
+ // Cap canonicalized body size — mirrors the verify path's guard so the
801
+ // sign side can't be used to mint signatures over payloads larger than
802
+ // any conformant verifier would accept.
803
+ if (canonical.length > MAX_SIGBASE_BODY_BYTES) {
804
+ throw new Error("Audit response events exceed maximum allowed size");
805
+ }
806
+ const prefixed = `ink/audit-response\n${canonical}`;
807
+ const bytes = new TextEncoder().encode(prefixed);
808
+ const sig = await ed.signAsync(bytes, privateKey);
809
+ return base64urlEncode(sig);
810
+ }
811
+
812
+ /**
813
+ * Verify an INK audit response signature.
814
+ * Expects the domain-separated format: "ink/audit-response\n" + JCS(events).
815
+ * Returns false (never throws) for malformed or wrong-length signatures.
816
+ */
817
+ export async function verifyAuditResponseSignature(
818
+ events: unknown[],
819
+ signature: string,
820
+ publicKey: Uint8Array,
821
+ ): Promise<boolean> {
822
+ if (!Array.isArray(events)) return false;
823
+ if (typeof signature !== "string") return false;
824
+ // Ed25519 signatures are exactly 64 bytes = 86 unpadded base64url chars.
825
+ if (!/^[A-Za-z0-9_-]{86}$/.test(signature)) return false;
826
+ // Pre-canonicalize complexity cap (see verifyAuditEventSignature).
827
+ if (!isWithinCanonicalizeBounds(events)) return false;
828
+ const canonical = jcsCanonicalize(events);
829
+ // Defense-in-depth: cap canonicalized body size to bound pre-verify work.
830
+ if (canonical.length > MAX_SIGBASE_BODY_BYTES) return false;
831
+ const prefixed = `ink/audit-response\n${canonical}`;
832
+ const bytes = new TextEncoder().encode(prefixed);
833
+ try {
834
+ const sig = base64urlDecode(signature);
835
+ return await ed.verifyAsync(sig, bytes, publicKey);
836
+ } catch {
837
+ // Malformed signature (wrong length, invalid chars, bad key) — treat as invalid
838
+ return false;
839
+ }
840
+ }
841
+
842
+ /**
843
+ * Validate the internal continuity of an audit event chain. Distinct
844
+ * from verifyAuditResponseSignature, which only verifies the response
845
+ * wrapper signature. Callers fetching audit responses MUST call both:
846
+ * the signature gate proves the witness/agent attested to this slice,
847
+ * this gate proves the slice itself is contiguous and fork-free.
848
+ *
849
+ * Rules enforced:
850
+ * - input must be an array of non-null plain objects
851
+ * - each event must have integer sequence and string-or-null previousEventHash
852
+ * - sequences within the response must be strictly increasing by 1
853
+ * (a partial-window response anchored elsewhere is fine, but no internal gaps)
854
+ * - duplicate sequence numbers within the response are a fork
855
+ * - events[i].previousEventHash MUST equal computeEventHash(events[i-1]) for i >= 1
856
+ * - events[0].previousEventHash is NOT verified against any external
857
+ * anchor; callers that have one (a prior pinned event hash) must
858
+ * verify the boundary themselves
859
+ */
860
+ export async function verifyAuditEventChain(
861
+ events: unknown,
862
+ ): Promise<
863
+ | { valid: true }
864
+ | { valid: false; error: "invalid_input" | "invalid_event" | "sequence_gap" | "sequence_fork" | "previous_hash_mismatch" }
865
+ > {
866
+ if (!Array.isArray(events)) return { valid: false, error: "invalid_input" };
867
+ if (events.length === 0) return { valid: true };
868
+
869
+ let lastSeq: number | null = null;
870
+ let lastHash: string | null = null;
871
+ for (let i = 0; i < events.length; i++) {
872
+ const ev = events[i];
873
+ if (ev === null || typeof ev !== "object" || Array.isArray(ev)) {
874
+ return { valid: false, error: "invalid_event" };
875
+ }
876
+ const seq = (ev as Record<string, unknown>).sequence;
877
+ const prev = (ev as Record<string, unknown>).previousEventHash;
878
+ if (typeof seq !== "number" || !Number.isInteger(seq) || seq < 1) {
879
+ return { valid: false, error: "invalid_event" };
880
+ }
881
+ if (prev !== null && typeof prev !== "string") {
882
+ return { valid: false, error: "invalid_event" };
883
+ }
884
+ if (i > 0) {
885
+ if (seq === lastSeq) return { valid: false, error: "sequence_fork" };
886
+ if (seq !== (lastSeq as number) + 1) return { valid: false, error: "sequence_gap" };
887
+ if (prev !== lastHash) return { valid: false, error: "previous_hash_mismatch" };
888
+ }
889
+ let thisHash: string;
890
+ try {
891
+ thisHash = await computeEventHash(ev as Record<string, unknown>);
892
+ } catch {
893
+ return { valid: false, error: "invalid_event" };
894
+ }
895
+ lastSeq = seq;
896
+ lastHash = thisHash;
897
+ }
898
+ return { valid: true };
899
+ }
900
+
901
+ // Re-export encoding helpers for test use
902
+ export { base64urlEncode, base64urlDecode, hexToBytes, bytesToHex, jcsCanonicalize };