@adastracomputing/ink 0.1.0-alpha.3 → 0.1.0-alpha.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/dist/audit/inclusion-receipt.d.ts +142 -0
- package/dist/audit/inclusion-receipt.js +496 -0
- package/dist/crypto/ink.d.ts +178 -0
- package/dist/crypto/ink.js +915 -0
- package/dist/crypto/keys.d.ts +42 -0
- package/dist/crypto/keys.js +179 -0
- package/dist/crypto/multi-key-verify.d.ts +29 -0
- package/dist/crypto/multi-key-verify.js +153 -0
- package/dist/crypto/sign.d.ts +17 -0
- package/dist/crypto/sign.js +152 -0
- package/dist/crypto/verify.js +1 -0
- package/dist/discovery/agent-card.d.ts +83 -0
- package/dist/discovery/agent-card.js +545 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +15 -0
- package/dist/ink/checkpoint.d.ts +19 -0
- package/dist/ink/checkpoint.js +69 -0
- package/dist/ink/discovery-gating.d.ts +237 -0
- package/dist/ink/discovery-gating.js +91 -0
- package/dist/ink/handshake-budget.d.ts +90 -0
- package/dist/ink/handshake-budget.js +397 -0
- package/dist/ink/receipts.d.ts +31 -0
- package/dist/ink/receipts.js +89 -0
- package/dist/ink/transport-auth.d.ts +47 -0
- package/dist/ink/transport-auth.js +77 -0
- package/dist/middleware/ink-auth.d.ts +68 -0
- package/dist/middleware/ink-auth.js +214 -0
- package/dist/models/agent-card.d.ts +154 -0
- package/dist/models/agent-card.js +59 -0
- package/dist/models/ink-audit.d.ts +344 -0
- package/dist/models/ink-audit.js +167 -0
- package/dist/models/ink-handshake.d.ts +129 -0
- package/dist/models/ink-handshake.js +89 -0
- package/dist/models/intent.d.ts +437 -0
- package/dist/models/intent.js +172 -0
- package/dist/models/key-entry.d.ts +60 -0
- package/dist/models/key-entry.js +13 -0
- package/dist/models/profile.d.ts +61 -0
- package/dist/models/profile.js +24 -0
- package/package.json +15 -11
- package/src/audit/inclusion-receipt.ts +0 -604
- package/src/crypto/ink.ts +0 -1046
- package/src/crypto/keys.ts +0 -210
- package/src/crypto/multi-key-verify.ts +0 -170
- package/src/crypto/sign.ts +0 -155
- package/src/discovery/agent-card.ts +0 -508
- package/src/index.ts +0 -73
- package/src/ink/checkpoint.ts +0 -75
- package/src/ink/discovery-gating.ts +0 -147
- package/src/ink/handshake-budget.ts +0 -413
- package/src/ink/receipts.ts +0 -114
- package/src/ink/transport-auth.ts +0 -96
- package/src/middleware/ink-auth.ts +0 -263
- package/src/models/agent-card.ts +0 -63
- package/src/models/ink-audit.ts +0 -205
- package/src/models/ink-handshake.ts +0 -123
- package/src/models/intent.ts +0 -201
- package/src/models/key-entry.ts +0 -52
- package/src/models/profile.ts +0 -31
- /package/{src/crypto/verify.ts → dist/crypto/verify.d.ts} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,30 @@ 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.5, ship compiled JS
|
|
12
|
+
|
|
13
|
+
Fixes a publish-time regression in `0.1.0-alpha.3` (and the unreleased
|
|
14
|
+
`alpha.4`) where the package shipped raw TypeScript under `main` and
|
|
15
|
+
`exports`. Node 24 refuses to strip types from anything under
|
|
16
|
+
`node_modules`, so any consumer following the quickstart hit
|
|
17
|
+
`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` on the first `import`
|
|
18
|
+
and could not use the library at all.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- `npm run build` compiles `src/` to `dist/` via `tsconfig.build.json`;
|
|
23
|
+
`prepublishOnly` runs it automatically so the npm tarball always
|
|
24
|
+
contains compiled JS plus declaration maps.
|
|
25
|
+
- `main`, `types` and `exports."."` now point at `./dist/index.js` and
|
|
26
|
+
`./dist/index.d.ts`. The `files` array ships `dist/` instead of
|
|
27
|
+
`src/`, so consumers no longer see raw TS in `node_modules`.
|
|
28
|
+
- Dev shell and `engines.node` move from Node 22 to Node 24 (the
|
|
29
|
+
current Active LTS) to match CI.
|
|
30
|
+
|
|
31
|
+
End-to-end verified against `witness-demo.tulpa.network`: the
|
|
32
|
+
quickstart `submit.mjs` now returns a signed inclusion receipt on
|
|
33
|
+
Node 24 without modification.
|
|
34
|
+
|
|
11
35
|
## 0.1.0-alpha.3, signed audit-query response
|
|
12
36
|
|
|
13
37
|
Closes the last HIGH conformance-audit finding (witness audit-query
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
export interface InclusionReceipt {
|
|
2
|
+
eventId: string;
|
|
3
|
+
leafIndex: number;
|
|
4
|
+
treeSize: number;
|
|
5
|
+
rootHash: string;
|
|
6
|
+
inclusionProof: string[];
|
|
7
|
+
/** ISO 8601 timestamp at which the witness committed the leaf. */
|
|
8
|
+
timestamp: string;
|
|
9
|
+
/** Base64url Ed25519 signature over the canonical bytes. */
|
|
10
|
+
serviceSignature: string;
|
|
11
|
+
}
|
|
12
|
+
export interface VerifyStep {
|
|
13
|
+
name: string;
|
|
14
|
+
pass: boolean;
|
|
15
|
+
detail?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface InclusionReceiptVerifyResult {
|
|
18
|
+
valid: boolean;
|
|
19
|
+
steps: VerifyStep[];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Verify an INK inclusion receipt.
|
|
23
|
+
*
|
|
24
|
+
* Always performs:
|
|
25
|
+
* - Structural validation of the receipt object
|
|
26
|
+
* - Service signature verification against `witnessPublicKey`
|
|
27
|
+
*
|
|
28
|
+
* Optionally performs (when the corresponding input is provided):
|
|
29
|
+
* - Leaf-to-root proof walk (`eventHash`)
|
|
30
|
+
* - Cross-check against a later signed checkpoint (`laterCheckpoint`)
|
|
31
|
+
*/
|
|
32
|
+
export declare function verifyInclusionReceipt(opts: {
|
|
33
|
+
receipt: InclusionReceipt;
|
|
34
|
+
/** Raw 32-byte Ed25519 public key of the witness service. */
|
|
35
|
+
witnessPublicKey: Uint8Array;
|
|
36
|
+
/** Optional RFC 6962 leaf hash for the underlying audit event:
|
|
37
|
+
* SHA-256(0x00 || JCS(event-without-agentSignature)), hex-encoded.
|
|
38
|
+
* Use `computeAuditMerkleLeafHash` to derive it. When provided, the
|
|
39
|
+
* inclusion proof is walked from this leaf up to the claimed rootHash. */
|
|
40
|
+
eventHash?: string;
|
|
41
|
+
/** Optional later checkpoint to cross-check the receipt against.
|
|
42
|
+
* Must come from a `/ink/v1/checkpoint` response that the verifier
|
|
43
|
+
* has separately validated as authentic. */
|
|
44
|
+
laterCheckpoint?: {
|
|
45
|
+
treeSize: number;
|
|
46
|
+
rootHash: string;
|
|
47
|
+
};
|
|
48
|
+
}): Promise<InclusionReceiptVerifyResult>;
|
|
49
|
+
export interface AuditQueryResponse {
|
|
50
|
+
protocol: "ink/0.1";
|
|
51
|
+
type: "network.tulpa.audit_query_response";
|
|
52
|
+
serviceDid: string;
|
|
53
|
+
messageId: string;
|
|
54
|
+
requester: string;
|
|
55
|
+
events: Array<Record<string, unknown> & {
|
|
56
|
+
id: string;
|
|
57
|
+
}>;
|
|
58
|
+
proofs: Array<{
|
|
59
|
+
eventId: string;
|
|
60
|
+
leafIndex: number;
|
|
61
|
+
inclusionProof: string[];
|
|
62
|
+
}>;
|
|
63
|
+
treeSize: number;
|
|
64
|
+
rootHash: string;
|
|
65
|
+
timestamp: string;
|
|
66
|
+
serviceSignature: string;
|
|
67
|
+
}
|
|
68
|
+
export interface AuditQueryResponseVerifyResult {
|
|
69
|
+
valid: boolean;
|
|
70
|
+
steps: VerifyStep[];
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Full §7.3 verification of a witness audit-query response. Use this in
|
|
74
|
+
* preference to `verifyAuditQueryResponseSignature`, which is the
|
|
75
|
+
* underlying primitive and verifies only the Ed25519 signature. This
|
|
76
|
+
* function additionally enforces:
|
|
77
|
+
*
|
|
78
|
+
* - Envelope shape (protocol, type, serviceDid, requester, messageId,
|
|
79
|
+
* timestamp, treeSize, rootHash, events[], proofs[])
|
|
80
|
+
* - Service signature with the right canonical bytes
|
|
81
|
+
* - Optional caller-supplied bindings: expected `messageId`,
|
|
82
|
+
* `requester`, `serviceDid` (each rejected on mismatch)
|
|
83
|
+
* - `events` and `proofs` align one-to-one by `eventId`
|
|
84
|
+
* - Every event includes a non-empty `agentSignature` field
|
|
85
|
+
* - Every proof walks from `computeAuditMerkleLeafHash(event)` up to
|
|
86
|
+
* the response's `rootHash` at `treeSize`
|
|
87
|
+
* - Optional `laterCheckpoint`: tree only grew, no fork at same size
|
|
88
|
+
*
|
|
89
|
+
* **Per-event agent-signature verification (§7.5 trust model).** A
|
|
90
|
+
* Merkle-valid response is necessary but not sufficient: the witness
|
|
91
|
+
* could in principle commit a fabricated "event" not signed by any
|
|
92
|
+
* agent, sign the resulting `(treeSize, rootHash)`, and the Merkle
|
|
93
|
+
* proof walks just fine. To detect this, callers MUST pass a
|
|
94
|
+
* `verifyEventSignature` callback that resolves the agent's published
|
|
95
|
+
* Ed25519 keys (typically via Agent Card §2) and validates
|
|
96
|
+
* `event.agentSignature`. The callback is REQUIRED, not optional: the
|
|
97
|
+
* verifier refuses to return `valid: true` without it, so a caller
|
|
98
|
+
* cannot accidentally accept witness-fabricated events.
|
|
99
|
+
*
|
|
100
|
+
* **Freshness.** A `valid: true` result attests that the response was a
|
|
101
|
+
* complete enumeration of the requester's visible events at the
|
|
102
|
+
* `(treeSize, rootHash)` snapshot the witness signed, NOT that it is
|
|
103
|
+
* the witness's current authoritative view. The signed envelope binds
|
|
104
|
+
* `timestamp`, but verifiers wanting "is this still current?"
|
|
105
|
+
* semantics MUST additionally fetch a fresh witness checkpoint and
|
|
106
|
+
* compare it (e.g. require `laterCheckpoint.treeSize === response.treeSize
|
|
107
|
+
* && laterCheckpoint.rootHash === response.rootHash` for "current", or
|
|
108
|
+
* use `laterCheckpoint` here only to prove the tree never rewound or
|
|
109
|
+
* forked).
|
|
110
|
+
*
|
|
111
|
+
* Returns `{valid, steps}` where each step explains pass/fail with detail.
|
|
112
|
+
* Pure function. Does not perform network I/O.
|
|
113
|
+
*/
|
|
114
|
+
export declare function verifyAuditQueryResponse(opts: {
|
|
115
|
+
response: AuditQueryResponse;
|
|
116
|
+
/** Raw 32-byte Ed25519 public key of the witness service. */
|
|
117
|
+
witnessPublicKey: Uint8Array;
|
|
118
|
+
/** Locally authenticated requester DID. Verifier MUST supply this so
|
|
119
|
+
* a response signed for Alice cannot be replayed to Bob. */
|
|
120
|
+
expectedRequester: string;
|
|
121
|
+
/** The `messageId` the verifier asked about. Bound for paranoia: the
|
|
122
|
+
* signed envelope already commits to messageId, so this catches
|
|
123
|
+
* client-side routing bugs before they become trust bugs. */
|
|
124
|
+
expectedMessageId: string;
|
|
125
|
+
/** Optional: witness DID the verifier expects (pinned out of band). */
|
|
126
|
+
expectedServiceDid?: string;
|
|
127
|
+
/** Optional later checkpoint to cross-check against. Same semantics
|
|
128
|
+
* as `verifyInclusionReceipt`. */
|
|
129
|
+
laterCheckpoint?: {
|
|
130
|
+
treeSize: number;
|
|
131
|
+
rootHash: string;
|
|
132
|
+
};
|
|
133
|
+
/** Per-event agent-signature verifier (REQUIRED by Auditability §7.5).
|
|
134
|
+
* The caller resolves the event's submitting agent's public key set
|
|
135
|
+
* (typically from the Agent Card) and returns true if
|
|
136
|
+
* `event.agentSignature` verifies. The verifier refuses to return
|
|
137
|
+
* `valid: true` without this: Merkle inclusion alone does not prove
|
|
138
|
+
* the agent produced the event. If a caller genuinely wants to
|
|
139
|
+
* bypass per-event signature checks (e.g. during a pure Merkle
|
|
140
|
+
* audit), they MUST explicitly pass a callback that does so. */
|
|
141
|
+
verifyEventSignature: (event: Record<string, unknown>) => Promise<boolean>;
|
|
142
|
+
}): Promise<AuditQueryResponseVerifyResult>;
|
|
@@ -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
|
+
}
|