@adastracomputing/ink 0.1.0-alpha.3 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +15 -3
  3. package/dist/audit/inclusion-receipt.d.ts +142 -0
  4. package/dist/audit/inclusion-receipt.js +496 -0
  5. package/dist/crypto/ink.d.ts +178 -0
  6. package/dist/crypto/ink.js +915 -0
  7. package/dist/crypto/keys.d.ts +42 -0
  8. package/dist/crypto/keys.js +179 -0
  9. package/dist/crypto/multi-key-verify.d.ts +29 -0
  10. package/dist/crypto/multi-key-verify.js +153 -0
  11. package/dist/crypto/sign.d.ts +17 -0
  12. package/dist/crypto/sign.js +152 -0
  13. package/dist/crypto/verify.js +1 -0
  14. package/dist/discovery/agent-card.d.ts +83 -0
  15. package/dist/discovery/agent-card.js +545 -0
  16. package/dist/index.d.ts +13 -0
  17. package/dist/index.js +16 -0
  18. package/dist/ink/checkpoint.d.ts +19 -0
  19. package/dist/ink/checkpoint.js +69 -0
  20. package/dist/ink/discovery-gating.d.ts +247 -0
  21. package/dist/ink/discovery-gating.js +94 -0
  22. package/dist/ink/handshake-budget.d.ts +90 -0
  23. package/dist/ink/handshake-budget.js +397 -0
  24. package/dist/ink/receipts.d.ts +31 -0
  25. package/dist/ink/receipts.js +89 -0
  26. package/dist/ink/transport-auth.d.ts +47 -0
  27. package/dist/ink/transport-auth.js +77 -0
  28. package/dist/middleware/ink-auth.d.ts +68 -0
  29. package/dist/middleware/ink-auth.js +214 -0
  30. package/dist/models/agent-card.d.ts +170 -0
  31. package/dist/models/agent-card.js +107 -0
  32. package/dist/models/ink-audit.d.ts +344 -0
  33. package/dist/models/ink-audit.js +167 -0
  34. package/dist/models/ink-handshake.d.ts +129 -0
  35. package/dist/models/ink-handshake.js +89 -0
  36. package/dist/models/intent.d.ts +437 -0
  37. package/dist/models/intent.js +172 -0
  38. package/dist/models/key-entry.d.ts +60 -0
  39. package/dist/models/key-entry.js +13 -0
  40. package/dist/models/profile.d.ts +61 -0
  41. package/dist/models/profile.js +24 -0
  42. package/package.json +15 -11
  43. package/specs/ink-auditability.md +2 -2
  44. package/specs/ink-containment-phase1-implementation-spec.md +1 -1
  45. package/src/audit/inclusion-receipt.ts +0 -604
  46. package/src/crypto/ink.ts +0 -1046
  47. package/src/crypto/keys.ts +0 -210
  48. package/src/crypto/multi-key-verify.ts +0 -170
  49. package/src/crypto/sign.ts +0 -155
  50. package/src/discovery/agent-card.ts +0 -508
  51. package/src/index.ts +0 -73
  52. package/src/ink/checkpoint.ts +0 -75
  53. package/src/ink/discovery-gating.ts +0 -147
  54. package/src/ink/handshake-budget.ts +0 -413
  55. package/src/ink/receipts.ts +0 -114
  56. package/src/ink/transport-auth.ts +0 -96
  57. package/src/middleware/ink-auth.ts +0 -263
  58. package/src/models/agent-card.ts +0 -63
  59. package/src/models/ink-audit.ts +0 -205
  60. package/src/models/ink-handshake.ts +0 -123
  61. package/src/models/intent.ts +0 -201
  62. package/src/models/key-entry.ts +0 -52
  63. package/src/models/profile.ts +0 -31
  64. /package/{src/crypto/verify.ts → dist/crypto/verify.d.ts} +0 -0
