@adastracomputing/ink 0.1.0-alpha.3 → 0.1.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/audit/inclusion-receipt.d.ts +142 -0
  3. package/dist/audit/inclusion-receipt.js +496 -0
  4. package/dist/crypto/ink.d.ts +178 -0
  5. package/dist/crypto/ink.js +915 -0
  6. package/dist/crypto/keys.d.ts +42 -0
  7. package/dist/crypto/keys.js +179 -0
  8. package/dist/crypto/multi-key-verify.d.ts +29 -0
  9. package/dist/crypto/multi-key-verify.js +153 -0
  10. package/dist/crypto/sign.d.ts +17 -0
  11. package/dist/crypto/sign.js +152 -0
  12. package/dist/crypto/verify.js +1 -0
  13. package/dist/discovery/agent-card.d.ts +83 -0
  14. package/dist/discovery/agent-card.js +545 -0
  15. package/dist/index.d.ts +12 -0
  16. package/dist/index.js +15 -0
  17. package/dist/ink/checkpoint.d.ts +19 -0
  18. package/dist/ink/checkpoint.js +69 -0
  19. package/dist/ink/discovery-gating.d.ts +237 -0
  20. package/dist/ink/discovery-gating.js +91 -0
  21. package/dist/ink/handshake-budget.d.ts +90 -0
  22. package/dist/ink/handshake-budget.js +397 -0
  23. package/dist/ink/receipts.d.ts +31 -0
  24. package/dist/ink/receipts.js +89 -0
  25. package/dist/ink/transport-auth.d.ts +47 -0
  26. package/dist/ink/transport-auth.js +77 -0
  27. package/dist/middleware/ink-auth.d.ts +68 -0
  28. package/dist/middleware/ink-auth.js +214 -0
  29. package/dist/models/agent-card.d.ts +154 -0
  30. package/dist/models/agent-card.js +59 -0
  31. package/dist/models/ink-audit.d.ts +344 -0
  32. package/dist/models/ink-audit.js +167 -0
  33. package/dist/models/ink-handshake.d.ts +129 -0
  34. package/dist/models/ink-handshake.js +89 -0
  35. package/dist/models/intent.d.ts +437 -0
  36. package/dist/models/intent.js +172 -0
  37. package/dist/models/key-entry.d.ts +60 -0
  38. package/dist/models/key-entry.js +13 -0
  39. package/dist/models/profile.d.ts +61 -0
  40. package/dist/models/profile.js +24 -0
  41. package/package.json +15 -11
  42. package/src/audit/inclusion-receipt.ts +0 -604
  43. package/src/crypto/ink.ts +0 -1046
  44. package/src/crypto/keys.ts +0 -210
  45. package/src/crypto/multi-key-verify.ts +0 -170
  46. package/src/crypto/sign.ts +0 -155
  47. package/src/discovery/agent-card.ts +0 -508
  48. package/src/index.ts +0 -73
  49. package/src/ink/checkpoint.ts +0 -75
  50. package/src/ink/discovery-gating.ts +0 -147
  51. package/src/ink/handshake-budget.ts +0 -413
  52. package/src/ink/receipts.ts +0 -114
  53. package/src/ink/transport-auth.ts +0 -96
  54. package/src/middleware/ink-auth.ts +0 -263
  55. package/src/models/agent-card.ts +0 -63
  56. package/src/models/ink-audit.ts +0 -205
  57. package/src/models/ink-handshake.ts +0 -123
  58. package/src/models/intent.ts +0 -201
  59. package/src/models/key-entry.ts +0 -52
  60. package/src/models/profile.ts +0 -31
  61. /package/{src/crypto/verify.ts → dist/crypto/verify.d.ts} +0 -0
