@adastracomputing/ink 0.1.0-alpha.1 → 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 +43 -5
- package/CODE_OF_CONDUCT.md +1 -1
- package/README.md +7 -5
- package/SECURITY.md +1 -1
- package/bin/ink.mjs +51 -0
- package/bin/verify-inclusion-impl.mjs +483 -0
- package/docs/maturity.md +3 -3
- package/docs/threat-model.md +1 -1
- package/package.json +7 -3
- package/specs/ink-auditability.md +37 -12
- package/specs/ink-compliance-checklist.md +9 -1
- package/src/audit/inclusion-receipt.ts +604 -0
- package/src/crypto/ink.ts +173 -29
- package/src/index.ts +14 -0
- package/src/models/ink-audit.ts +8 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
All notable changes to INK
|
|
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,9 +8,48 @@ 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
|
+
|
|
39
|
+
## 0.1.0-alpha.2, inclusion-receipt verifier
|
|
40
|
+
|
|
41
|
+
Adds a public verification path for INK Auditability Section 7
|
|
42
|
+
inclusion receipts, plus a CLI any third party can run without
|
|
43
|
+
trusting any specific operator's UI.
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
|
|
47
|
+
- `verifyInclusionReceipt({receipt, witnessPublicKey, eventHash?, laterCheckpoint?})` exported from the package root. Pure function. Returns `{valid, steps[]}` where each step explains pass/fail with detail. Always verifies structure + Ed25519 service signature against the canonical `ink/audit-inclusion/v1\n` + JCS format. Optionally walks the Merkle proof when `eventHash` is provided, and cross-checks against a `laterCheckpoint` for tree-grew-not-rewound + no-fork-at-same-treeSize.
|
|
48
|
+
- `ink` CLI dispatcher with a `verify-inclusion` subcommand. `npx @adastracomputing/ink verify-inclusion --file receipt.json --witness https://witness.example.com` fetches the witness DID document + current checkpoint and runs the full verification. Witness URL is validated (https-only by default, `--allow-http` opt-in, no credentials). Exit code 0 = valid, 1 = invalid, 2 = usage / network / validation error. Self-contained ESM JavaScript so it works on any Node 22+ install with no TypeScript toolchain.
|
|
49
|
+
|
|
11
50
|
## 0.1.0-alpha.1, spec clarification
|
|
12
51
|
|
|
13
|
-
Spec-only release.
|
|
52
|
+
Spec-only release. Library code in `src/` is
|
|
14
53
|
unchanged from `0.1.0-alpha.0`; the bundled spec text is updated.
|
|
15
54
|
|
|
16
55
|
### Spec changes
|
|
@@ -26,8 +65,7 @@ unchanged from `0.1.0-alpha.0`; the bundled spec text is updated.
|
|
|
26
65
|
|
|
27
66
|
## 0.1.0-alpha.0, first public alpha
|
|
28
67
|
|
|
29
|
-
Initial open-source release of the INK protocol
|
|
30
|
-
and accompanying specification.
|
|
68
|
+
Initial open-source release of the INK protocol library and specification.
|
|
31
69
|
|
|
32
70
|
### Protocol surface
|
|
33
71
|
|
|
@@ -42,7 +80,7 @@ and accompanying specification.
|
|
|
42
80
|
- Optional containment extension: capability-gated visibility, handshake
|
|
43
81
|
budgets, sender silent-drop after first rate-limit violation.
|
|
44
82
|
|
|
45
|
-
###
|
|
83
|
+
### Library
|
|
46
84
|
|
|
47
85
|
- Public API exported from the package root, see README for the export
|
|
48
86
|
surface.
|
package/CODE_OF_CONDUCT.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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/
|
|
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
|
|
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
|
|
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
|
|
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)
|
package/bin/ink.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `ink` CLI dispatcher. Subcommands:
|
|
4
|
+
* verify-inclusion verify an INK inclusion receipt against a witness
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* npx @adastracomputing/ink verify-inclusion --file receipt.json --witness https://witness.tulpa.network
|
|
8
|
+
*
|
|
9
|
+
* Resolves npm's bin invocation pattern: with a single bin named `ink`
|
|
10
|
+
* matching the package's unscoped slug, `npx @adastracomputing/ink ...`
|
|
11
|
+
* routes all args to this dispatcher.
|
|
12
|
+
*/
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
|
|
16
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
|
|
18
|
+
const SUBCOMMANDS = {
|
|
19
|
+
"verify-inclusion": "verify-inclusion-impl.mjs",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function printHelp() {
|
|
23
|
+
console.log(`ink: INK protocol command-line interface.
|
|
24
|
+
|
|
25
|
+
Subcommands:
|
|
26
|
+
verify-inclusion Verify an INK inclusion receipt against a witness.
|
|
27
|
+
|
|
28
|
+
Run a subcommand with --help for details, e.g.:
|
|
29
|
+
npx @adastracomputing/ink verify-inclusion --help
|
|
30
|
+
`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const argv = process.argv.slice(2);
|
|
34
|
+
const sub = argv[0];
|
|
35
|
+
|
|
36
|
+
if (!sub || sub === "--help" || sub === "-h") {
|
|
37
|
+
printHelp();
|
|
38
|
+
process.exit(sub ? 0 : 2);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const impl = SUBCOMMANDS[sub];
|
|
42
|
+
if (!impl) {
|
|
43
|
+
console.error(`Unknown subcommand: ${sub}`);
|
|
44
|
+
printHelp();
|
|
45
|
+
process.exit(2);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Re-route remaining args to the subcommand implementation. The impl
|
|
49
|
+
// reads from process.argv directly, so rewrite it before importing.
|
|
50
|
+
process.argv = [process.argv[0], join(here, impl), ...argv.slice(1)];
|
|
51
|
+
await import(`./${impl}`);
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI: verify an INK inclusion receipt against a witness's published
|
|
4
|
+
* identity and current checkpoint. Self-contained ESM module so the
|
|
5
|
+
* shebang resolves on any Node 22+ install without a TS toolchain.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
*
|
|
9
|
+
* # Receipt on stdin
|
|
10
|
+
* cat receipt.json | npx @adastracomputing/ink verify-inclusion \
|
|
11
|
+
* --witness https://witness.tulpa.network
|
|
12
|
+
*
|
|
13
|
+
* # Receipt from file
|
|
14
|
+
* npx @adastracomputing/ink verify-inclusion \
|
|
15
|
+
* --file receipt.json \
|
|
16
|
+
* --witness https://witness.tulpa.network
|
|
17
|
+
*
|
|
18
|
+
* # Also walk the inclusion proof
|
|
19
|
+
* npx @adastracomputing/ink verify-inclusion \
|
|
20
|
+
* --file receipt.json \
|
|
21
|
+
* --witness https://witness.tulpa.network \
|
|
22
|
+
* --event-hash 8a3c...
|
|
23
|
+
*
|
|
24
|
+
* Exit codes:
|
|
25
|
+
* 0 receipt is valid
|
|
26
|
+
* 1 receipt is invalid (a step failed)
|
|
27
|
+
* 2 usage / network / parsing error
|
|
28
|
+
*/
|
|
29
|
+
import { readFileSync, statSync } from "node:fs";
|
|
30
|
+
import * as ed from "@noble/ed25519";
|
|
31
|
+
import canonicalize from "canonicalize";
|
|
32
|
+
|
|
33
|
+
// ── arg parsing ──
|
|
34
|
+
|
|
35
|
+
function parseArgs(argv) {
|
|
36
|
+
const out = {};
|
|
37
|
+
for (let i = 0; i < argv.length; i++) {
|
|
38
|
+
const a = argv[i];
|
|
39
|
+
if (a === "--file" || a === "-f") out.file = argv[++i];
|
|
40
|
+
else if (a === "--witness" || a === "-w") out.witness = argv[++i];
|
|
41
|
+
else if (a === "--event-hash" || a === "-e") out.eventHash = argv[++i];
|
|
42
|
+
else if (a === "--allow-http") out.allowHttp = true;
|
|
43
|
+
else if (a === "--help" || a === "-h") out.help = true;
|
|
44
|
+
else {
|
|
45
|
+
console.error(`Unknown argument: ${a}`);
|
|
46
|
+
process.exit(2);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Validate and normalize the --witness URL. Rejects unparseable URLs,
|
|
54
|
+
* schemes other than https (or http with --allow-http), and URLs that
|
|
55
|
+
* carry credentials. Returns scheme://host[:port] with no path.
|
|
56
|
+
*/
|
|
57
|
+
function validateWitnessUrl(raw, allowHttp) {
|
|
58
|
+
let u;
|
|
59
|
+
try { u = new URL(raw); }
|
|
60
|
+
catch { throw new Error(`--witness is not a valid URL: ${raw}`); }
|
|
61
|
+
if (u.username || u.password) throw new Error("--witness URL must not contain credentials");
|
|
62
|
+
if (u.protocol === "https:") return `${u.protocol}//${u.host}`;
|
|
63
|
+
if (u.protocol === "http:") {
|
|
64
|
+
if (!allowHttp) throw new Error("--witness URL must use https:// (pass --allow-http for plain http)");
|
|
65
|
+
return `${u.protocol}//${u.host}`;
|
|
66
|
+
}
|
|
67
|
+
throw new Error(`--witness URL scheme must be https:// or http://, got ${u.protocol}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function printHelp() {
|
|
71
|
+
console.log(`verify-inclusion: verify an INK inclusion receipt.
|
|
72
|
+
|
|
73
|
+
Usage:
|
|
74
|
+
verify-inclusion --witness <url> [--file <receipt.json>] [--event-hash <hex>]
|
|
75
|
+
|
|
76
|
+
Options:
|
|
77
|
+
-w, --witness <url> Witness base URL (e.g. https://witness.tulpa.network)
|
|
78
|
+
-f, --file <path> Receipt JSON file. Omit to read from stdin.
|
|
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.
|
|
83
|
+
-h, --help Show this help.
|
|
84
|
+
|
|
85
|
+
Exit codes:
|
|
86
|
+
0 receipt valid
|
|
87
|
+
1 receipt invalid
|
|
88
|
+
2 usage or network error
|
|
89
|
+
`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── encoding helpers (mirror src/crypto/ink.ts) ──
|
|
93
|
+
|
|
94
|
+
function base64urlDecode(s) {
|
|
95
|
+
const padded = s.replace(/-/g, "+").replace(/_/g, "/") + "==".slice(0, (4 - (s.length % 4)) % 4);
|
|
96
|
+
const bin = Buffer.from(padded, "base64");
|
|
97
|
+
return new Uint8Array(bin.buffer, bin.byteOffset, bin.byteLength);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function hexToBytes(hex) {
|
|
101
|
+
const out = new Uint8Array(hex.length / 2);
|
|
102
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
103
|
+
out[i / 2] = parseInt(hex.slice(i, i + 2), 16);
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function bytesToHex(bytes) {
|
|
109
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── multibase Ed25519 key decode (z-prefix, base58btc, 0xed 0x01 multicodec) ──
|
|
113
|
+
|
|
114
|
+
const BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
115
|
+
function decodePublicKeyMultibase(mb) {
|
|
116
|
+
if (typeof mb !== "string" || mb.length === 0 || mb[0] !== "z") {
|
|
117
|
+
throw new Error("publicKeyMultibase must start with 'z' (base58btc)");
|
|
118
|
+
}
|
|
119
|
+
const body = mb.slice(1);
|
|
120
|
+
let num = 0n;
|
|
121
|
+
for (const ch of body) {
|
|
122
|
+
const idx = BASE58.indexOf(ch);
|
|
123
|
+
if (idx < 0) throw new Error(`invalid base58btc char: ${ch}`);
|
|
124
|
+
num = num * 58n + BigInt(idx);
|
|
125
|
+
}
|
|
126
|
+
const bytes = [];
|
|
127
|
+
while (num > 0n) {
|
|
128
|
+
bytes.unshift(Number(num & 0xffn));
|
|
129
|
+
num >>= 8n;
|
|
130
|
+
}
|
|
131
|
+
for (const ch of body) {
|
|
132
|
+
if (ch !== "1") break;
|
|
133
|
+
bytes.unshift(0);
|
|
134
|
+
}
|
|
135
|
+
if (bytes.length < 2 || bytes[0] !== 0xed || bytes[1] !== 0x01) {
|
|
136
|
+
throw new Error("multibase key missing Ed25519 multicodec prefix (0xed 0x01)");
|
|
137
|
+
}
|
|
138
|
+
return new Uint8Array(bytes.slice(2));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Merkle inclusion-proof walker (RFC 6962-derived) ──
|
|
142
|
+
|
|
143
|
+
async function hashPair(left, right) {
|
|
144
|
+
const l = hexToBytes(left);
|
|
145
|
+
const r = hexToBytes(right);
|
|
146
|
+
const buf = new Uint8Array(1 + l.length + r.length);
|
|
147
|
+
buf[0] = 0x01;
|
|
148
|
+
buf.set(l, 1);
|
|
149
|
+
buf.set(r, 1 + l.length);
|
|
150
|
+
const out = new Uint8Array(await crypto.subtle.digest("SHA-256", buf));
|
|
151
|
+
return bytesToHex(out);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function largestPowerOf2LessThan(n) {
|
|
155
|
+
if (n <= 1) return 0;
|
|
156
|
+
let p = 1;
|
|
157
|
+
while (p * 2 < n) p *= 2;
|
|
158
|
+
return p;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function recomputeRoot(currentHash, proof, proofIdx, leafIndex, start, size) {
|
|
162
|
+
if (size === 1) {
|
|
163
|
+
if (proofIdx !== proof.length) throw new Error("inclusion proof has unused entries");
|
|
164
|
+
return currentHash;
|
|
165
|
+
}
|
|
166
|
+
if (proofIdx >= proof.length) {
|
|
167
|
+
throw new Error("inclusion proof too short for declared treeSize");
|
|
168
|
+
}
|
|
169
|
+
const split = largestPowerOf2LessThan(size);
|
|
170
|
+
if (leafIndex - start < split) {
|
|
171
|
+
const leftResult = await recomputeRoot(currentHash, proof, proofIdx + 1, leafIndex, start, split);
|
|
172
|
+
return hashPair(leftResult, proof[proofIdx]);
|
|
173
|
+
}
|
|
174
|
+
const rightResult = await recomputeRoot(currentHash, proof, proofIdx + 1, leafIndex, start + split, size - split);
|
|
175
|
+
return hashPair(proof[proofIdx], rightResult);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── core verifier ──
|
|
179
|
+
|
|
180
|
+
const MAX_PROOF_LENGTH = 64;
|
|
181
|
+
const MAX_RECEIPT_BYTES = 64 * 1024;
|
|
182
|
+
|
|
183
|
+
function checkCheckpointShape(cp) {
|
|
184
|
+
if (cp === null || typeof cp !== "object") return "laterCheckpoint must be an object";
|
|
185
|
+
if (!Number.isInteger(cp.treeSize) || cp.treeSize < 0) return "laterCheckpoint.treeSize must be a non-negative integer";
|
|
186
|
+
if (typeof cp.rootHash !== "string" || !/^[0-9a-f]{64}$/.test(cp.rootHash)) {
|
|
187
|
+
return "laterCheckpoint.rootHash must be 64 lowercase hex chars";
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function checkReceiptShape(r) {
|
|
193
|
+
if (r === null || typeof r !== "object") return "receipt is not an object";
|
|
194
|
+
if (typeof r.eventId !== "string" || r.eventId.length === 0) return "eventId missing";
|
|
195
|
+
if (!Number.isInteger(r.leafIndex) || r.leafIndex < 0) return "leafIndex must be non-negative integer";
|
|
196
|
+
if (!Number.isInteger(r.treeSize) || r.treeSize < 1) return "treeSize must be positive integer";
|
|
197
|
+
if (r.leafIndex >= r.treeSize) return "leafIndex must be < treeSize";
|
|
198
|
+
if (typeof r.rootHash !== "string" || !/^[0-9a-f]{64}$/.test(r.rootHash)) return "rootHash must be 64 lowercase hex chars";
|
|
199
|
+
if (!Array.isArray(r.inclusionProof)) return "inclusionProof must be an array";
|
|
200
|
+
if (r.inclusionProof.length > MAX_PROOF_LENGTH) return `inclusionProof exceeds max length of ${MAX_PROOF_LENGTH} entries`;
|
|
201
|
+
for (const p of r.inclusionProof) {
|
|
202
|
+
if (typeof p !== "string" || !/^[0-9a-f]{64}$/.test(p)) return "every inclusionProof entry must be 64 lowercase hex chars";
|
|
203
|
+
}
|
|
204
|
+
if (typeof r.timestamp !== "string" || r.timestamp.length === 0) return "timestamp missing";
|
|
205
|
+
if (typeof r.serviceSignature !== "string" || r.serviceSignature.length === 0) return "serviceSignature missing";
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function verifyReceipt(receipt, witnessPublicKey, eventHash, laterCheckpoint) {
|
|
210
|
+
const steps = [];
|
|
211
|
+
const structuralProblem = checkReceiptShape(receipt);
|
|
212
|
+
if (structuralProblem) {
|
|
213
|
+
steps.push({ name: "structure", pass: false, detail: structuralProblem });
|
|
214
|
+
return { valid: false, steps };
|
|
215
|
+
}
|
|
216
|
+
steps.push({ name: "structure", pass: true });
|
|
217
|
+
|
|
218
|
+
const signedPayload = {
|
|
219
|
+
eventId: receipt.eventId,
|
|
220
|
+
leafIndex: receipt.leafIndex,
|
|
221
|
+
treeSize: receipt.treeSize,
|
|
222
|
+
rootHash: receipt.rootHash,
|
|
223
|
+
timestamp: receipt.timestamp,
|
|
224
|
+
};
|
|
225
|
+
const sigBase = `ink/audit-inclusion/v1\n${canonicalize(signedPayload)}`;
|
|
226
|
+
let sigValid = false;
|
|
227
|
+
try {
|
|
228
|
+
const sig = base64urlDecode(receipt.serviceSignature);
|
|
229
|
+
sigValid = await ed.verifyAsync(sig, new TextEncoder().encode(sigBase), witnessPublicKey);
|
|
230
|
+
} catch (e) {
|
|
231
|
+
steps.push({ name: "signature", pass: false, detail: e instanceof Error ? e.message : "signature decode failed" });
|
|
232
|
+
return { valid: false, steps };
|
|
233
|
+
}
|
|
234
|
+
if (!sigValid) {
|
|
235
|
+
steps.push({ name: "signature", pass: false, detail: "Ed25519 verification failed" });
|
|
236
|
+
return { valid: false, steps };
|
|
237
|
+
}
|
|
238
|
+
steps.push({ name: "signature", pass: true });
|
|
239
|
+
|
|
240
|
+
if (eventHash !== undefined) {
|
|
241
|
+
if (!/^[0-9a-f]{64}$/.test(eventHash)) {
|
|
242
|
+
steps.push({ name: "proof", pass: false, detail: "eventHash must be 64 lowercase hex chars" });
|
|
243
|
+
return { valid: false, steps };
|
|
244
|
+
}
|
|
245
|
+
let computed;
|
|
246
|
+
try {
|
|
247
|
+
computed = await recomputeRoot(eventHash, receipt.inclusionProof, 0, receipt.leafIndex, 0, receipt.treeSize);
|
|
248
|
+
} catch (e) {
|
|
249
|
+
steps.push({ name: "proof", pass: false, detail: e instanceof Error ? e.message : "proof walk failed" });
|
|
250
|
+
return { valid: false, steps };
|
|
251
|
+
}
|
|
252
|
+
if (computed !== receipt.rootHash) {
|
|
253
|
+
steps.push({ name: "proof", pass: false, detail: "leaf-to-root walk did not reach claimed rootHash" });
|
|
254
|
+
return { valid: false, steps };
|
|
255
|
+
}
|
|
256
|
+
steps.push({ name: "proof", pass: true });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (laterCheckpoint !== undefined) {
|
|
260
|
+
const cpShape = checkCheckpointShape(laterCheckpoint);
|
|
261
|
+
if (cpShape) {
|
|
262
|
+
steps.push({ name: "checkpoint", pass: false, detail: cpShape });
|
|
263
|
+
return { valid: false, steps };
|
|
264
|
+
}
|
|
265
|
+
if (laterCheckpoint.treeSize < receipt.treeSize) {
|
|
266
|
+
steps.push({
|
|
267
|
+
name: "checkpoint",
|
|
268
|
+
pass: false,
|
|
269
|
+
detail: `checkpoint treeSize ${laterCheckpoint.treeSize} < receipt treeSize ${receipt.treeSize} (witness rewound the tree)`,
|
|
270
|
+
});
|
|
271
|
+
return { valid: false, steps };
|
|
272
|
+
}
|
|
273
|
+
if (laterCheckpoint.treeSize === receipt.treeSize && laterCheckpoint.rootHash !== receipt.rootHash) {
|
|
274
|
+
steps.push({
|
|
275
|
+
name: "checkpoint",
|
|
276
|
+
pass: false,
|
|
277
|
+
detail: "checkpoint rootHash differs from receipt rootHash at same treeSize (fork)",
|
|
278
|
+
});
|
|
279
|
+
return { valid: false, steps };
|
|
280
|
+
}
|
|
281
|
+
steps.push({ name: "checkpoint", pass: true });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return { valid: true, steps };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── witness HTTP helpers ──
|
|
288
|
+
|
|
289
|
+
/** Hard caps to prevent a malicious or compromised --witness URL
|
|
290
|
+
* from forcing unbounded memory growth via a streamed response. */
|
|
291
|
+
const MAX_RESPONSE_BYTES = 64 * 1024;
|
|
292
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Fetch with byte cap and abort timeout. Aborts the response stream
|
|
296
|
+
* mid-read if it exceeds the cap so we never allocate beyond it.
|
|
297
|
+
* Returns the decoded UTF-8 text.
|
|
298
|
+
*/
|
|
299
|
+
async function fetchBounded(url) {
|
|
300
|
+
const ctrl = new AbortController();
|
|
301
|
+
const timer = setTimeout(() => ctrl.abort(new Error("fetch timed out")), FETCH_TIMEOUT_MS);
|
|
302
|
+
try {
|
|
303
|
+
const res = await fetch(url, { signal: ctrl.signal });
|
|
304
|
+
if (!res.ok) throw new Error(`fetch failed (${res.status}): ${url}`);
|
|
305
|
+
if (!res.body) return "";
|
|
306
|
+
const reader = res.body.getReader();
|
|
307
|
+
const chunks = [];
|
|
308
|
+
let total = 0;
|
|
309
|
+
try {
|
|
310
|
+
while (true) {
|
|
311
|
+
const { value, done } = await reader.read();
|
|
312
|
+
if (done) break;
|
|
313
|
+
if (value) {
|
|
314
|
+
total += value.byteLength;
|
|
315
|
+
if (total > MAX_RESPONSE_BYTES) {
|
|
316
|
+
try { await reader.cancel(); } catch { /* ignore */ }
|
|
317
|
+
throw new Error(`response exceeds ${MAX_RESPONSE_BYTES} bytes`);
|
|
318
|
+
}
|
|
319
|
+
chunks.push(value);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
} finally {
|
|
323
|
+
try { reader.releaseLock(); } catch { /* ignore */ }
|
|
324
|
+
}
|
|
325
|
+
const merged = new Uint8Array(total);
|
|
326
|
+
let off = 0;
|
|
327
|
+
for (const c of chunks) { merged.set(c, off); off += c.byteLength; }
|
|
328
|
+
return new TextDecoder().decode(merged);
|
|
329
|
+
} finally {
|
|
330
|
+
clearTimeout(timer);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function fetchWitnessPublicKey(witnessUrl) {
|
|
335
|
+
const url = `${witnessUrl.replace(/\/$/, "")}/.well-known/did.json`;
|
|
336
|
+
const body = await fetchBounded(url);
|
|
337
|
+
let doc;
|
|
338
|
+
try { doc = JSON.parse(body); }
|
|
339
|
+
catch { throw new Error(`DID document is not valid JSON: ${url}`); }
|
|
340
|
+
const vm = doc?.verificationMethod?.[0]?.publicKeyMultibase;
|
|
341
|
+
if (typeof vm !== "string") throw new Error("DID document missing verificationMethod[0].publicKeyMultibase");
|
|
342
|
+
return decodePublicKeyMultibase(vm);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Parse a C2SP tlog-checkpoint response. The body has three header
|
|
347
|
+
* lines (origin, treeSize, rootHash), each terminated by \n, then a
|
|
348
|
+
* blank line, then a signature line. We don't verify the signature
|
|
349
|
+
* here (it'd require pre-fetching the witness key, which the caller
|
|
350
|
+
* already does for the receipt). Just extract the header fields with
|
|
351
|
+
* strict regexes so a malformed checkpoint can't fake-pass.
|
|
352
|
+
*/
|
|
353
|
+
function parseCheckpointBody(body) {
|
|
354
|
+
const sepIdx = body.indexOf("\n\n");
|
|
355
|
+
if (sepIdx < 0) return null;
|
|
356
|
+
const header = body.slice(0, sepIdx);
|
|
357
|
+
const lines = header.split("\n");
|
|
358
|
+
if (lines.length !== 3) return null;
|
|
359
|
+
if (!lines[0]) return null;
|
|
360
|
+
if (!/^\d+$/.test(lines[1])) return null;
|
|
361
|
+
const treeSize = parseInt(lines[1], 10);
|
|
362
|
+
if (!Number.isInteger(treeSize) || treeSize < 0 || treeSize > Number.MAX_SAFE_INTEGER) return null;
|
|
363
|
+
if (!/^[0-9a-f]{64}$/.test(lines[2])) return null;
|
|
364
|
+
return { treeSize, rootHash: lines[2] };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function fetchCurrentCheckpoint(witnessUrl) {
|
|
368
|
+
const url = `${witnessUrl.replace(/\/$/, "")}/ink/v1/checkpoint`;
|
|
369
|
+
let body;
|
|
370
|
+
try {
|
|
371
|
+
body = await fetchBounded(url);
|
|
372
|
+
} catch {
|
|
373
|
+
// Checkpoint cross-check is optional; downgrade fetch failures to
|
|
374
|
+
// 'not available' rather than crashing the verifier.
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
return parseCheckpointBody(body);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function readStdin() {
|
|
381
|
+
return new Promise((resolve, reject) => {
|
|
382
|
+
let data = "";
|
|
383
|
+
process.stdin.setEncoding("utf8");
|
|
384
|
+
process.stdin.on("data", (chunk) => {
|
|
385
|
+
data += chunk;
|
|
386
|
+
if (data.length > MAX_RECEIPT_BYTES) {
|
|
387
|
+
reject(new Error(`receipt input exceeds ${MAX_RECEIPT_BYTES} bytes`));
|
|
388
|
+
process.stdin.destroy();
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
process.stdin.on("end", () => resolve(data));
|
|
392
|
+
process.stdin.on("error", reject);
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ── main ──
|
|
397
|
+
|
|
398
|
+
async function main() {
|
|
399
|
+
const args = parseArgs(process.argv.slice(2));
|
|
400
|
+
if (args.help) {
|
|
401
|
+
printHelp();
|
|
402
|
+
process.exit(0);
|
|
403
|
+
}
|
|
404
|
+
if (!args.witness) {
|
|
405
|
+
console.error("Error: --witness <url> is required.");
|
|
406
|
+
printHelp();
|
|
407
|
+
process.exit(2);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
let witnessBase;
|
|
411
|
+
try {
|
|
412
|
+
witnessBase = validateWitnessUrl(args.witness, args.allowHttp);
|
|
413
|
+
} catch (e) {
|
|
414
|
+
console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
|
|
415
|
+
process.exit(2);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
let raw;
|
|
419
|
+
try {
|
|
420
|
+
if (args.file) {
|
|
421
|
+
// Stat first so a multi-GB file is rejected before allocation.
|
|
422
|
+
const st = statSync(args.file);
|
|
423
|
+
if (st.size > MAX_RECEIPT_BYTES) {
|
|
424
|
+
throw new Error(`receipt file exceeds ${MAX_RECEIPT_BYTES} bytes (${st.size} on disk)`);
|
|
425
|
+
}
|
|
426
|
+
raw = readFileSync(args.file, "utf8");
|
|
427
|
+
} else {
|
|
428
|
+
raw = await readStdin();
|
|
429
|
+
}
|
|
430
|
+
if (raw.length > MAX_RECEIPT_BYTES) {
|
|
431
|
+
throw new Error(`receipt exceeds ${MAX_RECEIPT_BYTES} bytes after decode`);
|
|
432
|
+
}
|
|
433
|
+
} catch (e) {
|
|
434
|
+
console.error(`Error reading receipt: ${e instanceof Error ? e.message : String(e)}`);
|
|
435
|
+
process.exit(2);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
let receipt;
|
|
439
|
+
try {
|
|
440
|
+
receipt = JSON.parse(raw);
|
|
441
|
+
} catch (e) {
|
|
442
|
+
console.error(`Error parsing receipt JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
443
|
+
process.exit(2);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
let witnessPublicKey;
|
|
447
|
+
try {
|
|
448
|
+
witnessPublicKey = await fetchWitnessPublicKey(witnessBase);
|
|
449
|
+
} catch (e) {
|
|
450
|
+
console.error(`Error fetching witness identity: ${e instanceof Error ? e.message : String(e)}`);
|
|
451
|
+
process.exit(2);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const laterCheckpoint = await fetchCurrentCheckpoint(witnessBase);
|
|
455
|
+
|
|
456
|
+
const result = await verifyReceipt(receipt, witnessPublicKey, args.eventHash, laterCheckpoint ?? undefined);
|
|
457
|
+
|
|
458
|
+
console.log(`Receipt: eventId=${receipt?.eventId} leafIndex=${receipt?.leafIndex} treeSize=${receipt?.treeSize}`);
|
|
459
|
+
console.log(`Witness: ${witnessBase}`);
|
|
460
|
+
if (laterCheckpoint) {
|
|
461
|
+
console.log(`Current checkpoint: treeSize=${laterCheckpoint.treeSize} rootHash=${laterCheckpoint.rootHash}`);
|
|
462
|
+
} else {
|
|
463
|
+
console.log("Current checkpoint: not available (skipping checkpoint cross-check)");
|
|
464
|
+
}
|
|
465
|
+
console.log("");
|
|
466
|
+
for (const step of result.steps) {
|
|
467
|
+
const mark = step.pass ? "PASS" : "FAIL";
|
|
468
|
+
console.log(` [${mark}] ${step.name}${step.detail ? ": " + step.detail : ""}`);
|
|
469
|
+
}
|
|
470
|
+
console.log("");
|
|
471
|
+
if (result.valid) {
|
|
472
|
+
console.log("RECEIPT VALID");
|
|
473
|
+
process.exit(0);
|
|
474
|
+
} else {
|
|
475
|
+
console.log("RECEIPT INVALID");
|
|
476
|
+
process.exit(1);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
main().catch((e) => {
|
|
481
|
+
console.error(`Unexpected error: ${e instanceof Error ? e.message : String(e)}`);
|
|
482
|
+
process.exit(2);
|
|
483
|
+
});
|