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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Changelog
2
2
 
3
- All notable changes to INK and the reference implementation are recorded
3
+ All notable changes to INK are recorded
4
4
  here. Pre-1.0 releases follow `0.Y.Z` semantics, see
5
5
  [`docs/maturity.md`](docs/maturity.md) for the versioning policy.
6
6
 
@@ -8,6 +8,34 @@ here. Pre-1.0 releases follow `0.Y.Z` semantics, see
8
8
 
9
9
  No unreleased changes.
10
10
 
11
+ ## 0.1.0-alpha.3, signed audit-query response
12
+
13
+ Closes the last HIGH conformance-audit finding (witness audit-query
14
+ response missing signature, proofs and protocol envelope).
15
+
16
+ ### Added
17
+
18
+ - `signAuditQueryResponse(payload, privateKey)` and `verifyAuditQueryResponseSignature(payload, signature, publicKey)` primitives. Canonical signed bytes are `ink/audit-query-response/v1\n` + JCS(payload without serviceSignature). The payload binds `serviceDid`, `messageId`, `requester`, `events`, `proofs`, `treeSize`, `rootHash`, `timestamp`, so a valid signature cannot be rebound to a different witness, message, requester, or root.
19
+ - `verifyAuditQueryResponse({response, witnessPublicKey, expectedRequester, expectedMessageId, verifyEventSignature, expectedServiceDid?, laterCheckpoint?})` is the recommended high-level verifier. `verifyEventSignature` is a REQUIRED callback that resolves the submitting agent's keys and validates each event's `agentSignature`. Without it, the verifier refuses to return valid, because Merkle inclusion alone does not prove agent provenance (§7.5). The function enforces envelope shape, requester binding, events/proofs strict one-to-one alignment, the §7.4 per-event scope rule, walks every Merkle proof via `computeAuditMerkleLeafHash` up to the response's `rootHash`, runs `verifyEventSignature` on every event and supports optional later-checkpoint cross-check. `verifyAuditQueryResponseSignature` alone is signature-only and is documented as a low-level primitive.
20
+ - `computeAuditMerkleLeafHash(event)` primitive: the RFC 6962 leaf-hash rule for inclusion proofs, `SHA-256(0x00 || JCS(event-without-agentSignature))`. Distinct from `computeEventHash` (unprefixed, used only for `previousEventHash` chain linkage). Verifiers walking an inclusion proof MUST use this function, not `computeEventHash`.
21
+ - Nix flake now exposes `apps.default`, so `nix run github:Ad-Astra-Computing/ink -- verify-inclusion --file r.json --witness URL` works without `npm install`.
22
+
23
+ ### Security
24
+
25
+ - The §7.3 envelope now binds `requester`. Without this binding, a signed witness response generated for Alice could be replayed to Bob as Bob's authoritative view of the same `messageId`. Verifiers MUST check the response's `requester` equals their locally authenticated requester before accepting events as a complete view.
26
+ - Witnesses MUST fail closed when the requester's visible event set for a `messageId` exceeds the response cap, returning an unsigned HTTP 413 rather than silently signing a partial response. The reference and OSS witnesses query `LIMIT MAX_QUERY_EVENTS + 1`, detect overflow and refuse to sign.
27
+ - Witnesses MUST emit a deterministic, stable result-set order so signed bytes are reproducible. The reference and OSS witnesses use `ORDER BY event_id ASC`.
28
+ - Storage-integrity failures during proof construction (missing event_hash, hash mismatch, missing Merkle node, unprovable leaf, malformed event_json) now return HTTP 500 instead of silently omitting events from a signed response.
29
+ - All canonicalize-and-sign / canonicalize-and-verify paths now cap by UTF-8 byte length, not JS string length. With non-ASCII event data the prior cap could be undercounted and let oversized payloads through. Affects `buildSignatureBase`, `computeMessageHash`, `signAuditEvent` / `verifyAuditEventSignature`, `computeEventHash`, `signAuditResponse` / `verifyAuditResponseSignature`, `signAuditQueryResponse` / `verifyAuditQueryResponseSignature` and the witness `handleQuery` response-size guard.
30
+ - `verifyAuditEventSignature`, `verifyAuditResponseSignature`, `verifyAuditQueryResponseSignature` now wrap canonicalization inside the try/catch, so payloads that pass the complexity precheck but throw inside `jcsCanonicalize` (e.g. objects with `undefined` values) return `false` instead of propagating.
31
+
32
+ ### Spec
33
+
34
+ - `specs/ink-auditability.md` §7.3 (audit-query response) now defines the full signed-envelope shape: `{protocol, type: "network.tulpa.audit_query_response", serviceDid, messageId, requester, events, proofs[{eventId, leafIndex, inclusionProof}], treeSize, rootHash, timestamp, serviceSignature}`. Previous text described a bare `{events}` shape with no signature, no protocol envelope and no per-event proofs.
35
+ - §7.3 leaf-hash text now references `computeAuditMerkleLeafHash` directly and warns implementers that `computeEventHash` (chain linkage) is NOT the leaf input.
36
+ - §7.3 now explicitly forbids witnesses from signing partial results: truncation MUST be an unsigned error. A signed response is a complete enumeration of the requester's visible events at `(treeSize, rootHash)`.
37
+ - §7.3 requires witnesses to emit `events` and `proofs` in a stable, deterministic order.
38
+
11
39
  ## 0.1.0-alpha.2, inclusion-receipt verifier
12
40
 
13
41
  Adds a public verification path for INK Auditability Section 7
@@ -21,7 +49,7 @@ trusting any specific operator's UI.
21
49
 
22
50
  ## 0.1.0-alpha.1, spec clarification
23
51
 
24
- Spec-only release. Reference-implementation code in `src/` is
52
+ Spec-only release. Library code in `src/` is
25
53
  unchanged from `0.1.0-alpha.0`; the bundled spec text is updated.
26
54
 
27
55
  ### Spec changes
@@ -37,8 +65,7 @@ unchanged from `0.1.0-alpha.0`; the bundled spec text is updated.
37
65
 
38
66
  ## 0.1.0-alpha.0, first public alpha
39
67
 
40
- Initial open-source release of the INK protocol reference implementation
41
- and accompanying specification.
68
+ Initial open-source release of the INK protocol library and specification.
42
69
 
43
70
  ### Protocol surface
44
71
 
@@ -53,7 +80,7 @@ and accompanying specification.
53
80
  - Optional containment extension: capability-gated visibility, handshake
54
81
  budgets, sender silent-drop after first rate-limit violation.
55
82
 
56
- ### Reference implementation
83
+ ### Library
57
84
 
58
85
  - Public API exported from the package root, see README for the export
59
86
  surface.
@@ -11,7 +11,7 @@ Participate in this project as a professional. That means:
11
11
  - Assume good faith from contributors and maintainers. If you believe
12
12
  someone has acted in bad faith, raise it privately first.
13
13
  - Keep public discussions focused on the protocol, the spec, the
14
- reference implementation, or related interoperability work.
14
+ library, or related interoperability work.
15
15
 
16
16
  ## Scope
17
17
 
package/README.md CHANGED
@@ -65,7 +65,9 @@ const ok = await verifyInkSignature(input, signature, keypair.publicKey);
65
65
 