@@ -0,0 +1,496 @@
1
+ /**
2
+ * INK Auditability Section 7 inclusion-receipt verification.
3
+ *
4
+ * A witness returns a signed inclusion receipt when an agent submits
5
+ * an audit event. The receipt commits the witness to a specific
6
+ * (leafIndex, treeSize, rootHash) for the submitted event.
7
+ *
8
+ * To verify a receipt independently:
9
+ * 1. Check the witness's serviceSignature against its published
10
+ * Ed25519 public key. The signed bytes are
11
+ * `ink/audit-inclusion/v1\n` + JCS({eventId, leafIndex, treeSize,
12
+ * rootHash, timestamp}).
13
+ * 2. (Optional) Re-hash the audit event to derive the leaf hash and
14
+ * walk the inclusion proof up to the witness's claimed rootHash.
15
+ * 3. (Optional) Cross-check the receipt against a later signed
16
+ * checkpoint: the tree only grew (treeSize >= receipt.treeSize)
17
+ * and if equal, the rootHash matches.
18
+ *
19
+ * This module ships the pure verification logic. The bin/verify-inclusion
20
+ * CLI is a thin wrapper that fetches the witness DID document + a
21
+ * current checkpoint and calls verifyInclusionReceipt.
22
+ */
23
+ import * as ed from "@noble/ed25519";
24
+ import { base64urlDecode, jcsCanonicalize, hexToBytes, bytesToHex, computeAuditMerkleLeafHash, verifyAuditQueryResponseSignature, } from "../crypto/ink.js";
25
+ /**
26
+ * Verify an INK inclusion receipt.
27
+ *
28
+ * Always performs:
29
+ * - Structural validation of the receipt object
30
+ * - Service signature verification against `witnessPublicKey`
31
+ *
32
+ * Optionally performs (when the corresponding input is provided):
33
+ * - Leaf-to-root proof walk (`eventHash`)
34
+ * - Cross-check against a later signed checkpoint (`laterCheckpoint`)
35
+ */
36
+ export async function verifyInclusionReceipt(opts) {
37
+ const steps = [];
38
+ const { receipt, witnessPublicKey, eventHash, laterCheckpoint } = opts;
39
+ // ── Step 1: structural validation ──
40
+ const structuralProblem = checkReceiptShape(receipt);
41
+ if (structuralProblem) {
42
+ steps.push({ name: "structure", pass: false, detail: structuralProblem });
43
+ return { valid: false, steps };
44
+ }
45
+ steps.push({ name: "structure", pass: true });
46
+ // ── Step 2: signature ──
47
+ const signedPayload = {
48
+ eventId: receipt.eventId,
49
+ leafIndex: receipt.leafIndex,
50
+ treeSize: receipt.treeSize,
51
+ rootHash: receipt.rootHash,
52
+ timestamp: receipt.timestamp,
53
+ };
54
+ const sigBase = `ink/audit-inclusion/v1\n${jcsCanonicalize(signedPayload)}`;
55
+ let sigValid = false;
56
+ try {
57
+ const sig = base64urlDecode(receipt.serviceSignature);
58
+ sigValid = await ed.verifyAsync(sig, new TextEncoder().encode(sigBase), witnessPublicKey);
59
+ }
60
+ catch (e) {
61
+ steps.push({
62
+ name: "signature",
63
+ pass: false,
64
+ detail: e instanceof Error ? e.message : "signature decode failed",
65
+ });
66
+ return { valid: false, steps };
67
+ }
68
+ if (!sigValid) {
69
+ steps.push({ name: "signature", pass: false, detail: "Ed25519 verification failed" });
70
+ return { valid: false, steps };
71
+ }
72
+ steps.push({ name: "signature", pass: true });
73
+ // ── Step 3: inclusion-proof walk (optional) ──
74
+ if (eventHash !== undefined) {
75
+ if (!/^[0-9a-f]{64}$/.test(eventHash)) {
76
+ steps.push({ name: "proof", pass: false, detail: "eventHash must be 64 lowercase hex chars" });
77
+ return { valid: false, steps };
78
+ }
79
+ const verified = await verifyInclusionProof(eventHash, receipt.inclusionProof, receipt.leafIndex, receipt.treeSize, receipt.rootHash);
80
+ if (!verified) {
81
+ steps.push({ name: "proof", pass: false, detail: "leaf-to-root walk did not reach claimed rootHash" });
82
+ return { valid: false, steps };
83
+ }
84
+ steps.push({ name: "proof", pass: true });
85
+ }
86
+ // ── Step 4: later-checkpoint cross-check (optional) ──
87
+ if (laterCheckpoint !== undefined) {
88
+ const cpShape = checkCheckpointShape(laterCheckpoint);
89
+ if (cpShape) {
90
+ steps.push({ name: "checkpoint", pass: false, detail: cpShape });
91
+ return { valid: false, steps };
92
+ }
93
+ if (laterCheckpoint.treeSize < receipt.treeSize) {
94
+ steps.push({
95
+ name: "checkpoint",
96
+ pass: false,
97
+ detail: `checkpoint treeSize ${laterCheckpoint.treeSize} < receipt treeSize ${receipt.treeSize} (witness rewound the tree)`,
98
+ });
99
+ return { valid: false, steps };
100
+ }
101
+ if (laterCheckpoint.treeSize === receipt.treeSize && laterCheckpoint.rootHash !== receipt.rootHash) {
102
+ steps.push({
103
+ name: "checkpoint",
104
+ pass: false,
105
+ detail: "checkpoint rootHash differs from receipt rootHash at same treeSize (fork)",
106
+ });
107
+ return { valid: false, steps };
108
+ }
109
+ steps.push({ name: "checkpoint", pass: true });
110
+ }
111
+ return { valid: true, steps };
112
+ }
113
+ /**
114
+ * Full §7.3 verification of a witness audit-query response. Use this in
115
+ * preference to `verifyAuditQueryResponseSignature`, which is the
116
+ * underlying primitive and verifies only the Ed25519 signature. This
117
+ * function additionally enforces:
118
+ *
119
+ * - Envelope shape (protocol, type, serviceDid, requester, messageId,
120
+ * timestamp, treeSize, rootHash, events[], proofs[])
121
+ * - Service signature with the right canonical bytes
122
+ * - Optional caller-supplied bindings: expected `messageId`,
123
+ * `requester`, `serviceDid` (each rejected on mismatch)
124
+ * - `events` and `proofs` align one-to-one by `eventId`
125
+ * - Every event includes a non-empty `agentSignature` field
126
+ * - Every proof walks from `computeAuditMerkleLeafHash(event)` up to
127
+ * the response's `rootHash` at `treeSize`
128
+ * - Optional `laterCheckpoint`: tree only grew, no fork at same size
129
+ *
130
+ * **Per-event agent-signature verification (§7.5 trust model).** A
131
+ * Merkle-valid response is necessary but not sufficient: the witness
132
+ * could in principle commit a fabricated "event" not signed by any
133
+ * agent, sign the resulting `(treeSize, rootHash)`, and the Merkle
134
+ * proof walks just fine. To detect this, callers MUST pass a
135
+ * `verifyEventSignature` callback that resolves the agent's published
136
+ * Ed25519 keys (typically via Agent Card §2) and validates
137
+ * `event.agentSignature`. The callback is REQUIRED, not optional: the
138
+ * verifier refuses to return `valid: true` without it, so a caller
139
+ * cannot accidentally accept witness-fabricated events.
140
+ *
141
+ * **Freshness.** A `valid: true` result attests that the response was a
142
+ * complete enumeration of the requester's visible events at the
143
+ * `(treeSize, rootHash)` snapshot the witness signed, NOT that it is
144
+ * the witness's current authoritative view. The signed envelope binds
145
+ * `timestamp`, but verifiers wanting "is this still current?"
146
+ * semantics MUST additionally fetch a fresh witness checkpoint and
147
+ * compare it (e.g. require `laterCheckpoint.treeSize === response.treeSize
148
+ * && laterCheckpoint.rootHash === response.rootHash` for "current", or
149
+ * use `laterCheckpoint` here only to prove the tree never rewound or
150
+ * forked).
151
+ *
152
+ * Returns `{valid, steps}` where each step explains pass/fail with detail.
153
+ * Pure function. Does not perform network I/O.
154
+ */
155
+ export async function verifyAuditQueryResponse(opts) {
156
+ const steps = [];
157
+ const { response, witnessPublicKey, expectedRequester, expectedMessageId, expectedServiceDid, laterCheckpoint, verifyEventSignature } = opts;
158
+ // ── Step 1: structural validation ──
159
+ const structuralProblem = checkAuditQueryResponseShape(response);
160
+ if (structuralProblem) {
161
+ steps.push({ name: "structure", pass: false, detail: structuralProblem });
162
+ return { valid: false, steps };
163
+ }
164
+ steps.push({ name: "structure", pass: true });
165
+ // ── Step 2: caller-supplied binding checks ──
166
+ if (response.messageId !== expectedMessageId) {
167
+ steps.push({ name: "binding", pass: false, detail: `messageId mismatch: response=${response.messageId} expected=${expectedMessageId}` });
168
+ return { valid: false, steps };
169
+ }
170
+ if (response.requester !== expectedRequester) {
171
+ steps.push({ name: "binding", pass: false, detail: "requester mismatch (response signed for a different requester)" });
172
+ return { valid: false, steps };
173
+ }
174
+ if (expectedServiceDid !== undefined && response.serviceDid !== expectedServiceDid) {
175
+ steps.push({ name: "binding", pass: false, detail: `serviceDid mismatch: response=${response.serviceDid} expected=${expectedServiceDid}` });
176
+ return { valid: false, steps };
177
+ }
178
+ steps.push({ name: "binding", pass: true });
179
+ // ── Step 3: signature over canonical bytes ──
180
+ const { serviceSignature, ...payload } = response;
181
+ const sigValid = await verifyAuditQueryResponseSignature(payload, serviceSignature, witnessPublicKey);
182
+ if (!sigValid) {
183
+ steps.push({ name: "signature", pass: false, detail: "Ed25519 verification failed" });
184
+ return { valid: false, steps };
185
+ }
186
+ steps.push({ name: "signature", pass: true });
187
+ // ── Step 4: per-event scope check ──
188
+ //
189
+ // The envelope binds messageId and requester, but until we look INTO
190
+ // each event we don't know the witness isn't returning a Merkle-valid
191
+ // event from a different messageId or one the requester is not a
192
+ // party to. Reject any event whose own fields contradict the envelope.
193
+ for (const event of response.events) {
194
+ const eMessageId = event.messageId;
195
+ if (typeof eMessageId !== "string" || eMessageId !== response.messageId) {
196
+ steps.push({ name: "scope", pass: false, detail: `event ${event.id}: messageId does not match envelope` });
197
+ return { valid: false, steps };
198
+ }
199
+ const eAgentId = event.agentId;
200
+ const eCounterpartyId = event.counterpartyId;
201
+ const requesterIsParty = (typeof eAgentId === "string" && eAgentId === expectedRequester) ||
202
+ (typeof eCounterpartyId === "string" && eCounterpartyId === expectedRequester);
203
+ if (!requesterIsParty) {
204
+ steps.push({ name: "scope", pass: false, detail: `event ${event.id}: requester ${expectedRequester} is not a party (agentId/counterpartyId)` });
205
+ return { valid: false, steps };
206
+ }
207
+ }
208
+ steps.push({ name: "scope", pass: true });
209
+ // ── Step 5: events ↔ proofs strict one-to-one by eventId ──
210
+ //
211
+ // §7.3 mandates a one-to-one mapping. Enforce both directions:
212
+ // - No duplicate event.id (otherwise `events: [A, A]` paired with
213
+ // `proofs: [proof(A), proof(extra)]` could pass length + has-proof
214
+ // checks while including a proof for an unverified event).
215
+ // - No duplicate proof.eventId.
216
+ // - Every proof.eventId corresponds to some event.id (no "extra"
217
+ // proofs for events not in the response).
218
+ if (response.events.length !== response.proofs.length) {
219
+ steps.push({ name: "proofs", pass: false, detail: `events and proofs length differ: ${response.events.length} vs ${response.proofs.length}` });
220
+ return { valid: false, steps };
221
+ }
222
+ const eventIds = new Set();
223
+ for (const event of response.events) {
224
+ if (eventIds.has(event.id)) {
225
+ steps.push({ name: "proofs", pass: false, detail: `duplicate event id ${event.id}` });
226
+ return { valid: false, steps };
227
+ }
228
+ eventIds.add(event.id);
229
+ }
230
+ const proofById = new Map();
231
+ for (const p of response.proofs) {
232
+ if (proofById.has(p.eventId)) {
233
+ steps.push({ name: "proofs", pass: false, detail: `duplicate proof for eventId ${p.eventId}` });
234
+ return { valid: false, steps };
235
+ }
236
+ if (!eventIds.has(p.eventId)) {
237
+ steps.push({ name: "proofs", pass: false, detail: `proof references unknown eventId ${p.eventId}` });
238
+ return { valid: false, steps };
239
+ }
240
+ proofById.set(p.eventId, { leafIndex: p.leafIndex, inclusionProof: p.inclusionProof });
241
+ }
242
+ for (const event of response.events) {
243
+ if (!proofById.has(event.id)) {
244
+ steps.push({ name: "proofs", pass: false, detail: `event ${event.id} has no matching proof` });
245
+ return { valid: false, steps };
246
+ }
247
+ }
248
+ steps.push({ name: "proofs", pass: true });
249
+ // ── Step 6: walk each inclusion proof ──
250
+ for (const event of response.events) {
251
+ const p = proofById.get(event.id);
252
+ let leafHash;
253
+ try {
254
+ leafHash = await computeAuditMerkleLeafHash(event);
255
+ }
256
+ catch (e) {
257
+ steps.push({ name: "proof-walk", pass: false, detail: `event ${event.id}: leaf-hash computation failed: ${e instanceof Error ? e.message : String(e)}` });
258
+ return { valid: false, steps };
259
+ }
260
+ const ok = await verifyInclusionProof(leafHash, p.inclusionProof, p.leafIndex, response.treeSize, response.rootHash);
261
+ if (!ok) {
262
+ steps.push({ name: "proof-walk", pass: false, detail: `event ${event.id}: leaf-to-root walk did not reach claimed rootHash` });
263
+ return { valid: false, steps };
264
+ }
265
+ }
266
+ steps.push({ name: "proof-walk", pass: true });
267
+ // ── Step 6: per-event agent signature ──
268
+ //
269
+ // Merkle validity proves the witness committed to these exact event
270
+ // bytes; it does NOT prove an agent ever signed them. The caller
271
+ // MUST supply `verifyEventSignature`; we refuse to return valid
272
+ // otherwise.
273
+ if (typeof verifyEventSignature !== "function") {
274
+ steps.push({
275
+ name: "agent-signature",
276
+ pass: false,
277
+ detail: "verifyEventSignature callback is required (Auditability §7.5); refusing to accept witness Merkle inclusion as proof of agent provenance",
278
+ });
279
+ return { valid: false, steps };
280
+ }
281
+ for (const event of response.events) {
282
+ let ok = false;
283
+ try {
284
+ ok = await verifyEventSignature(event);
285
+ }
286
+ catch (e) {
287
+ steps.push({ name: "agent-signature", pass: false, detail: `event ${event.id}: verifier threw: ${e instanceof Error ? e.message : String(e)}` });
288
+ return { valid: false, steps };
289
+ }
290
+ if (!ok) {
291
+ steps.push({ name: "agent-signature", pass: false, detail: `event ${event.id}: agentSignature did not verify` });
292
+ return { valid: false, steps };
293
+ }
294
+ }
295
+ steps.push({ name: "agent-signature", pass: true });
296
+ // ── Step 7: optional later-checkpoint cross-check ──
297
+ if (laterCheckpoint !== undefined) {
298
+ const cpShape = checkCheckpointShape(laterCheckpoint);
299
+ if (cpShape) {
300
+ steps.push({ name: "checkpoint", pass: false, detail: cpShape });
301
+ return { valid: false, steps };
302
+ }
303
+ if (laterCheckpoint.treeSize < response.treeSize) {
304
+ steps.push({
305
+ name: "checkpoint",
306
+ pass: false,
307
+ detail: `checkpoint treeSize ${laterCheckpoint.treeSize} < response treeSize ${response.treeSize} (witness rewound the tree)`,
308
+ });
309
+ return { valid: false, steps };
310
+ }
311
+ if (laterCheckpoint.treeSize === response.treeSize && laterCheckpoint.rootHash !== response.rootHash) {
312
+ steps.push({
313
+ name: "checkpoint",
314
+ pass: false,
315
+ detail: "checkpoint rootHash differs from response rootHash at same treeSize (fork)",
316
+ });
317
+ return { valid: false, steps };
318
+ }
319
+ steps.push({ name: "checkpoint", pass: true });
320
+ }
321
+ return { valid: true, steps };
322
+ }
323
+ // ── Internal helpers ──
324
+ /** Generous upper bound on inclusion-proof length. Real proofs are
325
+ * ceil(log2(treeSize)) entries; a treeSize > 2^60 is implausible for
326
+ * any real log, so capping at 64 entries bounds memory + walker depth
327
+ * without rejecting legitimate input. The signed payload binds
328
+ * treeSize but not the proof array itself, so an attacker could
329
+ * otherwise append unbounded garbage to a valid receipt. */
330
+ const MAX_PROOF_LENGTH = 64;
331
+ function checkReceiptShape(receipt) {
332
+ if (receipt === null || typeof receipt !== "object")
333
+ return "receipt is not an object";
334
+ if (typeof receipt.eventId !== "string" || receipt.eventId.length === 0)
335
+ return "eventId missing";
336
+ if (!Number.isInteger(receipt.leafIndex) || receipt.leafIndex < 0)
337
+ return "leafIndex must be non-negative integer";
338
+ if (!Number.isInteger(receipt.treeSize) || receipt.treeSize < 1)
339
+ return "treeSize must be positive integer";
340
+ if (receipt.leafIndex >= receipt.treeSize)
341
+ return "leafIndex must be < treeSize";
342
+ if (typeof receipt.rootHash !== "string" || !/^[0-9a-f]{64}$/.test(receipt.rootHash)) {
343
+ return "rootHash must be 64 lowercase hex chars";
344
+ }
345
+ if (!Array.isArray(receipt.inclusionProof))
346
+ return "inclusionProof must be an array";
347
+ if (receipt.inclusionProof.length > MAX_PROOF_LENGTH) {
348
+ return `inclusionProof exceeds max length of ${MAX_PROOF_LENGTH} entries`;
349
+ }
350
+ for (const p of receipt.inclusionProof) {
351
+ if (typeof p !== "string" || !/^[0-9a-f]{64}$/.test(p)) {
352
+ return "every inclusionProof entry must be 64 lowercase hex chars";
353
+ }
354
+ }
355
+ if (typeof receipt.timestamp !== "string" || receipt.timestamp.length === 0)
356
+ return "timestamp missing";
357
+ if (typeof receipt.serviceSignature !== "string" || receipt.serviceSignature.length === 0) {
358
+ return "serviceSignature missing";
359
+ }
360
+ return null;
361
+ }
362
+ // SHA-256("") in hex, used as the empty-log Merkle root per RFC 6962 §2.1.
363
+ // A fresh witness with no submissions reports treeSize=0 and rootHash=EMPTY_TREE_ROOT.
364
+ const EMPTY_TREE_ROOT = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
365
+ function checkAuditQueryResponseShape(r) {
366
+ if (r === null || typeof r !== "object")
367
+ return "response is not an object";
368
+ if (r.protocol !== "ink/0.1")
369
+ return `protocol must be "ink/0.1"`;
370
+ if (r.type !== "network.tulpa.audit_query_response")
371
+ return `type must be "network.tulpa.audit_query_response"`;
372
+ if (typeof r.serviceDid !== "string" || r.serviceDid.length === 0)
373
+ return "serviceDid missing";
374
+ if (typeof r.messageId !== "string" || r.messageId.length === 0)
375
+ return "messageId missing";
376
+ if (typeof r.requester !== "string" || r.requester.length === 0)
377
+ return "requester missing";
378
+ if (typeof r.timestamp !== "string" || r.timestamp.length === 0)
379
+ return "timestamp missing";
380
+ if (typeof r.serviceSignature !== "string" || r.serviceSignature.length === 0)
381
+ return "serviceSignature missing";
382
+ if (!Number.isInteger(r.treeSize) || r.treeSize < 0)
383
+ return "treeSize must be a non-negative integer";
384
+ if (typeof r.rootHash !== "string" || !/^[0-9a-f]{64}$/.test(r.rootHash)) {
385
+ return "rootHash must be 64 lowercase hex chars";
386
+ }
387
+ if (!Array.isArray(r.events))
388
+ return "events must be an array";
389
+ if (!Array.isArray(r.proofs))
390
+ return "proofs must be an array";
391
+ // Empty-log case: a fresh witness can sign treeSize=0 with the
392
+ // canonical empty-tree root and zero events/proofs. Any other shape
393
+ // at treeSize=0 is the witness fabricating a state.
394
+ if (r.treeSize === 0) {
395
+ if (r.events.length !== 0)
396
+ return "treeSize=0 response must have empty events";
397
+ if (r.proofs.length !== 0)
398
+ return "treeSize=0 response must have empty proofs";
399
+ if (r.rootHash !== EMPTY_TREE_ROOT)
400
+ return "treeSize=0 response must have the empty-tree rootHash";
401
+ }
402
+ for (const e of r.events) {
403
+ if (e === null || typeof e !== "object")
404
+ return "every event must be an object";
405
+ if (typeof e.id !== "string")
406
+ return "every event must have a string id";
407
+ const agentSig = e.agentSignature;
408
+ if (typeof agentSig !== "string" || agentSig.length === 0) {
409
+ return "every event must include a non-empty agentSignature";
410
+ }
411
+ }
412
+ for (const p of r.proofs) {
413
+ if (p === null || typeof p !== "object")
414
+ return "every proof must be an object";
415
+ if (typeof p.eventId !== "string" || p.eventId.length === 0)
416
+ return "every proof must have an eventId";
417
+ if (!Number.isInteger(p.leafIndex) || p.leafIndex < 0)
418
+ return "every proof.leafIndex must be a non-negative integer";
419
+ if (p.leafIndex >= r.treeSize)
420
+ return "every proof.leafIndex must be < treeSize";
421
+ if (!Array.isArray(p.inclusionProof))
422
+ return "every proof.inclusionProof must be an array";
423
+ if (p.inclusionProof.length > MAX_PROOF_LENGTH) {
424
+ return `proof.inclusionProof exceeds max length of ${MAX_PROOF_LENGTH} entries`;
425
+ }
426
+ for (const h of p.inclusionProof) {
427
+ if (typeof h !== "string" || !/^[0-9a-f]{64}$/.test(h)) {
428
+ return "every inclusionProof entry must be 64 lowercase hex chars";
429
+ }
430
+ }
431
+ }
432
+ return null;
433
+ }
434
+ function checkCheckpointShape(cp) {
435
+ if (cp === null || typeof cp !== "object")
436
+ return "laterCheckpoint must be an object";
437
+ if (!Number.isInteger(cp.treeSize) || cp.treeSize < 0) {
438
+ return "laterCheckpoint.treeSize must be a non-negative integer";
439
+ }
440
+ if (typeof cp.rootHash !== "string" || !/^[0-9a-f]{64}$/.test(cp.rootHash)) {
441
+ return "laterCheckpoint.rootHash must be 64 lowercase hex chars";
442
+ }
443
+ return null;
444
+ }
445
+ async function hashPair(left, right) {
446
+ const l = hexToBytes(left);
447
+ const r = hexToBytes(right);
448
+ const buf = new Uint8Array(1 + l.length + r.length);
449
+ buf[0] = 0x01;
450
+ buf.set(l, 1);
451
+ buf.set(r, 1 + l.length);
452
+ const out = new Uint8Array(await crypto.subtle.digest("SHA-256", buf));
453
+ return bytesToHex(out);
454
+ }
455
+ function largestPowerOf2LessThan(n) {
456
+ if (n <= 1)
457
+ return 0;
458
+ let p = 1;
459
+ while (p * 2 < n)
460
+ p *= 2;
461
+ return p;
462
+ }
463
+ async function recomputeRoot(currentHash, proof, proofIdx, leafIndex, start, size) {
464
+ if (size === 1) {
465
+ // Reached the leaf. Any proof entries left over mean the proof was
466
+ // padded with extras; reject it as malformed.
467
+ if (proofIdx !== proof.length)
468
+ throw new Error("inclusion proof has unused entries");
469
+ return currentHash;
470
+ }
471
+ if (proofIdx >= proof.length) {
472
+ // Proof exhausted before walking down to the leaf. Without this,
473
+ // an attacker can present a short proof against a tree > 1 leaf
474
+ // and the walker returns currentHash (the leaf), which a verifier
475
+ // might mistakenly equate to rootHash.
476
+ throw new Error("inclusion proof too short for declared treeSize");
477
+ }
478
+ const split = largestPowerOf2LessThan(size);
479
+ if (leafIndex - start < split) {
480
+ const leftResult = await recomputeRoot(currentHash, proof, proofIdx + 1, leafIndex, start, split);
481
+ return hashPair(leftResult, proof[proofIdx]);
482
+ }
483
+ const rightResult = await recomputeRoot(currentHash, proof, proofIdx + 1, leafIndex, start + split, size - split);
484
+ return hashPair(proof[proofIdx], rightResult);
485
+ }
486
+ async function verifyInclusionProof(leafHash, proof, leafIndex, treeSize, expectedRootHash) {
487
+ if (leafIndex < 0 || leafIndex >= treeSize)
488
+ return false;
489
+ try {
490
+ const computed = await recomputeRoot(leafHash, proof, 0, leafIndex, 0, treeSize);
491
+ return computed === expectedRootHash;
492
+ }
493
+ catch {
494
+ return false;
495
+ }
496
+ }
@@ -0,0 +1,178 @@
1
+ declare function base64urlEncode(bytes: Uint8Array): string;
2
+ declare function base64urlDecode(str: string): Uint8Array;
3
+ declare function hexToBytes(hex: string): Uint8Array;
4
+ declare function bytesToHex(bytes: Uint8Array): string;
5
+ declare function jcsCanonicalize(obj: unknown): string;
6
+ export interface InkSignInput {
7
+ method: string;
8
+ path: string;
9
+ recipientDid: string;
10
+ body: Record<string, unknown>;
11
+ timestamp: string;
12
+ }
13
+ export declare function buildSignatureBase(input: InkSignInput): string;
14
+ /**
15
+ * Sign an INK message. Returns the base64url-encoded Ed25519 signature.
16
+ */
17
+ export declare function signInkMessage(input: InkSignInput, privateKey: Uint8Array): Promise<string>;
18
+ /**
19
+ * Verify an INK message signature.
20
+ * Returns false (never throws) for malformed or wrong-length signatures.
21
+ */
22
+ export declare function verifyInkSignature(input: InkSignInput, signatureBase64url: string, publicKey: Uint8Array): Promise<boolean>;
23
+ /**
24
+ * Build the Authorization header value for an INK request.
25
+ * Optionally includes keyId for key-rotation-aware verification.
26
+ *
27
+ * Both values are validated against the same grammar the receiver uses so that
28
+ * invalid characters (including CR/LF that could cause header injection) are
29
+ * rejected before they reach the HTTP layer.
30
+ */
31
+ export declare function buildAuthHeader(signatureBase64url: string, keyId?: string): string;
32
+ export interface InkEncryptedEnvelope {
33
+ protocol: "ink/0.1";
34
+ type: "network.tulpa.encrypted";
35
+ from: string;
36
+ ephemeralKey: string;
37
+ nonce: string;
38
+ ciphertext: string;
39
+ timestamp: string;
40
+ messageNonce: string;
41
+ }
42
+ export interface InkEncryptResult {
43
+ envelope: InkEncryptedEnvelope;
44
+ ephemeralPublicKey: Uint8Array;
45
+ }
46
+ /**
47
+ * Encrypt an INK message payload using ECIES:
48
+ * 1. Generate ephemeral X25519 keypair (or accept one for deterministic tests)
49
+ * 2. ECDH with recipient's X25519 public key
50
+ * 3. HKDF-SHA256(sharedSecret, salt="ink/0.1", info="ink/0.1/encrypt") → 32-byte AES key
51
+ * 4. AES-256-GCM encrypt the JSON-serialized plaintext
52
+ * 5. Pack into outer envelope
53
+ */
54
+ export declare function encryptInkPayload(plaintext: Record<string, unknown>, senderDid: string, recipientEncryptionKeyHex: string, timestamp: string, messageNonce: string, options?: {
55
+ ephemeralPrivateKey?: Uint8Array;
56
+ aesNonce?: Uint8Array;
57
+ }): Promise<InkEncryptResult>;
58
+ /**
59
+ * Decrypt an INK encrypted envelope using the recipient's X25519 private key.
60
+ * Returns the decrypted inner envelope and verifies inner/outer consistency.
61
+ */
62
+ export declare function decryptInkPayload(envelope: InkEncryptedEnvelope, recipientEncryptionPrivateKeyHex: string, recipientDid?: string): Promise<Record<string, unknown>>;
63
+ export interface ReplayCheckInput {
64
+ messageTimestamp: string;
65
+ receiverClock: string;
66
+ nonce: string;
67
+ previouslySeenNonces: string[];
68
+ }
69
+ export interface ReplayCheckResult {
70
+ accepted: boolean;
71
+ errorCode?: "expired_message" | "duplicate_nonce";
72
+ }
73
+ export declare const MAX_TIMESTAMP_AGE_MS: number;
74
+ export declare const MAX_FUTURE_TIMESTAMP_MS: number;
75
+ /**
76
+ * Check whether an INK message should be accepted or rejected
77
+ * based on timestamp freshness and nonce deduplication (§3.5).
78
+ */
79
+ export declare function checkReplay(input: ReplayCheckInput): ReplayCheckResult;
80
+ /**
81
+ * Compute SHA-256 hash of JCS-canonicalized body. Returns hex string.
82
+ * Used for messageHash in receipts and previousEventHash in audit chains.
83
+ */
84
+ export declare function computeMessageHash(body: Record<string, unknown>): Promise<string>;
85
+ /**
86
+ * Sign an INK audit event. Returns base64url-encoded Ed25519 signature.
87
+ * Signs the JCS-canonicalized event with the agentSignature field excluded.
88
+ */
89
+ export declare function signAuditEvent(event: Record<string, unknown>, privateKey: Uint8Array): Promise<string>;
90
+ /**
91
+ * Verify an INK audit event signature.
92
+ * Returns false (never throws) for malformed or wrong-length signatures.
93
+ */
94
+ export declare function verifyAuditEventSignature(event: Record<string, unknown>, publicKey: Uint8Array): Promise<boolean>;
95
+ /**
96
+ * Compute the RFC 6962 Merkle leaf hash for an INK audit event:
97
+ *
98
+ * SHA-256(0x00 || JCS(event-without-agentSignature))
99
+ *
100
+ * This is the leaf-hashing rule a witness MUST use when building its
101
+ * transparency log (Auditability §7.3). It is distinct from
102
+ * `computeEventHash`, which omits the 0x00 prefix and is used only for
103
+ * `previousEventHash` chain linkage inside the agent's local audit log.
104
+ *
105
+ * Returns the lowercase-hex digest.
106
+ */
107
+ export declare function computeAuditMerkleLeafHash(event: Record<string, unknown>): Promise<string>;
108
+ /**
109
+ * Compute SHA-256 hash of JCS-canonicalized audit event (excluding agentSignature).
110
+ * Used for previousEventHash chain linkage. NOT the Merkle leaf hash:
111
+ * see `computeAuditMerkleLeafHash` for the RFC 6962 leaf-hash rule used
112
+ * by witness transparency logs.
113
+ */
114
+ export declare function computeEventHash(event: Record<string, unknown>): Promise<string>;
115
+ /**
116
+ * Sign an INK audit response. Returns base64url-encoded Ed25519 signature.
117
+ * Domain-separated: signs "ink/audit-response\n" + JCS(events) to prevent
118
+ * cross-protocol signature replay.
119
+ */
120
+ export declare function signAuditResponse(events: unknown[], privateKey: Uint8Array): Promise<string>;
121
+ /**
122
+ * Verify an INK audit response signature.
123
+ * Expects the domain-separated format: "ink/audit-response\n" + JCS(events).
124
+ * Returns false (never throws) for malformed or wrong-length signatures.
125
+ */
126
+ export declare function verifyAuditResponseSignature(events: unknown[], signature: string, publicKey: Uint8Array): Promise<boolean>;
127
+ /**
128
+ * Validate the internal continuity of an audit event chain. Distinct
129
+ * from verifyAuditResponseSignature, which only verifies the response
130
+ * wrapper signature. Callers fetching audit responses MUST call both:
131
+ * the signature gate proves the witness/agent attested to this slice,
132
+ * this gate proves the slice itself is contiguous and fork-free.
133
+ *
134
+ * Rules enforced:
135
+ * - input must be an array of non-null plain objects
136
+ * - each event must have integer sequence and string-or-null previousEventHash
137
+ * - sequences within the response must be strictly increasing by 1
138
+ * (a partial-window response anchored elsewhere is fine, but no internal gaps)
139
+ * - duplicate sequence numbers within the response are a fork
140
+ * - events[i].previousEventHash MUST equal computeEventHash(events[i-1]) for i >= 1
141
+ * - events[0].previousEventHash is NOT verified against any external
142
+ * anchor; callers that have one (a prior pinned event hash) must
143
+ * verify the boundary themselves
144
+ */
145
+ export declare function verifyAuditEventChain(events: unknown): Promise<{
146
+ valid: true;
147
+ } | {
148
+ valid: false;
149
+ error: "invalid_input" | "invalid_event" | "sequence_gap" | "sequence_fork" | "previous_hash_mismatch";
150
+ }>;
151
+ /**
152
+ * Sign an INK audit-query response from a witness. The signed bytes are:
153
+ *
154
+ * "ink/audit-query-response/v1\n" + JCS(response object minus serviceSignature)
155
+ *
156
+ * Callers pass the response object EXCLUDING `serviceSignature`. The
157
+ * canonical bytes bind every other field, including `protocol`, `type`,
158
+ * `messageId`, `events`, `proofs`, `treeSize`, `rootHash`, `serviceDid`,
159
+ * and `timestamp`, so verifiers cannot rebind a valid signature to a
160
+ * different witness/message/root.
161
+ */
162
+ export declare function signAuditQueryResponse(responseWithoutSignature: Record<string, unknown>, privateKey: Uint8Array): Promise<string>;
163
+ /**
164
+ * Verify the Ed25519 signature on an audit-query response. This is the
165
+ * LOW-LEVEL primitive. Most consumers should call
166
+ * `verifyAuditQueryResponse` (from `src/audit/inclusion-receipt.ts`)
167
+ * instead: it enforces envelope shape, requester binding, the
168
+ * events-to-proofs one-to-one mapping, and walks every Merkle proof.
169
+ *
170
+ * Calling this function alone does NOT prove the response is acceptable.
171
+ * A signed but malformed envelope (wrong type, wrong protocol, no
172
+ * proofs, wrong requester) can still pass here. Caller is responsible
173
+ * for pinning / resolving the witness public key out of band (e.g.
174
+ * via /.well-known/did.json). Returns false (never throws) for any
175
+ * malformed input.
176
+ */
177
+ export declare function verifyAuditQueryResponseSignature(responseWithoutSignature: Record<string, unknown>, signature: string, publicKey: Uint8Array): Promise<boolean>;
178
+ export { base64urlEncode, base64urlDecode, hexToBytes, bytesToHex, jcsCanonicalize };