package/src/crypto/ink.ts DELETED
@@ -1,1046 +0,0 @@
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 (new TextEncoder().encode(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
- const bytes = new TextEncoder().encode(canonical);
689
- if (bytes.length > MAX_SIGBASE_BODY_BYTES) {
690
- throw new Error("Message body exceeds maximum allowed size");
691
- }
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
- const prefixed = `ink/audit-event\n${canonical}`;
717
- const bytes = new TextEncoder().encode(prefixed);
718
- if (bytes.length > MAX_SIGBASE_BODY_BYTES) {
719
- throw new Error("Audit event exceeds maximum allowed size");
720
- }
721
- const sig = await ed.signAsync(bytes, privateKey);
722
- return base64urlEncode(sig);
723
- }
724
-
725
- /**
726
- * Verify an INK audit event signature.
727
- * Returns false (never throws) for malformed or wrong-length signatures.
728
- */
729
- export async function verifyAuditEventSignature(
730
- event: Record<string, unknown>,
731
- publicKey: Uint8Array,
732
- ): Promise<boolean> {
733
- if (event === null || typeof event !== "object" || Array.isArray(event)) return false;
734
- const signature = event.agentSignature as string;
735
- if (typeof signature !== "string") return false;
736
- // Ed25519 signatures are exactly 64 bytes = 86 unpadded base64url chars.
737
- if (!/^[A-Za-z0-9_-]{86}$/.test(signature)) return false;
738
- const { agentSignature: _, ...eventWithoutSig } = event;
739
- // Pre-canonicalize complexity cap: bail before jcsCanonicalize walks an
740
- // attacker-supplied object that would only get rejected by the size cap
741
- // below. Cheap enough that it adds no cost for real events.
742
- if (!isWithinCanonicalizeBounds(eventWithoutSig)) return false;
743
- try {
744
- const canonical = jcsCanonicalize(eventWithoutSig);
745
- const prefixed = `ink/audit-event\n${canonical}`;
746
- const bytes = new TextEncoder().encode(prefixed);
747
- // Defense-in-depth: cap signed-body byte count to bound pre-verify work.
748
- // UTF-8 byte length, not JS string length, so multi-byte event data
749
- // cannot smuggle past the cap.
750
- if (bytes.length > MAX_SIGBASE_BODY_BYTES) return false;
751
- const sig = base64urlDecode(signature);
752
- return await ed.verifyAsync(sig, bytes, publicKey);
753
- } catch {
754
- return false;
755
- }
756
- }
757
-
758
- /**
759
- * Compute the RFC 6962 Merkle leaf hash for an INK audit event:
760
- *
761
- * SHA-256(0x00 || JCS(event-without-agentSignature))
762
- *
763
- * This is the leaf-hashing rule a witness MUST use when building its
764
- * transparency log (Auditability §7.3). It is distinct from
765
- * `computeEventHash`, which omits the 0x00 prefix and is used only for
766
- * `previousEventHash` chain linkage inside the agent's local audit log.
767
- *
768
- * Returns the lowercase-hex digest.
769
- */
770
- export async function computeAuditMerkleLeafHash(event: Record<string, unknown>): Promise<string> {
771
- if (event === null || typeof event !== "object" || Array.isArray(event)) {
772
- throw new Error("event must be a non-null object");
773
- }
774
- const { agentSignature: _, ...eventWithoutSig } = event;
775
- if (!isWithinCanonicalizeBounds(eventWithoutSig)) {
776
- throw new Error("Audit event exceeds maximum allowed complexity");
777
- }
778
- const canonical = jcsCanonicalize(eventWithoutSig);
779
- const canonicalBytes = new TextEncoder().encode(canonical);
780
- if (canonicalBytes.length > MAX_SIGBASE_BODY_BYTES) {
781
- throw new Error("Audit event exceeds maximum allowed size");
782
- }
783
- const prefixed = new Uint8Array(canonicalBytes.length + 1);
784
- prefixed[0] = 0x00;
785
- prefixed.set(canonicalBytes, 1);
786
- const digest = await crypto.subtle.digest("SHA-256", prefixed);
787
- return bytesToHex(new Uint8Array(digest));
788
- }
789
-
790
- /**
791
- * Compute SHA-256 hash of JCS-canonicalized audit event (excluding agentSignature).
792
- * Used for previousEventHash chain linkage. NOT the Merkle leaf hash:
793
- * see `computeAuditMerkleLeafHash` for the RFC 6962 leaf-hash rule used
794
- * by witness transparency logs.
795
- */
796
- export async function computeEventHash(event: Record<string, unknown>): Promise<string> {
797
- if (event === null || typeof event !== "object" || Array.isArray(event)) {
798
- throw new Error("event must be a non-null object");
799
- }
800
- const { agentSignature: _, ...eventWithoutSig } = event;
801
- // Mirrors the sign/verify-side guards: previousEventHash flows from
802
- // this function into hash-chained audit logs, so a poisoned event
803
- // could otherwise burn CPU/memory inside canonicalize before the
804
- // chain insertion path notices the size.
805
- if (!isWithinCanonicalizeBounds(eventWithoutSig)) {
806
- throw new Error("Audit event exceeds maximum allowed complexity");
807
- }
808
- const canonical = jcsCanonicalize(eventWithoutSig);
809
- const bytes = new TextEncoder().encode(canonical);
810
- if (bytes.length > MAX_SIGBASE_BODY_BYTES) {
811
- throw new Error("Audit event exceeds maximum allowed size");
812
- }
813
- const digest = await crypto.subtle.digest("SHA-256", bytes);
814
- return bytesToHex(new Uint8Array(digest));
815
- }
816
-
817
- /**
818
- * Sign an INK audit response. Returns base64url-encoded Ed25519 signature.
819
- * Domain-separated: signs "ink/audit-response\n" + JCS(events) to prevent
820
- * cross-protocol signature replay.
821
- */
822
- export async function signAuditResponse(
823
- events: unknown[],
824
- privateKey: Uint8Array,
825
- ): Promise<string> {
826
- // Pre-canonicalize complexity cap — mirrors verifyAuditResponseSignature
827
- // so a peer requesting an audit response cannot make the responder
828
- // burn CPU/memory inside jcsCanonicalize before the length cap below.
829
- if (!isWithinCanonicalizeBounds(events)) {
830
- throw new Error("Audit response events exceed maximum allowed complexity");
831
- }
832
- const canonical = jcsCanonicalize(events);
833
- const prefixed = `ink/audit-response\n${canonical}`;
834
- const bytes = new TextEncoder().encode(prefixed);
835
- // Cap signed-body byte count. Mirrors the verify path's guard so the
836
- // sign side can't mint signatures over payloads larger than any
837
- // conformant verifier would accept.
838
- if (bytes.length > MAX_SIGBASE_BODY_BYTES) {
839
- throw new Error("Audit response events exceed maximum allowed size");
840
- }
841
- const sig = await ed.signAsync(bytes, privateKey);
842
- return base64urlEncode(sig);
843
- }
844
-
845
- /**
846
- * Verify an INK audit response signature.
847
- * Expects the domain-separated format: "ink/audit-response\n" + JCS(events).
848
- * Returns false (never throws) for malformed or wrong-length signatures.
849
- */
850
- export async function verifyAuditResponseSignature(
851
- events: unknown[],
852
- signature: string,
853
- publicKey: Uint8Array,
854
- ): Promise<boolean> {
855
- if (!Array.isArray(events)) return false;
856
- if (typeof signature !== "string") return false;
857
- // Ed25519 signatures are exactly 64 bytes = 86 unpadded base64url chars.
858
- if (!/^[A-Za-z0-9_-]{86}$/.test(signature)) return false;
859
- // Pre-canonicalize complexity cap (see verifyAuditEventSignature).
860
- if (!isWithinCanonicalizeBounds(events)) return false;
861
- try {
862
- const canonical = jcsCanonicalize(events);
863
- const prefixed = `ink/audit-response\n${canonical}`;
864
- const bytes = new TextEncoder().encode(prefixed);
865
- if (bytes.length > MAX_SIGBASE_BODY_BYTES) return false;
866
- const sig = base64urlDecode(signature);
867
- return await ed.verifyAsync(sig, bytes, publicKey);
868
- } catch {
869
- return false;
870
- }
871
- }
872
-
873
- /**
874
- * Validate the internal continuity of an audit event chain. Distinct
875
- * from verifyAuditResponseSignature, which only verifies the response
876
- * wrapper signature. Callers fetching audit responses MUST call both:
877
- * the signature gate proves the witness/agent attested to this slice,
878
- * this gate proves the slice itself is contiguous and fork-free.
879
- *
880
- * Rules enforced:
881
- * - input must be an array of non-null plain objects
882
- * - each event must have integer sequence and string-or-null previousEventHash
883
- * - sequences within the response must be strictly increasing by 1
884
- * (a partial-window response anchored elsewhere is fine, but no internal gaps)
885
- * - duplicate sequence numbers within the response are a fork
886
- * - events[i].previousEventHash MUST equal computeEventHash(events[i-1]) for i >= 1
887
- * - events[0].previousEventHash is NOT verified against any external
888
- * anchor; callers that have one (a prior pinned event hash) must
889
- * verify the boundary themselves
890
- */
891
- export async function verifyAuditEventChain(
892
- events: unknown,
893
- ): Promise<
894
- | { valid: true }
895
- | { valid: false; error: "invalid_input" | "invalid_event" | "sequence_gap" | "sequence_fork" | "previous_hash_mismatch" }
896
- > {
897
- if (!Array.isArray(events)) return { valid: false, error: "invalid_input" };
898
- if (events.length === 0) return { valid: true };
899
-
900
- let lastSeq: number | null = null;
901
- let lastHash: string | null = null;
902
- for (let i = 0; i < events.length; i++) {
903
- const ev = events[i];
904
- if (ev === null || typeof ev !== "object" || Array.isArray(ev)) {
905
- return { valid: false, error: "invalid_event" };
906
- }
907
- const seq = (ev as Record<string, unknown>).sequence;
908
- const prev = (ev as Record<string, unknown>).previousEventHash;
909
- if (typeof seq !== "number" || !Number.isInteger(seq) || seq < 1) {
910
- return { valid: false, error: "invalid_event" };
911
- }
912
- if (prev !== null && typeof prev !== "string") {
913
- return { valid: false, error: "invalid_event" };
914
- }
915
- if (i > 0) {
916
- if (seq === lastSeq) return { valid: false, error: "sequence_fork" };
917
- if (seq !== (lastSeq as number) + 1) return { valid: false, error: "sequence_gap" };
918
- if (prev !== lastHash) return { valid: false, error: "previous_hash_mismatch" };
919
- }
920
- let thisHash: string;
921
- try {
922
- thisHash = await computeEventHash(ev as Record<string, unknown>);
923
- } catch {
924
- return { valid: false, error: "invalid_event" };
925
- }
926
- lastSeq = seq;
927
- lastHash = thisHash;
928
- }
929
- return { valid: true };
930
- }
931
-
932
- // ── Audit-query response (witness side, Auditability Section 7.3) ──
933
- //
934
- // Distinct from signAuditResponse, which is the bilateral peer-to-peer
935
- // audit-exchange response between two agents. The witness query response
936
- // commits the WITNESS to (a) the events, (b) per-event Merkle proofs,
937
- // (c) the witness's treeSize / rootHash at response time, (d) the
938
- // messageId queried, signed under the witness's identity key.
939
-
940
- /**
941
- * Sign an INK audit-query response from a witness. The signed bytes are:
942
- *
943
- * "ink/audit-query-response/v1\n" + JCS(response object minus serviceSignature)
944
- *
945
- * Callers pass the response object EXCLUDING `serviceSignature`. The
946
- * canonical bytes bind every other field, including `protocol`, `type`,
947
- * `messageId`, `events`, `proofs`, `treeSize`, `rootHash`, `serviceDid`,
948
- * and `timestamp`, so verifiers cannot rebind a valid signature to a
949
- * different witness/message/root.
950
- */
951
- export async function signAuditQueryResponse(
952
- responseWithoutSignature: Record<string, unknown>,
953
- privateKey: Uint8Array,
954
- ): Promise<string> {
955
- if (responseWithoutSignature === null || typeof responseWithoutSignature !== "object" || Array.isArray(responseWithoutSignature)) {
956
- throw new Error("response must be a non-null object");
957
- }
958
- // §7.3 / §7.4 sign-side scope enforcement. A conformant witness must
959
- // not mint a signature over a response where any event falls outside
960
- // the envelope's (messageId, requester) scope: those rules apply at
961
- // sign time as well as at verify time. Without this, a witness that
962
- // composed payloads incorrectly could ship alpha.3-invalid signed
963
- // bytes that the high-level verifier would then reject. Catching it
964
- // here ensures the primitive is self-defending.
965
- const envMessageId = (responseWithoutSignature as { messageId?: unknown }).messageId;
966
- const envRequester = (responseWithoutSignature as { requester?: unknown }).requester;
967
- const events = (responseWithoutSignature as { events?: unknown }).events;
968
- if (Array.isArray(events) && events.length > 0) {
969
- if (typeof envMessageId !== "string" || envMessageId.length === 0) {
970
- throw new Error("Audit-query response must include a non-empty messageId");
971
- }
972
- if (typeof envRequester !== "string" || envRequester.length === 0) {
973
- throw new Error("Audit-query response must include a non-empty requester");
974
- }
975
- for (const event of events) {
976
- if (event === null || typeof event !== "object" || Array.isArray(event)) {
977
- throw new Error("Every event must be a non-null object");
978
- }
979
- const e = event as { messageId?: unknown; agentId?: unknown; counterpartyId?: unknown; agentSignature?: unknown };
980
- if (e.messageId !== envMessageId) {
981
- throw new Error("Per-event scope violation: event.messageId does not match envelope.messageId");
982
- }
983
- const requesterIsParty =
984
- (typeof e.agentId === "string" && e.agentId === envRequester) ||
985
- (typeof e.counterpartyId === "string" && e.counterpartyId === envRequester);
986
- if (!requesterIsParty) {
987
- throw new Error("Per-event scope violation: requester is not a party (agentId/counterpartyId)");
988
- }
989
- // §7.3 verifier MUST check agentSignature; sign-side mirror so a
990
- // witness using this primitive cannot ship signed responses that
991
- // strip per-event provenance.
992
- if (typeof e.agentSignature !== "string" || e.agentSignature.length === 0) {
993
- throw new Error("Per-event scope violation: event.agentSignature is missing or empty");
994
- }
995
- }
996
- }
997
- if (!isWithinCanonicalizeBounds(responseWithoutSignature)) {
998
- throw new Error("Audit-query response exceeds maximum allowed complexity");
999
- }
1000
- const canonical = jcsCanonicalize(responseWithoutSignature);
1001
- const prefixed = `ink/audit-query-response/v1\n${canonical}`;
1002
- const bytes = new TextEncoder().encode(prefixed);
1003
- if (bytes.length > MAX_SIGBASE_BODY_BYTES) {
1004
- throw new Error("Audit-query response exceeds maximum allowed size");
1005
- }
1006
- const sig = await ed.signAsync(bytes, privateKey);
1007
- return base64urlEncode(sig);
1008
- }
1009
-
1010
- /**
1011
- * Verify the Ed25519 signature on an audit-query response. This is the
1012
- * LOW-LEVEL primitive. Most consumers should call
1013
- * `verifyAuditQueryResponse` (from `src/audit/inclusion-receipt.ts`)
1014
- * instead: it enforces envelope shape, requester binding, the
1015
- * events-to-proofs one-to-one mapping, and walks every Merkle proof.
1016
- *
1017
- * Calling this function alone does NOT prove the response is acceptable.
1018
- * A signed but malformed envelope (wrong type, wrong protocol, no
1019
- * proofs, wrong requester) can still pass here. Caller is responsible
1020
- * for pinning / resolving the witness public key out of band (e.g.
1021
- * via /.well-known/did.json). Returns false (never throws) for any
1022
- * malformed input.
1023
- */
1024
- export async function verifyAuditQueryResponseSignature(
1025
- responseWithoutSignature: Record<string, unknown>,
1026
- signature: string,
1027
- publicKey: Uint8Array,
1028
- ): Promise<boolean> {
1029
- if (responseWithoutSignature === null || typeof responseWithoutSignature !== "object" || Array.isArray(responseWithoutSignature)) return false;
1030
- if (typeof signature !== "string") return false;
1031
- if (!/^[A-Za-z0-9_-]{86}$/.test(signature)) return false;
1032
- if (!isWithinCanonicalizeBounds(responseWithoutSignature)) return false;
1033
- try {
1034
- const canonical = jcsCanonicalize(responseWithoutSignature);
1035
- const prefixed = `ink/audit-query-response/v1\n${canonical}`;
1036
- const bytes = new TextEncoder().encode(prefixed);
1037
- if (bytes.length > MAX_SIGBASE_BODY_BYTES) return false;
1038
- const sig = base64urlDecode(signature);
1039
- return await ed.verifyAsync(sig, bytes, publicKey);
1040
- } catch {
1041
- return false;
1042
- }
1043
- }
1044
-
1045
- // Re-export encoding helpers for test use
1046
- export { base64urlEncode, base64urlDecode, hexToBytes, bytesToHex, jcsCanonicalize };