66
66
  For inbound request verification, `verifyInkAuth` parses the `Authorization: INK-Ed25519 <sig>` header, checks freshness, and applies the key-rotation authority rule. It requires a `nonceStore` option so the 5-minute freshness window does not silently accept replays; pass a `NonceStore` to have the middleware enforce single-use, or `"deferred"` to acknowledge that the caller will run `checkReplay` (or equivalent) elsewhere in the request pipeline.
67
67
 
68
- For consumers of audit-exchange responses, call both `verifyAuditResponseSignature` (signed response wrapper) and `verifyAuditEventChain` (sequence-by-one and `previousEventHash` continuity, fork detection). The signature gate alone does not prevent a witness from returning a gapped or forked slice.
68
+ For consumers of bilateral audit-exchange responses (`network.tulpa.audit_response`), call both `verifyAuditResponseSignature` (signed response wrapper) and `verifyAuditEventChain` (sequence-by-one and `previousEventHash` continuity, fork detection). The signature gate alone does not prevent a peer from returning a gapped or forked slice.
69
+
70
+ For consumers of witness audit-query responses (`network.tulpa.audit_query_response`, Auditability §7.3, added in `0.1.0-alpha.3`), call `verifyAuditQueryResponse({response, witnessPublicKey, expectedRequester, expectedMessageId, verifyEventSignature, expectedServiceDid?, laterCheckpoint?})`. The `verifyEventSignature` callback is REQUIRED: it resolves the submitting agent's Ed25519 keys (typically via Agent Card §2) and validates each event's `agentSignature`. Without it, the verifier refuses to return valid, because Merkle inclusion alone does not prove a real agent produced the event (§7.5). The verifier enforces envelope shape, the `requester` binding (prevents cross-requester replay), events/proofs strict one-to-one alignment, the §7.4 per-event scope rule, walks every Merkle proof via `computeAuditMerkleLeafHash` up to the response's `rootHash`, runs `verifyEventSignature` on every event and supports an optional later-checkpoint cross-check. The lower-level `verifyAuditQueryResponseSignature` is signature-only and is not sufficient to accept a witness response on its own.
69
71
 
70
72
  ## Tests
71
73
 
@@ -76,12 +78,12 @@ npm run lint # eslint
76
78
  npm run check:surface # public-surface drift check
77
79
  ```
78
80
 
79
- For Nix users: `nix develop` gives a pinned Node 22 + git + gitleaks shell. `nix build` produces the publishable npm tarball under `result/`.
81
+ For Nix users: `nix develop` gives a pinned Node 22 + git + gitleaks shell. `nix build` produces the publishable npm tarball under `result/`. `nix run github:Ad-Astra-Computing/ink -- verify-inclusion --file receipt.json --witness https://witness.example.com` runs the CLI without installing anything globally.
80
82
 
81
83
  ## Layout
82
84
 
83
85
  ```
84
- src/ reference implementation
86
+ src/ library implementation
85
87
  crypto/ signing, multi-key verification, key encoding
86
88
  models/ Zod schemas for Agent Card, handshake, key entries
87
89
  middleware/ transport-level INK auth (verifyInkAuth)
@@ -93,7 +95,7 @@ test-vectors/ JSON interop vectors
93
95
  test/ vitest unit + integration tests
94
96
  ```
95
97
 
96
- The reference implementation runs on any runtime providing standard Web Crypto and `fetch`: Node 22+, Deno, Bun, Cloudflare Workers, browsers. The timestamp freshness window is enforced inside `verifyInkAuth`; nonce single-use is enforced when a `NonceStore` is passed (otherwise `checkReplay` must be called separately). Nonce backing storage and its TTL policy are the integrator's choice.
98
+ The library runs on any runtime providing standard Web Crypto and `fetch`: Node 22+, Deno, Bun, Cloudflare Workers, browsers. The timestamp freshness window is enforced inside `verifyInkAuth`; nonce single-use is enforced when a `NonceStore` is passed (otherwise `checkReplay` must be called separately). Nonce backing storage and its TTL policy are the integrator's choice.
97
99
 
98
100
  ## What's stable in v0.1
99
101
 
@@ -117,7 +119,7 @@ You will see `network.tulpa.*` on the wire (e.g. `network.tulpa.intent`) and `in
117
119
 
118
120
  ## Relationship to Tulpa
119
121
 
120
- INK is developed by [Ad Astra Computing](https://adastracomputing.com) as the underlying protocol for [Tulpa](https://tulpa.network). The spec and the reference implementation in this repo are deliberately free of Tulpa product code so other agent platforms can adopt INK without inheriting Tulpa's surface area. Tulpa's product integration (message orchestration, marketplace, user-facing APIs) lives in a separate, closed-source codebase.
122
+ INK is developed by [Ad Astra Computing](https://adastracomputing.com) as the underlying protocol for [Tulpa](https://tulpa.network). The spec and the library in this repo are deliberately free of Tulpa product code so other agent platforms can adopt INK without inheriting Tulpa's surface area. Tulpa's product integration (message orchestration, marketplace, user-facing APIs) lives in a separate, closed-source codebase.
121
123
 
122
124
  ## Security
123
125
 
package/SECURITY.md CHANGED
@@ -39,7 +39,7 @@ In scope:
39
39
 
40
40
  Out of scope:
41
41
 
42
- - DoS via high-entropy inputs against the reference implementation
42
+ - DoS via high-entropy inputs against the library
43
43
  - Attacks that require a compromised identity system (e.g., a malicious PDS returning a fabricated DID document)
44
44
  - Timing side-channels in the reference `@noble/ed25519` verification
45
45
  - Attacks on Tulpa's product infrastructure (separate codebase, separate disclosure process)
@@ -76,7 +76,10 @@ Usage:
76
76
  Options:
77
77
  -w, --witness <url> Witness base URL (e.g. https://witness.tulpa.network)
78
78
  -f, --file <path> Receipt JSON file. Omit to read from stdin.
79
- -e, --event-hash <hex> Optional. Re-walk the inclusion proof using this leaf.
79
+ -e, --event-hash <hex> Optional. RFC 6962 leaf hash for the audit event:
80
+ SHA-256(0x00 || JCS(event-without-agentSignature)),
81
+ hex-encoded. When set, the inclusion proof is
82
+ re-walked from this leaf up to the claimed root.
80
83
  -h, --help Show this help.
81
84
 
82
85
  Exit codes:
package/docs/maturity.md CHANGED
@@ -17,7 +17,7 @@
17
17
  implementations should report discrepancies as issues.
18
18
  - The protocol is in use by one production integrator (Tulpa). That is
19
19
  one data point, not a guarantee of robustness at scale.
20
- - The reference implementation in `src/` runs on any runtime providing
20
+ - The library in `src/` runs on any runtime providing
21
21
  standard Web Crypto (`crypto.subtle`) and `fetch`, modern Node, Deno,
22
22
  Bun, and edge runtimes. Browser use is feasible but not exercised by
23
23
  the maintainers.
@@ -55,7 +55,7 @@ Pre-1.0 releases follow `0.Y.Z` semantics:
55
55
 
56
56
  - `0.Y.0`, Minor version bump indicates a wire-format change. Receivers
57
57
  must support at least one prior minor during a transition window.
58
- - `0.Y.Z` (Z > 0), Patch bumps fix bugs in the reference implementation
58
+ - `0.Y.Z` (Z > 0), Patch bumps fix bugs in the library
59
59
  and update test vectors where needed. They do not change wire format.
60
60
 
61
61
  Breaking changes before v1.0 will be announced in the repository
@@ -71,7 +71,7 @@ be a real incident:
71
71
  falls inside the in-scope protections and you accept the out-of-scope
72
72
  limits.
73
73
  2. Run `../test-vectors/*` against your implementation.
74
- 3. Fuzz your envelope parser. The reference implementation's tests are
74
+ 3. Fuzz your envelope parser. The library's tests are
75
75
  not a substitute.
76
76
  4. Pen-test the rotation and revocation flows specifically. The
77
77
  authority rule is the single most security-sensitive piece and the
@@ -80,7 +80,7 @@ bounds the damage window but does not eliminate it. Key custody is out of
80
80
  scope.
81
81
 
82
82
  ### Malicious marketplace extensions (if you integrate one)
83
- The reference implementation does not include an extension/marketplace
83
+ The library does not include an extension/marketplace
84
84
  layer. A product that integrates INK and adds a delegation-token layer
85
85
  (for third-party agents to act on behalf of users) must design its own
86
86
  trust model for the marketplace, manifest review, and capability
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@adastracomputing/ink",
3
- "version": "0.1.0-alpha.2",
4
- "description": "Reference implementation and specification of the INK (Inter-agent Networking Kernel) protocol",
3
+ "version": "0.1.0-alpha.3",
4
+ "description": "Library and specification for the INK (Inter-agent Networking Kernel) protocol",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "author": "Ad Astra Computing Inc.",
7
7
  "repository": {
@@ -71,6 +71,6 @@
71
71
  "ed25519",
72
72
  "atproto",
73
73
  "agent-to-agent",
74
- "reference-implementation"
74
+ "transparency-log"
75
75
  ]
76
76
  }
@@ -461,9 +461,9 @@ The audit service:
461
461
  1. Accepts signed `InkAuditEvent` submissions from agents
462
462
  2. Appends them to a **Merkle tree** (not just a hash chain, enables efficient inclusion proofs)
463
463
  3. Returns a **signed inclusion receipt** proving the event was recorded at a specific tree position and timestamp
464
- 4. Serves **inclusion proofs** and **consistency proofs** on demand
464
+ 4. Serves **inclusion proofs** on demand (per-submission via the inclusion receipt and per-query via the signed `audit_query_response` envelope). **Consistency proofs** between two arbitrary checkpoints are not in scope for alpha.3; consistency-proof verification against external `tlog-witness` cosigners (§7.0) is the alpha.3 mitigation against split-view attacks.
465
465
 
466
- The service CANNOT forge events (they carry the submitting agent's Ed25519 signature). It CAN prove:
466
+ The service CANNOT forge events that verifiers will accept, because every returned event carries the submitting agent's Ed25519 `agentSignature` and §7.3 verifiers re-check it against the agent's published keys. A witness that commits a fabricated event_json into its Merkle tree can produce a valid inclusion proof, but verifiers will reject the response when the agent signature fails to validate. (Verifiers that walk Merkle proofs without checking `agentSignature` lose this guarantee; see §7.5.) The service CAN prove:
467
467
  - That a specific event was submitted at a specific time (inclusion)
468
468
  - That the log is append-only and no events have been removed (consistency)
469
469
  - That two parties submitted conflicting events for the same message (conflict detection)
@@ -531,41 +531,66 @@ Authorization: INK-Ed25519 <signature>
531
531
  }
532
532
  ```
533
533
 
534
- **Response includes Merkle inclusion proofs:**
534
+ **Response includes Merkle inclusion proofs and is signed by the witness:**
535
535
 
536
536
  ```json
537
537
  {
538
538
  "protocol": "ink/0.1",
539
- "type": "network.tulpa.audit_response",
539
+ "type": "network.tulpa.audit_query_response",
540
+ "serviceDid": "did:web:witness.example.com",
540
541
  "messageId": "msg-123",
541
- "events": [ /* InkAuditEvent[] from all agents */ ],
542
+ "requester": "did:plc:requester",
543
+ "events": [ /* InkAuditEvent[] visible to the requester */ ],
542
544
  "proofs": [
543
545
  {
544
546
  "eventId": "01JBTEST0001",
545
547
  "leafIndex": 48290,
546
- "inclusionPath": ["<hash>", "<hash>", "..."],
547
- "treeSize": 48291,
548
- "rootHash": "<SHA-256 hex>"
548
+ "inclusionProof": ["<hash>", "<hash>", "..."]
549
549
  }
550
550
  ],
551
- "serviceSignature": "<Ed25519 over JCS(response)>"
551
+ "treeSize": 48291,
552
+ "rootHash": "<SHA-256 hex of Merkle tree root at response time>",
553
+ "timestamp": "2026-03-19T13:00:01Z",
554
+ "serviceSignature": "<Ed25519, see canonical format below>"
552
555
  }
553
556
  ```
554
557
 
558
+ **Canonical signature format.** `serviceSignature` is an Ed25519 signature over the bytes:
559
+
560
+ ```
561
+ "ink/audit-query-response/v1\n" || JCS(response-fields-without-serviceSignature)
562
+ ```
563
+
564
+ The signed payload binds the witness's `serviceDid`, the `messageId` requested, the authenticated `requester` whose access-control scope produced the result, every returned event, every inclusion proof, the witness's `treeSize` and `rootHash` at response time and the `timestamp`. The `proofs` array has one entry per event, keyed by `eventId`; verifiers MUST reject if proofs do not match events one-to-one. `treeSize` and `rootHash` apply uniformly to every proof. The `requester` binding prevents cross-requester replay: a witness response generated for Alice cannot be presented to Bob as Bob's authoritative view of the same `messageId`. Verifiers MUST check `requester` equals the locally authenticated requester before treating the response as their own scoped view.
565
+
566
+ Per-event scope: a signed envelope binds `messageId` and `requester` but says nothing about the event objects until verifiers look inside them. To prevent a witness or a tampering intermediary from smuggling out-of-scope events into a signed response, verifiers MUST reject any response where, for any returned event, `event.messageId` differs from the envelope `messageId`, OR the envelope `requester` is neither `event.agentId` nor `event.counterpartyId`. Witnesses SHOULD reject the same conditions at signing time as defense in depth against storage corruption.
567
+
568
+ Empty-log responses: a witness that has not yet committed any leaves reports `treeSize: 0` and `rootHash` equal to SHA-256 of the empty string (`e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`). A signed response with `treeSize: 0` is legitimate but MUST also have empty `events`, empty `proofs` and the empty-tree `rootHash`. Verifiers MUST reject any `treeSize: 0` response that deviates from this shape.
569
+
570
+ Per-event agent signatures: Merkle validity alone does NOT prove a returned event was produced by the agent named in `event.agentId`. A witness could in principle commit a fabricated event_json that is not a real `InkAuditEvent`. Every returned event MUST therefore include its `agentSignature` field. Verifiers MUST resolve the submitting agent's published Ed25519 keys (via Agent Card §2) and verify `agentSignature` on every event in addition to walking the Merkle proof. A response that omits `agentSignature` on any event MUST be rejected as structurally invalid.
571
+
572
+ Truncation: witnesses MUST NOT silently sign a partial result. If the requester's visible event set for a `messageId` exceeds the witness's response cap, the witness MUST return an unsigned error response (HTTP 413). A signed response is, by definition, a complete enumeration of the requester's visible events for that `messageId` at `(treeSize, rootHash)`.
573
+
574
+ Determinism: witnesses MUST emit `events` and matching `proofs` in a stable, deterministic order so verifiers can reproduce the signed bytes from the underlying records.
575
+
576
+ Leaf hash: each event's Merkle leaf hash is `SHA-256(0x00 || JCS(event-without-agentSignature))`. The leading `0x00` byte is the RFC 6962 leaf-domain-separation tag; internal Merkle nodes use `0x01 || left || right`. Verifiers MUST rehash the returned `event` object themselves (stripping `agentSignature`, then JCS, then SHA-256 with the `0x00` prefix) and use that hash as the leaf input to `inclusionProof`. They MUST NOT trust any leaf-hash value supplied by the witness alongside the event. Walking the proof from this computed leaf hash up through `inclusionProof` MUST reach the top-level `rootHash` per the proof construction in §7.2. The INK library exposes this exact computation as `computeAuditMerkleLeafHash`; it is distinct from `computeEventHash`, which is the unprefixed SHA-256 used for `previousEventHash` chain linkage and MUST NOT be used as the Merkle leaf input.
577
+
555
578
  #### 7.4 Access Control
556
579
 
557
580
  The audit service operates under **access-controlled transparency** (per SCITT):
558
581
  - Events are tagged with the `messageId` and the DIDs of sender/recipient
559
- - Only the sender, recipient or a party with a valid delegation chain (§ INK Authorization Chain) can query events for a given `messageId`
582
+ - Only the sender or recipient (i.e. an event's own `agentId` or `counterpartyId`) can query events for a given `messageId`. The witness MUST refuse to serve a row to any other requester
560
583
  - The service verifies the requester's identity via INK auth (§3.3) before serving events
561
- - The Merkle tree structure is public (anyone can verify consistency proofs) but event contents are access-controlled
584
+ - The Merkle tree structure is public: anyone can verify inclusion proofs against signed checkpoints and, where consistency-proof endpoints are deployed, cross-check that checkpoints are append-only. Event contents remain access-controlled
585
+
586
+ Delegated queries (where a third-party agent queries events on behalf of a principal via an INK Authorization Chain) are not in scope for alpha.3. A future revision will define the additional envelope fields the witness signs to bind the effective principal alongside the immediate requester, and verifiers will be updated accordingly. Until then, conformant witnesses MUST treat the per-event scope rule in §7.3 as authoritative: a returned event's `agentId` or `counterpartyId` MUST equal the response `requester`.
562
587
 
563
588
  This follows SCITT's model: the transparency guarantee (append-only, no suppression) is public, but the data itself is private.
564
589
 
565
590
  #### 7.5 Trust Model
566
591
 
567
592
  The audit service is a **semi-trusted witness**, not an arbiter:
568
- - It CANNOT forge events (Ed25519 signatures from agents)
593
+ - It CANNOT forge events that verifiers will accept, because verifiers re-check `event.agentSignature` against the agent's published Ed25519 keys (§7.3). A witness that commits a fabricated event_json into its Merkle tree can produce a valid inclusion proof, but the per-event agent-signature check fails and the response is rejected. Verifiers that walk the proof without checking `agentSignature` lose this guarantee.
569
594
  - It CANNOT modify events without breaking Merkle proofs
570
595
  - It CAN suppress events by refusing to include them (detectable via consistency proofs between submissions)
571
596
  - It CAN be unavailable (agents fall back to bilateral exchange)
@@ -16,7 +16,7 @@ This checklist lets an independent implementer verify INK conformance without re
16
16
  - **SHOULD**, recommended; deviations require justification
17
17
  - **MAY**, truly optional; advertised via capability
18
18
 
19
- **Status column** applies to the Tulpa reference implementation:
19
+ **Status column** applies to the Tulpa implementation:
20
20
  - **Required**, part of the v1 wire contract
21
21
  - **Optional**, capability-gated, not assumed
22
22
  - **Extension**, defined but not required for base interop
@@ -145,6 +145,14 @@ This checklist lets an independent implementer verify INK conformance without re
145
145
  | W6 | Checkpoint: C2SP tlog-checkpoint format at `GET /ink/v1/checkpoint` | SHOULD | Optional | Auditability §7 |, | `witness/witness/test/endpoints.test.ts (witness repo)` |
146
146
  | W7 | Transport auth on submit: dual signature (transport + event) | MUST | Optional | Auditability §7 | `witness.json` | `witness/witness/test/endpoints.test.ts (witness repo)` |
147
147
  | W8 | Submit includes `signingKeyId` in transport auth | SHOULD | Required | Key Rotation Phase 3 |, | `test/ink-key-rotation.test.ts` |
148
+ | W9 | Query response is the signed `network.tulpa.audit_query_response` envelope binding `serviceDid`, `messageId`, `requester`, `events`, `proofs`, `treeSize`, `rootHash`, `timestamp` | MUST | Optional | Auditability §7.3 | `witness.json` | `test/audit-query-response.test.ts`, `test/verify-audit-query-response.test.ts` |
149
+ | W10 | Per-event Merkle proof rule: leaf = `SHA-256(0x00 \|\| JCS(event-without-agentSignature))` (RFC 6962) | MUST | Optional | Auditability §7.3 | `witness.json` | `test/merkle-leaf-hash.test.ts` |
150
+ | W11 | Per-event scope: `event.messageId == envelope.messageId` AND `envelope.requester ∈ {event.agentId, event.counterpartyId}` | MUST | Optional | Auditability §7.3, §7.4 |, | `test/verify-audit-query-response.test.ts` |
151
+ | W12 | Deterministic result-set ordering so signed bytes are reproducible | MUST | Optional | Auditability §7.3 |, | `witness/witness/test/security-round12.test.ts (witness repo)` |
152
+ | W13 | Fail-closed on truncation: refuse to sign a partial result; return unsigned 413 | MUST | Optional | Auditability §7.3 |, | `witness/witness/test/security-round12.test.ts (witness repo)` |
153
+ | W14 | Fail-closed on storage integrity (event_hash mismatch, missing Merkle node, column-vs-event_json drift): HTTP 500, no signed response | MUST | Optional | Auditability §7.3 |, | `witness/witness/test/security-round12.test.ts (witness repo)` |
154
+ | W15 | Empty-log response: `treeSize == 0` MUST have empty `events`, empty `proofs` and canonical empty-tree `rootHash` | MUST | Optional | Auditability §7.3 |, | `test/verify-audit-query-response.test.ts` |
155
+ | W16 | Every returned event MUST include `agentSignature`; verifiers MUST verify it against the agent's published keys (witness Merkle validity does not prove agent provenance) | MUST | Optional | Auditability §7.3, §7.5 |, | `test/verify-audit-query-response.test.ts` |
148
156
 
149
157
  ---
150
158
 
@@ -21,7 +21,14 @@
21
21
  * current checkpoint and calls verifyInclusionReceipt.
22
22
  */
23
23
  import * as ed from "@noble/ed25519";
24
- import { base64urlDecode, jcsCanonicalize, hexToBytes, bytesToHex } from "../crypto/ink.js";
24
+ import {
25
+ base64urlDecode,
26
+ jcsCanonicalize,
27
+ hexToBytes,
28
+ bytesToHex,
29
+ computeAuditMerkleLeafHash,
30
+ verifyAuditQueryResponseSignature,
31
+ } from "../crypto/ink.js";
25
32
 
26
33
  export interface InclusionReceipt {
27
34
  eventId: string;
@@ -61,9 +68,10 @@ export async function verifyInclusionReceipt(opts: {
61
68
  receipt: InclusionReceipt;
62
69
  /** Raw 32-byte Ed25519 public key of the witness service. */
63
70
  witnessPublicKey: Uint8Array;
64
- /** Optional leaf hash (SHA-256 of JCS(audit event without agentSignature),
65
- * hex-encoded). When provided, the inclusion proof is walked from
66
- * the leaf up to the claimed rootHash. */
71
+ /** Optional RFC 6962 leaf hash for the underlying audit event:
72
+ * SHA-256(0x00 || JCS(event-without-agentSignature)), hex-encoded.
73
+ * Use `computeAuditMerkleLeafHash` to derive it. When provided, the
74
+ * inclusion proof is walked from this leaf up to the claimed rootHash. */
67
75
  eventHash?: string;
68
76
  /** Optional later checkpoint to cross-check the receipt against.
69
77
  * Must come from a `/ink/v1/checkpoint` response that the verifier
@@ -157,6 +165,281 @@ export async function verifyInclusionReceipt(opts: {
157
165
  return { valid: true, steps };
158
166
  }
159
167
 
168
+ // ── Audit-query response verification (INK Auditability §7.3) ──
169
+ //
170
+ // The low-level `verifyAuditQueryResponseSignature` only checks the
171
+ // Ed25519 signature over caller-supplied canonical bytes. This wrapper
172
+ // is the recommended verifier for consumers of a witness response: it
173
+ // re-derives the canonical bytes, then performs every additional
174
+ // envelope and proof check §7.3 mandates.
175
+
176
+ export interface AuditQueryResponse {
177
+ protocol: "ink/0.1";
178
+ type: "network.tulpa.audit_query_response";
179
+ serviceDid: string;
180
+ messageId: string;
181
+ requester: string;
182
+ events: Array<Record<string, unknown> & { id: string }>;
183
+ proofs: Array<{ eventId: string; leafIndex: number; inclusionProof: string[] }>;
184
+ treeSize: number;
185
+ rootHash: string;
186
+ timestamp: string;
187
+ serviceSignature: string;
188
+ }
189
+
190
+ export interface AuditQueryResponseVerifyResult {
191
+ valid: boolean;
192
+ steps: VerifyStep[];
193
+ }
194
+
195
+ /**
196
+ * Full §7.3 verification of a witness audit-query response. Use this in
197
+ * preference to `verifyAuditQueryResponseSignature`, which is the
198
+ * underlying primitive and verifies only the Ed25519 signature. This
199
+ * function additionally enforces:
200
+ *
201
+ * - Envelope shape (protocol, type, serviceDid, requester, messageId,
202
+ * timestamp, treeSize, rootHash, events[], proofs[])
203
+ * - Service signature with the right canonical bytes
204
+ * - Optional caller-supplied bindings: expected `messageId`,
205
+ * `requester`, `serviceDid` (each rejected on mismatch)
206
+ * - `events` and `proofs` align one-to-one by `eventId`
207
+ * - Every event includes a non-empty `agentSignature` field
208
+ * - Every proof walks from `computeAuditMerkleLeafHash(event)` up to
209
+ * the response's `rootHash` at `treeSize`
210
+ * - Optional `laterCheckpoint`: tree only grew, no fork at same size
211
+ *
212
+ * **Per-event agent-signature verification (§7.5 trust model).** A
213
+ * Merkle-valid response is necessary but not sufficient: the witness
214
+ * could in principle commit a fabricated "event" not signed by any
215
+ * agent, sign the resulting `(treeSize, rootHash)`, and the Merkle
216
+ * proof walks just fine. To detect this, callers MUST pass a
217
+ * `verifyEventSignature` callback that resolves the agent's published
218
+ * Ed25519 keys (typically via Agent Card §2) and validates
219
+ * `event.agentSignature`. The callback is REQUIRED, not optional: the
220
+ * verifier refuses to return `valid: true` without it, so a caller
221
+ * cannot accidentally accept witness-fabricated events.
222
+ *
223
+ * **Freshness.** A `valid: true` result attests that the response was a
224
+ * complete enumeration of the requester's visible events at the
225
+ * `(treeSize, rootHash)` snapshot the witness signed, NOT that it is
226
+ * the witness's current authoritative view. The signed envelope binds
227
+ * `timestamp`, but verifiers wanting "is this still current?"
228
+ * semantics MUST additionally fetch a fresh witness checkpoint and
229
+ * compare it (e.g. require `laterCheckpoint.treeSize === response.treeSize
230
+ * && laterCheckpoint.rootHash === response.rootHash` for "current", or
231
+ * use `laterCheckpoint` here only to prove the tree never rewound or
232
+ * forked).
233
+ *
234
+ * Returns `{valid, steps}` where each step explains pass/fail with detail.
235
+ * Pure function. Does not perform network I/O.
236
+ */
237
+ export async function verifyAuditQueryResponse(opts: {
238
+ response: AuditQueryResponse;
239
+ /** Raw 32-byte Ed25519 public key of the witness service. */
240
+ witnessPublicKey: Uint8Array;
241
+ /** Locally authenticated requester DID. Verifier MUST supply this so
242
+ * a response signed for Alice cannot be replayed to Bob. */
243
+ expectedRequester: string;
244
+ /** The `messageId` the verifier asked about. Bound for paranoia: the
245
+ * signed envelope already commits to messageId, so this catches
246
+ * client-side routing bugs before they become trust bugs. */
247
+ expectedMessageId: string;
248
+ /** Optional: witness DID the verifier expects (pinned out of band). */
249
+ expectedServiceDid?: string;
250
+ /** Optional later checkpoint to cross-check against. Same semantics
251
+ * as `verifyInclusionReceipt`. */
252
+ laterCheckpoint?: { treeSize: number; rootHash: string };
253
+ /** Per-event agent-signature verifier (REQUIRED by Auditability §7.5).
254
+ * The caller resolves the event's submitting agent's public key set
255
+ * (typically from the Agent Card) and returns true if
256
+ * `event.agentSignature` verifies. The verifier refuses to return
257
+ * `valid: true` without this: Merkle inclusion alone does not prove
258
+ * the agent produced the event. If a caller genuinely wants to
259
+ * bypass per-event signature checks (e.g. during a pure Merkle
260
+ * audit), they MUST explicitly pass a callback that does so. */
261
+ verifyEventSignature: (event: Record<string, unknown>) => Promise<boolean>;
262
+ }): Promise<AuditQueryResponseVerifyResult> {
263
+ const steps: VerifyStep[] = [];
264
+ const { response, witnessPublicKey, expectedRequester, expectedMessageId, expectedServiceDid, laterCheckpoint, verifyEventSignature } = opts;
265
+
266
+ // ── Step 1: structural validation ──
267
+ const structuralProblem = checkAuditQueryResponseShape(response);
268
+ if (structuralProblem) {
269
+ steps.push({ name: "structure", pass: false, detail: structuralProblem });
270
+ return { valid: false, steps };
271
+ }
272
+ steps.push({ name: "structure", pass: true });
273
+
274
+ // ── Step 2: caller-supplied binding checks ──
275
+ if (response.messageId !== expectedMessageId) {
276
+ steps.push({ name: "binding", pass: false, detail: `messageId mismatch: response=${response.messageId} expected=${expectedMessageId}` });
277
+ return { valid: false, steps };
278
+ }
279
+ if (response.requester !== expectedRequester) {
280
+ steps.push({ name: "binding", pass: false, detail: "requester mismatch (response signed for a different requester)" });
281
+ return { valid: false, steps };
282
+ }
283
+ if (expectedServiceDid !== undefined && response.serviceDid !== expectedServiceDid) {
284
+ steps.push({ name: "binding", pass: false, detail: `serviceDid mismatch: response=${response.serviceDid} expected=${expectedServiceDid}` });
285
+ return { valid: false, steps };
286
+ }
287
+ steps.push({ name: "binding", pass: true });
288
+
289
+ // ── Step 3: signature over canonical bytes ──
290
+ const { serviceSignature, ...payload } = response;
291
+ const sigValid = await verifyAuditQueryResponseSignature(
292
+ payload as unknown as Record<string, unknown>,
293
+ serviceSignature,
294
+ witnessPublicKey,
295
+ );
296
+ if (!sigValid) {
297
+ steps.push({ name: "signature", pass: false, detail: "Ed25519 verification failed" });
298
+ return { valid: false, steps };
299
+ }
300
+ steps.push({ name: "signature", pass: true });
301
+
302
+ // ── Step 4: per-event scope check ──
303
+ //
304
+ // The envelope binds messageId and requester, but until we look INTO
305
+ // each event we don't know the witness isn't returning a Merkle-valid
306
+ // event from a different messageId or one the requester is not a
307
+ // party to. Reject any event whose own fields contradict the envelope.
308
+ for (const event of response.events) {
309
+ const eMessageId = (event as { messageId?: unknown }).messageId;
310
+ if (typeof eMessageId !== "string" || eMessageId !== response.messageId) {
311
+ steps.push({ name: "scope", pass: false, detail: `event ${event.id}: messageId does not match envelope` });
312
+ return { valid: false, steps };
313
+ }
314
+ const eAgentId = (event as { agentId?: unknown }).agentId;
315
+ const eCounterpartyId = (event as { counterpartyId?: unknown }).counterpartyId;
316
+ const requesterIsParty =
317
+ (typeof eAgentId === "string" && eAgentId === expectedRequester) ||
318
+ (typeof eCounterpartyId === "string" && eCounterpartyId === expectedRequester);
319
+ if (!requesterIsParty) {
320
+ steps.push({ name: "scope", pass: false, detail: `event ${event.id}: requester ${expectedRequester} is not a party (agentId/counterpartyId)` });
321
+ return { valid: false, steps };
322
+ }
323
+ }
324
+ steps.push({ name: "scope", pass: true });
325
+
326
+ // ── Step 5: events ↔ proofs strict one-to-one by eventId ──
327
+ //
328
+ // §7.3 mandates a one-to-one mapping. Enforce both directions:
329
+ // - No duplicate event.id (otherwise `events: [A, A]` paired with
330
+ // `proofs: [proof(A), proof(extra)]` could pass length + has-proof
331
+ // checks while including a proof for an unverified event).
332
+ // - No duplicate proof.eventId.
333
+ // - Every proof.eventId corresponds to some event.id (no "extra"
334
+ // proofs for events not in the response).
335
+ if (response.events.length !== response.proofs.length) {
336
+ steps.push({ name: "proofs", pass: false, detail: `events and proofs length differ: ${response.events.length} vs ${response.proofs.length}` });
337
+ return { valid: false, steps };
338
+ }
339
+ const eventIds = new Set<string>();
340
+ for (const event of response.events) {
341
+ if (eventIds.has(event.id)) {
342
+ steps.push({ name: "proofs", pass: false, detail: `duplicate event id ${event.id}` });
343
+ return { valid: false, steps };
344
+ }
345
+ eventIds.add(event.id);
346
+ }
347
+ const proofById = new Map<string, { leafIndex: number; inclusionProof: string[] }>();
348
+ for (const p of response.proofs) {
349
+ if (proofById.has(p.eventId)) {
350
+ steps.push({ name: "proofs", pass: false, detail: `duplicate proof for eventId ${p.eventId}` });
351
+ return { valid: false, steps };
352
+ }
353
+ if (!eventIds.has(p.eventId)) {
354
+ steps.push({ name: "proofs", pass: false, detail: `proof references unknown eventId ${p.eventId}` });
355
+ return { valid: false, steps };
356
+ }
357
+ proofById.set(p.eventId, { leafIndex: p.leafIndex, inclusionProof: p.inclusionProof });
358
+ }
359
+ for (const event of response.events) {
360
+ if (!proofById.has(event.id)) {
361
+ steps.push({ name: "proofs", pass: false, detail: `event ${event.id} has no matching proof` });
362
+ return { valid: false, steps };
363
+ }
364
+ }
365
+ steps.push({ name: "proofs", pass: true });
366
+
367
+ // ── Step 6: walk each inclusion proof ──
368
+ for (const event of response.events) {
369
+ const p = proofById.get(event.id)!;
370
+ let leafHash: string;
371
+ try {
372
+ leafHash = await computeAuditMerkleLeafHash(event as unknown as Record<string, unknown>);
373
+ } catch (e) {
374
+ steps.push({ name: "proof-walk", pass: false, detail: `event ${event.id}: leaf-hash computation failed: ${e instanceof Error ? e.message : String(e)}` });
375
+ return { valid: false, steps };
376
+ }
377
+ const ok = await verifyInclusionProof(leafHash, p.inclusionProof, p.leafIndex, response.treeSize, response.rootHash);
378
+ if (!ok) {
379
+ steps.push({ name: "proof-walk", pass: false, detail: `event ${event.id}: leaf-to-root walk did not reach claimed rootHash` });
380
+ return { valid: false, steps };
381
+ }
382
+ }
383
+ steps.push({ name: "proof-walk", pass: true });
384
+
385
+ // ── Step 6: per-event agent signature ──
386
+ //
387
+ // Merkle validity proves the witness committed to these exact event
388
+ // bytes; it does NOT prove an agent ever signed them. The caller
389
+ // MUST supply `verifyEventSignature`; we refuse to return valid
390
+ // otherwise.
391
+ if (typeof verifyEventSignature !== "function") {
392
+ steps.push({
393
+ name: "agent-signature",
394
+ pass: false,
395
+ detail: "verifyEventSignature callback is required (Auditability §7.5); refusing to accept witness Merkle inclusion as proof of agent provenance",
396
+ });
397
+ return { valid: false, steps };
398
+ }
399
+ for (const event of response.events) {
400
+ let ok = false;
401
+ try {
402
+ ok = await verifyEventSignature(event as unknown as Record<string, unknown>);
403
+ } catch (e) {
404
+ steps.push({ name: "agent-signature", pass: false, detail: `event ${event.id}: verifier threw: ${e instanceof Error ? e.message : String(e)}` });
405
+ return { valid: false, steps };
406
+ }
407
+ if (!ok) {
408
+ steps.push({ name: "agent-signature", pass: false, detail: `event ${event.id}: agentSignature did not verify` });
409
+ return { valid: false, steps };
410
+ }
411
+ }
412
+ steps.push({ name: "agent-signature", pass: true });
413
+
414
+ // ── Step 7: optional later-checkpoint cross-check ──
415
+ if (laterCheckpoint !== undefined) {
416
+ const cpShape = checkCheckpointShape(laterCheckpoint);
417
+ if (cpShape) {
418
+ steps.push({ name: "checkpoint", pass: false, detail: cpShape });
419
+ return { valid: false, steps };
420
+ }
421
+ if (laterCheckpoint.treeSize < response.treeSize) {
422
+ steps.push({
423
+ name: "checkpoint",
424
+ pass: false,
425
+ detail: `checkpoint treeSize ${laterCheckpoint.treeSize} < response treeSize ${response.treeSize} (witness rewound the tree)`,
426
+ });
427
+ return { valid: false, steps };
428
+ }
429
+ if (laterCheckpoint.treeSize === response.treeSize && laterCheckpoint.rootHash !== response.rootHash) {
430
+ steps.push({
431
+ name: "checkpoint",
432
+ pass: false,
433
+ detail: "checkpoint rootHash differs from response rootHash at same treeSize (fork)",
434
+ });
435
+ return { valid: false, steps };
436
+ }
437
+ steps.push({ name: "checkpoint", pass: true });
438
+ }
439
+
440
+ return { valid: true, steps };
441
+ }
442
+
160
443
  // ── Internal helpers ──
161
444
 
162
445
  /** Generous upper bound on inclusion-proof length. Real proofs are
@@ -192,6 +475,59 @@ function checkReceiptShape(receipt: InclusionReceipt): string | null {
192
475
  return null;
193
476
  }
194
477
 
478
+ // SHA-256("") in hex, used as the empty-log Merkle root per RFC 6962 §2.1.
479
+ // A fresh witness with no submissions reports treeSize=0 and rootHash=EMPTY_TREE_ROOT.
480
+ const EMPTY_TREE_ROOT = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
481
+
482
+ function checkAuditQueryResponseShape(r: AuditQueryResponse): string | null {
483
+ if (r === null || typeof r !== "object") return "response is not an object";
484
+ if (r.protocol !== "ink/0.1") return `protocol must be "ink/0.1"`;
485
+ if (r.type !== "network.tulpa.audit_query_response") return `type must be "network.tulpa.audit_query_response"`;
486
+ if (typeof r.serviceDid !== "string" || r.serviceDid.length === 0) return "serviceDid missing";
487
+ if (typeof r.messageId !== "string" || r.messageId.length === 0) return "messageId missing";
488
+ if (typeof r.requester !== "string" || r.requester.length === 0) return "requester missing";
489
+ if (typeof r.timestamp !== "string" || r.timestamp.length === 0) return "timestamp missing";
490
+ if (typeof r.serviceSignature !== "string" || r.serviceSignature.length === 0) return "serviceSignature missing";
491
+ if (!Number.isInteger(r.treeSize) || r.treeSize < 0) return "treeSize must be a non-negative integer";
492
+ if (typeof r.rootHash !== "string" || !/^[0-9a-f]{64}$/.test(r.rootHash)) {
493
+ return "rootHash must be 64 lowercase hex chars";
494
+ }
495
+ if (!Array.isArray(r.events)) return "events must be an array";
496
+ if (!Array.isArray(r.proofs)) return "proofs must be an array";
497
+ // Empty-log case: a fresh witness can sign treeSize=0 with the
498
+ // canonical empty-tree root and zero events/proofs. Any other shape
499
+ // at treeSize=0 is the witness fabricating a state.
500
+ if (r.treeSize === 0) {
501
+ if (r.events.length !== 0) return "treeSize=0 response must have empty events";
502
+ if (r.proofs.length !== 0) return "treeSize=0 response must have empty proofs";
503
+ if (r.rootHash !== EMPTY_TREE_ROOT) return "treeSize=0 response must have the empty-tree rootHash";
504
+ }
505
+ for (const e of r.events) {
506
+ if (e === null || typeof e !== "object") return "every event must be an object";
507
+ if (typeof (e as { id?: unknown }).id !== "string") return "every event must have a string id";
508
+ const agentSig = (e as { agentSignature?: unknown }).agentSignature;
509
+ if (typeof agentSig !== "string" || agentSig.length === 0) {
510
+ return "every event must include a non-empty agentSignature";
511
+ }
512
+ }
513
+ for (const p of r.proofs) {
514
+ if (p === null || typeof p !== "object") return "every proof must be an object";
515
+ if (typeof p.eventId !== "string" || p.eventId.length === 0) return "every proof must have an eventId";
516
+ if (!Number.isInteger(p.leafIndex) || p.leafIndex < 0) return "every proof.leafIndex must be a non-negative integer";
517
+ if (p.leafIndex >= r.treeSize) return "every proof.leafIndex must be < treeSize";
518
+ if (!Array.isArray(p.inclusionProof)) return "every proof.inclusionProof must be an array";
519
+ if (p.inclusionProof.length > MAX_PROOF_LENGTH) {
520
+ return `proof.inclusionProof exceeds max length of ${MAX_PROOF_LENGTH} entries`;
521
+ }
522
+ for (const h of p.inclusionProof) {
523
+ if (typeof h !== "string" || !/^[0-9a-f]{64}$/.test(h)) {
524
+ return "every inclusionProof entry must be 64 lowercase hex chars";
525
+ }
526
+ }
527
+ }
528
+ return null;
529
+ }
530
+
195
531
  function checkCheckpointShape(cp: { treeSize: number; rootHash: string }): string | null {
196
532
  if (cp === null || typeof cp !== "object") return "laterCheckpoint must be an object";
197
533
  if (!Number.isInteger(cp.treeSize) || cp.treeSize < 0) {
package/src/crypto/ink.ts CHANGED
@@ -211,7 +211,7 @@ export function buildSignatureBase(input: InkSignInput): string {
211
211
  throw new Error("Signature base body exceeds maximum allowed complexity");
212
212
  }
213
213
  const canonical = jcsCanonicalize(input.body);
214
- if (canonical.length > MAX_SIGBASE_BODY_BYTES) {
214
+ if (new TextEncoder().encode(canonical).length > MAX_SIGBASE_BODY_BYTES) {
215
215
  throw new Error("Signature base body exceeds maximum allowed size");
216
216
  }
217
217
  return `ink/0.1\n${input.method}\n${input.path}\n${input.recipientDid}\n${canonical}\n${input.timestamp}`;
@@ -685,10 +685,10 @@ export async function computeMessageHash(body: Record<string, unknown>): Promise
685
685
  throw new Error("Message body exceeds maximum allowed complexity");
686
686
  }
687
687
  const canonical = jcsCanonicalize(body);
688
- if (canonical.length > MAX_SIGBASE_BODY_BYTES) {
688
+ const bytes = new TextEncoder().encode(canonical);
689
+ if (bytes.length > MAX_SIGBASE_BODY_BYTES) {
689
690
  throw new Error("Message body exceeds maximum allowed size");
690
691
  }
691
- const bytes = new TextEncoder().encode(canonical);
692
692
  const digest = await crypto.subtle.digest("SHA-256", bytes);
693
693
  return bytesToHex(new Uint8Array(digest));
694
694
  }
@@ -713,12 +713,11 @@ export async function signAuditEvent(
713
713
  throw new Error("Audit event exceeds maximum allowed complexity");
714
714
  }
715
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
716
  const prefixed = `ink/audit-event\n${canonical}`;
721
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
+ }
722
721
  const sig = await ed.signAsync(bytes, privateKey);
723
722
  return base64urlEncode(sig);
724
723
  }
@@ -741,24 +740,58 @@ export async function verifyAuditEventSignature(
741
740
  // attacker-supplied object that would only get rejected by the size cap
742
741
  // below. Cheap enough that it adds no cost for real events.
743
742
  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
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
751
  const sig = base64urlDecode(signature);
752
752
  return await ed.verifyAsync(sig, bytes, publicKey);
753
753
  } catch {
754
- // Malformed signature (wrong length, invalid chars, bad key) — treat as invalid
755
754
  return false;
756
755
  }
757
756
  }
758
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
+
759
790
  /**
760
791
  * Compute SHA-256 hash of JCS-canonicalized audit event (excluding agentSignature).
761
- * Used for previousEventHash chain linkage.
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.
762
795
  */
763
796
  export async function computeEventHash(event: Record<string, unknown>): Promise<string> {
764
797
  if (event === null || typeof event !== "object" || Array.isArray(event)) {
@@ -773,10 +806,10 @@ export async function computeEventHash(event: Record<string, unknown>): Promise<
773
806
  throw new Error("Audit event exceeds maximum allowed complexity");
774
807
  }
775
808
  const canonical = jcsCanonicalize(eventWithoutSig);
776
- if (canonical.length > MAX_SIGBASE_BODY_BYTES) {
809
+ const bytes = new TextEncoder().encode(canonical);
810
+ if (bytes.length > MAX_SIGBASE_BODY_BYTES) {
777
811
  throw new Error("Audit event exceeds maximum allowed size");
778
812
  }
779
- const bytes = new TextEncoder().encode(canonical);
780
813
  const digest = await crypto.subtle.digest("SHA-256", bytes);
781
814
  return bytesToHex(new Uint8Array(digest));
782
815
  }
@@ -797,14 +830,14 @@ export async function signAuditResponse(
797
830
  throw new Error("Audit response events exceed maximum allowed complexity");
798
831
  }
799
832
  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
833
  const prefixed = `ink/audit-response\n${canonical}`;
807
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
+ }
808
841
  const sig = await ed.signAsync(bytes, privateKey);
809
842
  return base64urlEncode(sig);
810
843
  }
@@ -825,16 +858,14 @@ export async function verifyAuditResponseSignature(
825
858
  if (!/^[A-Za-z0-9_-]{86}$/.test(signature)) return false;
826
859
  // Pre-canonicalize complexity cap (see verifyAuditEventSignature).
827
860
  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
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;
834
866
  const sig = base64urlDecode(signature);
835
867
  return await ed.verifyAsync(sig, bytes, publicKey);
836
868
  } catch {
837
- // Malformed signature (wrong length, invalid chars, bad key) — treat as invalid
838
869
  return false;
839
870
  }
840
871
  }
@@ -898,5 +929,118 @@ export async function verifyAuditEventChain(
898
929
  return { valid: true };
899
930
  }
900
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
+
901
1045
  // Re-export encoding helpers for test use
902
1046
  export { base64urlEncode, base64urlDecode, hexToBytes, bytesToHex, jcsCanonicalize };
package/src/index.ts CHANGED
@@ -9,11 +9,14 @@ export {
9
9
  buildAuthHeader,
10
10
  computeMessageHash,
11
11
  computeEventHash,
12
+ computeAuditMerkleLeafHash,
12
13
  signAuditEvent,
13
14
  verifyAuditEventSignature,
14
15
  signAuditResponse,
15
16
  verifyAuditResponseSignature,
16
17
  verifyAuditEventChain,
18
+ signAuditQueryResponse,
19
+ verifyAuditQueryResponseSignature,
17
20
  encryptInkPayload,
18
21
  decryptInkPayload,
19
22
  checkReplay,
@@ -46,11 +49,14 @@ export {
46
49
  // Middleware: transport-level INK auth
47
50
  export { verifyInkAuth, type NonceStore } from "./middleware/ink-auth.js";
48
51
 
49
- // Audit: inclusion-receipt verification
52
+ // Audit: inclusion-receipt + audit-query-response verification
50
53
  export {
51
54
  verifyInclusionReceipt,
55
+ verifyAuditQueryResponse,
52
56
  type InclusionReceipt,
53
57
  type InclusionReceiptVerifyResult,
58
+ type AuditQueryResponse,
59
+ type AuditQueryResponseVerifyResult,
54
60
  type VerifyStep,
55
61
  } from "./audit/inclusion-receipt.js";
56
62
 
@@ -66,18 +66,18 @@ export type InkAuditEventType = z.infer<typeof InkAuditEventTypeSchema>;
66
66
  // ── INK Audit Event (hash-chained, signed) ──
67
67
 
68
68
  export const InkAuditEventSchema = z.object({
69
- id: z.string(),
69
+ id: z.string().min(1),
70
70
  version: z.literal("ink-audit/1"),
71
- agentId: z.string(),
72
- agentSignature: z.string(),
71
+ agentId: z.string().min(1),
72
+ agentSignature: z.string().min(1),
73
73
  sequence: z.number().int().positive(),
74
- previousEventHash: z.string().nullable(),
74
+ previousEventHash: z.string().regex(/^[0-9a-f]{64}$/).nullable(),
75
75
  eventType: InkAuditEventTypeSchema,
76
76
  timestamp: z.string().datetime(),
77
- messageId: z.string().optional(),
78
- correlationId: z.string().optional(),
79
- counterpartyId: z.string().optional(),
80
- signingKeyId: z.string().optional(),
77
+ messageId: z.string().min(1).optional(),
78
+ correlationId: z.string().min(1).optional(),
79
+ counterpartyId: z.string().min(1).optional(),
80
+ signingKeyId: z.string().min(1).optional(),
81
81
  data: z.record(z.unknown()).optional(),
82
82
  });
83
83