@adastracomputing/ink 0.3.0 → 0.5.0
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 +75 -0
- package/README.md +14 -1
- package/bin/verify-inclusion-impl.mjs +171 -23
- package/dist/audit/inclusion-receipt.d.ts +33 -8
- package/dist/audit/inclusion-receipt.js +125 -5
- package/dist/crypto/ink.js +12 -4
- package/dist/crypto/keys.d.ts +19 -0
- package/dist/crypto/keys.js +39 -0
- package/dist/crypto/sign.d.ts +21 -0
- package/dist/crypto/sign.js +26 -2
- package/dist/discovery/agent-card.d.ts +9 -7
- package/dist/index.d.ts +4 -4
- package/dist/index.js +6 -6
- package/dist/ink/checkpoint.d.ts +21 -0
- package/dist/ink/checkpoint.js +79 -0
- package/dist/ink/discovery-gating.js +4 -4
- package/dist/ink/receipts.d.ts +33 -1
- package/dist/ink/receipts.js +45 -1
- package/dist/middleware/ink-auth.d.ts +1 -0
- package/dist/middleware/ink-auth.js +7 -4
- package/dist/models/agent-card.js +22 -22
- package/dist/models/ink-audit.js +40 -36
- package/dist/models/ink-handshake.js +13 -13
- package/dist/models/intent.d.ts +2 -2
- package/dist/models/intent.js +9 -0
- package/docs/maturity.md +7 -1
- package/package.json +8 -7
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,81 @@ 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
|
|
|
7
|
+
## 0.5.0, transparency-log consistency proofs
|
|
8
|
+
|
|
9
|
+
This release adds RFC 6962 consistency-proof verification, so a verifier can
|
|
10
|
+
confirm a transparency log only ever appended to its tree between two
|
|
11
|
+
checkpoints rather than forking its history. The size comparison alone cannot
|
|
12
|
+
detect a split view. It is published on the `next` dist-tag; this release is
|
|
13
|
+
additive, with no breaking changes.
|
|
14
|
+
|
|
15
|
+
### Additions
|
|
16
|
+
|
|
17
|
+
- `verifyConsistencyProof(first, firstRoot, second, secondRoot, proof)` verifies
|
|
18
|
+
an RFC 6962 Section 2.1.2 consistency proof: that the tree of `first` leaves is
|
|
19
|
+
an append-only prefix of the tree of `second` leaves. It returns false (never
|
|
20
|
+
throws) for any malformed input or any proof that does not reconstruct both
|
|
21
|
+
roots with every node consumed.
|
|
22
|
+
- The `verify-inclusion` CLI uses it: when `--origin` enables the checkpoint
|
|
23
|
+
cross-check and the signature-verified checkpoint is newer than the receipt's
|
|
24
|
+
tree, the CLI fetches a consistency proof and verifies the receipt's tree is an
|
|
25
|
+
append-only prefix of the checkpoint. A witness that serves no proof has the
|
|
26
|
+
step reported as skipped, not passed.
|
|
27
|
+
|
|
28
|
+
The reference witness serves these proofs at `GET /ink/v1/consistency?first=N&second=M`.
|
|
29
|
+
|
|
30
|
+
## 0.4.0, stricter verification, message-size bounds, checkpoint and receipt verification
|
|
31
|
+
|
|
32
|
+
This release tightens signature verification and input validation and adds
|
|
33
|
+
several verification helpers. It is published on the `next` dist-tag.
|
|
34
|
+
|
|
35
|
+
### Potentially breaking validation tightenings
|
|
36
|
+
|
|
37
|
+
These reject inputs that `0.3.0` accepted. Legitimate signer and receiver
|
|
38
|
+
traffic is unaffected; the rejected inputs are malformed, malicious, or outside
|
|
39
|
+
the documented profile.
|
|
40
|
+
|
|
41
|
+
- Ed25519 signatures are now verified in strict RFC 8032 mode at every
|
|
42
|
+
verification site. Small-order public keys and non-canonical point encodings
|
|
43
|
+
are rejected.
|
|
44
|
+
- Signed JSON numbers are constrained to the forms every canonicalizer
|
|
45
|
+
serializes identically: non-finite values, negative zero, and values whose
|
|
46
|
+
shortest form uses exponential notation are rejected at signing and
|
|
47
|
+
verification.
|
|
48
|
+
- The agent card, audit, handshake, and discovery schemas now enforce maximum
|
|
49
|
+
field lengths and array sizes.
|
|
50
|
+
- The `Authorization: INK-Ed25519` header is matched against single literal
|
|
51
|
+
spaces; a tab, carriage return, or line feed in the separator is rejected.
|
|
52
|
+
|
|
53
|
+
### Additions
|
|
54
|
+
|
|
55
|
+
- `verifyCheckpoint(signed, witnessPublicKey, expectedOrigin)` verifies a signed
|
|
56
|
+
C2SP checkpoint: the witness Ed25519 signature over the checkpoint body and the
|
|
57
|
+
log origin. A checkpoint used for the inclusion-receipt cross-check must be
|
|
58
|
+
verified this way first.
|
|
59
|
+
- `verifyReceipt({ receipt, senderPublicKey, expected })` binds a delivery
|
|
60
|
+
receipt to the exact message it acknowledges: issuer key, `from`/`to`/
|
|
61
|
+
`messageId`, the recomputed message hash, and an optional `disposition`.
|
|
62
|
+
- `verifyInclusionReceipt` accepts an `event` option that recomputes the leaf
|
|
63
|
+
hash and binds it to `receipt.eventId`. The legacy `eventHash` is retained but
|
|
64
|
+
does not provide that binding.
|
|
65
|
+
- `verifyInkAuth` returns a prefix-independent `principal` alongside the raw
|
|
66
|
+
sender id; per-sender security state (blocks, rate limits) should key on
|
|
67
|
+
`principal`. `canonicalAgentPrincipal(agentId)` is exported for the same use.
|
|
68
|
+
|
|
69
|
+
Per the pre-1.0 policy this release publishes under the `next` dist-tag; `latest`
|
|
70
|
+
is unchanged.
|
|
71
|
+
|
|
72
|
+
## 0.3.0, accept the ink: agentId alias for key extraction
|
|
73
|
+
|
|
74
|
+
`extractPublicKeyFromAgentId` now accepts either the canonical `tulpa:` prefix or the `ink:` alias introduced in ink/0.4. Both carry the identical multibase Ed25519 key, so the bootstrap verification key is byte-identical and a signature made with that key verifies regardless of which accepted prefix carried it. The prefix is identity syntax, not signing authority.
|
|
75
|
+
|
|
76
|
+
Emission is unchanged: `deriveAgentId` still returns `tulpa:` (accept both, emit one). The new `AGENT_ID_KEY_PREFIXES` export is frozen so a consumer cannot widen the accepted set at runtime. The change is additive and backward compatible. Existing `tulpa:` inputs behave exactly as before, and every previously rejected prefix other than `ink:` is still rejected. The wire protocol version is unchanged.
|
|
77
|
+
|
|
78
|
+
A receiver that keys per-sender security state (blocks, rate limits, duplicate-payload checks, cached verification keys, connection identity) MUST collapse the two spellings to one prefix-independent principal so a sender cannot switch prefix to dodge a block or split a rate-limit window. See [Identity](https://ink.tulpa.network/spec/identity/).
|
|
79
|
+
|
|
80
|
+
Per the pre-1.0 policy this release publishes under the `next` dist-tag.
|
|
81
|
+
|
|
7
82
|
## 0.2.0, version-keyed body-signature domain
|
|
8
83
|
|
|
9
84
|
Version-keyed body-signature domain. The body message signature is now domain-separated by protocol version. ink/0.1 messages, and any object with no explicit ink/0.2 protocol, keep the legacy `tulpa/sign` domain so every signature produced to date still verifies. ink/0.2 messages are signed and verified under the neutral `ink/sign` domain. The verifier selects exactly one domain from the signed `protocol` field and never tries an alternate, so a signature made under one version's domain cannot be replayed under another.
|
package/README.md
CHANGED
|
@@ -98,6 +98,17 @@ For consumers of bilateral audit-exchange responses (`network.tulpa.audit_respon
|
|
|
98
98
|
|
|
99
99
|
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.
|
|
100
100
|
|
|
101
|
+
Verification helpers added in `0.4.0`:
|
|
102
|
+
|
|
103
|
+
- `verifyCheckpoint(signed, witnessPublicKey, expectedOrigin)` verifies a signed C2SP checkpoint's witness Ed25519 signature and binds its log origin, returning the parsed `{origin, treeSize, rootHash}` or `null`. Any checkpoint passed to `verifyInclusionReceipt`'s `laterCheckpoint` cross-check must be verified this way first; an unverified checkpoint body is attacker-controllable and provides no anti-rollback value.
|
|
104
|
+
- `verifyReceipt({receipt, senderPublicKey, expected})` verifies a delivery receipt against the message it acknowledges: the issuer's signature plus `from`/`to`/`messageId`, the recomputed message hash, and an optional `disposition`. It returns `{valid, reason?}`.
|
|
105
|
+
- `verifyInclusionReceipt` accepts an `event` option that recomputes the leaf hash and binds `event.id` to `receipt.eventId`, so the proof attests the named event's inclusion. Prefer it over the legacy unbound `eventHash`.
|
|
106
|
+
- `verifyInkAuth` returns a prefix-independent `principal` alongside the raw `senderAgentId`. Per-sender security state (block lists, rate limits) MUST key on `principal`, because the `tulpa:` and `ink:` spellings of one key are the same actor; `canonicalAgentPrincipal(agentId)` exposes the same mapping.
|
|
107
|
+
|
|
108
|
+
Added in `0.5.0`:
|
|
109
|
+
|
|
110
|
+
- `verifyConsistencyProof(first, firstRoot, second, secondRoot, proof)` verifies an RFC 6962 consistency proof that the tree of `first` leaves is an append-only prefix of the tree of `second` leaves, so a witness that forks its history rather than only appending is detected. The witness serves these proofs at `GET /ink/v1/consistency?first=N&second=M`, and the `verify-inclusion` CLI checks one against the current checkpoint when `--origin` is passed.
|
|
111
|
+
|
|
101
112
|
## Agent-assisted implementation
|
|
102
113
|
|
|
103
114
|
If you are asking an AI coding agent to add INK support to an existing service, the canonical packet for that workflow is the [Agent-assisted implementation](https://ink.tulpa.network/guides/agent-assisted-implementation/) guide. It contains the curated implementer prompt, a mandatory traceability matrix, the conformance checklist, and a human-review checklist. The guide is updated as the protocol evolves; treat it as the live source rather than copying its contents into your repo.
|
|
@@ -152,9 +163,11 @@ Subject to change before v1.0:
|
|
|
152
163
|
|
|
153
164
|
You will see `network.tulpa.*` on the wire (e.g. `network.tulpa.intent`) and `ink.tulpa.network` for the docs site. Both are historical artifacts of the protocol's origin and do not imply a runtime dependency on Tulpa. A vendor-neutral namespace may be introduced in a future revision.
|
|
154
165
|
|
|
166
|
+
As a first, non-breaking step in that direction, agentIds may use either the canonical `tulpa:` method prefix or the `ink:` alias; both encode the same Ed25519 key and denote the same actor. `deriveAgentId` still emits `tulpa:`, and `extractPublicKeyFromAgentId` accepts both (accept-both, emit-one). A receiver MUST collapse the two spellings to one prefix-independent principal for all per-sender security state (blocks, rate limits, duplicate-payload checks, cached keys, connection identity).
|
|
167
|
+
|
|
155
168
|
## Relationship to Tulpa
|
|
156
169
|
|
|
157
|
-
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
|
|
170
|
+
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 codebase.
|
|
158
171
|
|
|
159
172
|
## Interoperability
|
|
160
173
|
|
|
@@ -38,6 +38,7 @@ function parseArgs(argv) {
|
|
|
38
38
|
const a = argv[i];
|
|
39
39
|
if (a === "--file" || a === "-f") out.file = argv[++i];
|
|
40
40
|
else if (a === "--witness" || a === "-w") out.witness = argv[++i];
|
|
41
|
+
else if (a === "--origin") out.origin = argv[++i];
|
|
41
42
|
else if (a === "--event-hash" || a === "-e") out.eventHash = argv[++i];
|
|
42
43
|
else if (a === "--allow-http") out.allowHttp = true;
|
|
43
44
|
else if (a === "--help" || a === "-h") out.help = true;
|
|
@@ -75,6 +76,17 @@ Usage:
|
|
|
75
76
|
|
|
76
77
|
Options:
|
|
77
78
|
-w, --witness <url> Witness base URL (e.g. https://witness.tulpa.network)
|
|
79
|
+
--origin <name> Optional. Expected checkpoint origin (log identity)
|
|
80
|
+
to bind the signed checkpoint to. Enables the
|
|
81
|
+
checkpoint cross-check: the current checkpoint's
|
|
82
|
+
signature is verified against the witness key, and
|
|
83
|
+
when it is newer than the receipt and the witness
|
|
84
|
+
serves an RFC 6962 consistency proof, that proof is
|
|
85
|
+
verified so a forked history is caught. If the witness
|
|
86
|
+
serves no proof the consistency step is reported as
|
|
87
|
+
skipped, not passed. When --origin is omitted the
|
|
88
|
+
cross-check is skipped rather than trusting an
|
|
89
|
+
unverified checkpoint body.
|
|
78
90
|
-f, --file <path> Receipt JSON file. Omit to read from stdin.
|
|
79
91
|
-e, --event-hash <hex> Optional. RFC 6962 leaf hash for the audit event:
|
|
80
92
|
SHA-256(0x00 || JCS(event-without-agentSignature)),
|
|
@@ -175,6 +187,47 @@ async function recomputeRoot(currentHash, proof, proofIdx, leafIndex, start, siz
|
|
|
175
187
|
return hashPair(proof[proofIdx], rightResult);
|
|
176
188
|
}
|
|
177
189
|
|
|
190
|
+
const EMPTY_TREE_ROOT = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Verify an RFC 6962 Section 2.1.2 consistency proof (the first-sized tree is a
|
|
194
|
+
* prefix of the second). Mirrors verifyConsistencyProof() in
|
|
195
|
+
* src/audit/inclusion-receipt.ts — keep them in sync.
|
|
196
|
+
*/
|
|
197
|
+
async function verifyConsistencyProofCli(first, firstRoot, second, secondRoot, proof) {
|
|
198
|
+
const isHash = (s) => typeof s === "string" && /^[0-9a-f]{64}$/.test(s);
|
|
199
|
+
if (!Number.isSafeInteger(first) || !Number.isSafeInteger(second)) return false;
|
|
200
|
+
if (first < 0 || second < 0) return false;
|
|
201
|
+
if (!isHash(firstRoot) || !isHash(secondRoot)) return false;
|
|
202
|
+
if (!Array.isArray(proof) || !proof.every(isHash) || proof.length > 64) return false;
|
|
203
|
+
if (first > second) return false;
|
|
204
|
+
if (first === second) return proof.length === 0 && firstRoot === secondRoot;
|
|
205
|
+
if (first === 0) return proof.length === 0 && firstRoot === EMPTY_TREE_ROOT;
|
|
206
|
+
|
|
207
|
+
let node = first - 1;
|
|
208
|
+
let last = second - 1;
|
|
209
|
+
while (node % 2 === 1) { node = Math.floor(node / 2); last = Math.floor(last / 2); }
|
|
210
|
+
let i = 0;
|
|
211
|
+
const take = () => (i < proof.length ? proof[i++] : null);
|
|
212
|
+
let oldHash;
|
|
213
|
+
if (node > 0) { const h = take(); if (h === null) return false; oldHash = h; } else { oldHash = firstRoot; }
|
|
214
|
+
let newHash = oldHash;
|
|
215
|
+
while (node > 0) {
|
|
216
|
+
if (node % 2 === 1) {
|
|
217
|
+
const h = take(); if (h === null) return false;
|
|
218
|
+
oldHash = await hashPair(h, oldHash);
|
|
219
|
+
newHash = await hashPair(h, newHash);
|
|
220
|
+
} else if (node < last) {
|
|
221
|
+
const h = take(); if (h === null) return false;
|
|
222
|
+
newHash = await hashPair(newHash, h);
|
|
223
|
+
}
|
|
224
|
+
node = Math.floor(node / 2); last = Math.floor(last / 2);
|
|
225
|
+
}
|
|
226
|
+
while (last > 0) { const h = take(); if (h === null) return false; newHash = await hashPair(newHash, h); last = Math.floor(last / 2); }
|
|
227
|
+
if (i !== proof.length) return false;
|
|
228
|
+
return oldHash === firstRoot && newHash === secondRoot;
|
|
229
|
+
}
|
|
230
|
+
|
|
178
231
|
// ── core verifier ──
|
|
179
232
|
|
|
180
233
|
const MAX_PROOF_LENGTH = 64;
|
|
@@ -226,7 +279,8 @@ async function verifyReceipt(receipt, witnessPublicKey, eventHash, laterCheckpoi
|
|
|
226
279
|
let sigValid = false;
|
|
227
280
|
try {
|
|
228
281
|
const sig = base64urlDecode(receipt.serviceSignature);
|
|
229
|
-
|
|
282
|
+
// RFC 8032 strict verification, matching the library (reject small-order keys).
|
|
283
|
+
sigValid = await ed.verifyAsync(sig, new TextEncoder().encode(sigBase), witnessPublicKey, { zip215: false });
|
|
230
284
|
} catch (e) {
|
|
231
285
|
steps.push({ name: "signature", pass: false, detail: e instanceof Error ? e.message : "signature decode failed" });
|
|
232
286
|
return { valid: false, steps };
|
|
@@ -343,28 +397,59 @@ async function fetchWitnessPublicKey(witnessUrl) {
|
|
|
343
397
|
}
|
|
344
398
|
|
|
345
399
|
/**
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
400
|
+
* Verify a signed C2SP tlog-checkpoint and return { treeSize, rootHash,
|
|
401
|
+
* origin }, or null if the signature, origin, or format is invalid. The
|
|
402
|
+
* Ed25519 signature covers the body bytes `<origin>\n<treeSize>\n<rootHash>`
|
|
403
|
+
* (no trailing newline). The anti-rollback cross-check below only means
|
|
404
|
+
* anything against a checkpoint whose signature we have verified against the
|
|
405
|
+
* witness key, so this MUST verify, not just parse.
|
|
406
|
+
*
|
|
407
|
+
* Mirrors verifyCheckpoint() in src/ink/checkpoint.ts — keep them in sync.
|
|
352
408
|
*/
|
|
353
|
-
function
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
const
|
|
357
|
-
|
|
409
|
+
async function verifyCheckpointBody(signed, witnessPublicKey, expectedOrigin) {
|
|
410
|
+
if (typeof signed !== "string" || signed.length === 0 || signed.length > 4096) return null;
|
|
411
|
+
const SEP = "\n\n-- ";
|
|
412
|
+
const idx = signed.indexOf(SEP);
|
|
413
|
+
if (idx < 0) return null;
|
|
414
|
+
const body = signed.slice(0, idx);
|
|
415
|
+
const lines = body.split("\n");
|
|
358
416
|
if (lines.length !== 3) return null;
|
|
359
|
-
|
|
360
|
-
if (
|
|
361
|
-
|
|
417
|
+
const [origin, sizeLine, rootHash] = lines;
|
|
418
|
+
if (!origin || origin.length > 256) return null;
|
|
419
|
+
if (!/^\d+$/.test(sizeLine)) return null;
|
|
420
|
+
const treeSize = parseInt(sizeLine, 10);
|
|
362
421
|
if (!Number.isInteger(treeSize) || treeSize < 0 || treeSize > Number.MAX_SAFE_INTEGER) return null;
|
|
363
|
-
if (!/^[0-9a-f]{64}$/.test(
|
|
364
|
-
|
|
422
|
+
if (!/^[0-9a-f]{64}$/.test(rootHash)) return null;
|
|
423
|
+
// The expected origin must be supplied by the caller (a trusted value), not
|
|
424
|
+
// taken from the checkpoint body, so a witness key that signs several origins
|
|
425
|
+
// cannot substitute a checkpoint for a different log than the receipt's.
|
|
426
|
+
if (typeof expectedOrigin !== "string" || expectedOrigin.length === 0) return null;
|
|
427
|
+
if (origin !== expectedOrigin) return null;
|
|
428
|
+
const sigLines = signed.slice(idx + 2).split("\n").filter((l) => l.length > 0);
|
|
429
|
+
if (sigLines.length === 0 || sigLines.length > 8) return null;
|
|
430
|
+
const bodyBytes = new TextEncoder().encode(body);
|
|
431
|
+
for (const line of sigLines) {
|
|
432
|
+
if (!line.startsWith("-- ")) return null;
|
|
433
|
+
const rest = line.slice(3);
|
|
434
|
+
const sp = rest.indexOf(" ");
|
|
435
|
+
if (sp < 0) return null;
|
|
436
|
+
if (rest.slice(0, sp) !== expectedOrigin) continue;
|
|
437
|
+
try {
|
|
438
|
+
const sig = base64urlDecode(rest.slice(sp + 1));
|
|
439
|
+
if (sig.length !== 64) return null;
|
|
440
|
+
const ok = await ed.verifyAsync(sig, bodyBytes, witnessPublicKey, { zip215: false });
|
|
441
|
+
return ok ? { treeSize, rootHash, origin } : null;
|
|
442
|
+
} catch {
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return null;
|
|
365
447
|
}
|
|
366
448
|
|
|
367
|
-
async function fetchCurrentCheckpoint(witnessUrl) {
|
|
449
|
+
async function fetchCurrentCheckpoint(witnessUrl, witnessPublicKey, expectedOrigin) {
|
|
450
|
+
// No trusted origin, no cross-check: refuse to trust the checkpoint body's
|
|
451
|
+
// self-asserted origin. Pass --origin <witness-origin> to enable it.
|
|
452
|
+
if (typeof expectedOrigin !== "string" || expectedOrigin.length === 0) return null;
|
|
368
453
|
const url = `${witnessUrl.replace(/\/$/, "")}/ink/v1/checkpoint`;
|
|
369
454
|
let body;
|
|
370
455
|
try {
|
|
@@ -374,7 +459,27 @@ async function fetchCurrentCheckpoint(witnessUrl) {
|
|
|
374
459
|
// 'not available' rather than crashing the verifier.
|
|
375
460
|
return null;
|
|
376
461
|
}
|
|
377
|
-
return
|
|
462
|
+
return verifyCheckpointBody(body, witnessPublicKey, expectedOrigin);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function fetchConsistencyProof(witnessUrl, first, second) {
|
|
466
|
+
const url = `${witnessUrl.replace(/\/$/, "")}/ink/v1/consistency?first=${first}&second=${second}`;
|
|
467
|
+
let body;
|
|
468
|
+
try {
|
|
469
|
+
body = await fetchBounded(url);
|
|
470
|
+
} catch {
|
|
471
|
+
// A witness that does not serve consistency proofs downgrades the check to
|
|
472
|
+
// 'not available' rather than failing the receipt.
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
let parsed;
|
|
476
|
+
try {
|
|
477
|
+
parsed = JSON.parse(body);
|
|
478
|
+
} catch {
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
if (!parsed || !Array.isArray(parsed.proof)) return null;
|
|
482
|
+
return parsed.proof;
|
|
378
483
|
}
|
|
379
484
|
|
|
380
485
|
async function readStdin() {
|
|
@@ -451,20 +556,63 @@ async function main() {
|
|
|
451
556
|
process.exit(2);
|
|
452
557
|
}
|
|
453
558
|
|
|
454
|
-
|
|
559
|
+
// The checkpoint cross-check only carries weight against a checkpoint whose
|
|
560
|
+
// Ed25519 signature we have verified against the witness key. An unverified
|
|
561
|
+
// checkpoint (bad/absent signature, or origin mismatch) is dropped so the
|
|
562
|
+
// cross-check is skipped rather than trusting attacker-controlled values.
|
|
563
|
+
const laterCheckpoint = await fetchCurrentCheckpoint(witnessBase, witnessPublicKey, args.origin);
|
|
455
564
|
|
|
456
565
|
const result = await verifyReceipt(receipt, witnessPublicKey, args.eventHash, laterCheckpoint ?? undefined);
|
|
457
566
|
|
|
567
|
+
// Append-only proof: when the signature-verified checkpoint is strictly newer
|
|
568
|
+
// than the receipt's tree, fetch and verify an RFC 6962 consistency proof, so
|
|
569
|
+
// a witness that forked its history between the two snapshots is caught. The
|
|
570
|
+
// checkpoint size comparison alone cannot detect a same-prefix fork.
|
|
571
|
+
if (laterCheckpoint && Number.isInteger(receipt?.treeSize) && laterCheckpoint.treeSize > receipt.treeSize) {
|
|
572
|
+
const proof = await fetchConsistencyProof(witnessBase, receipt.treeSize, laterCheckpoint.treeSize);
|
|
573
|
+
if (proof === null) {
|
|
574
|
+
// Honest downgrade: the witness did not serve a usable consistency proof.
|
|
575
|
+
// Mark it skipped (NOT passed) so a witness that forked cannot look clean
|
|
576
|
+
// by withholding the endpoint; the checkpoint signature, rewind, and
|
|
577
|
+
// same-size fork checks still ran.
|
|
578
|
+
result.steps.push({
|
|
579
|
+
name: "consistency",
|
|
580
|
+
skip: true,
|
|
581
|
+
detail: "not checked (witness did not serve a consistency proof); append-only growth was not verified",
|
|
582
|
+
});
|
|
583
|
+
} else {
|
|
584
|
+
const consistent = await verifyConsistencyProofCli(
|
|
585
|
+
receipt.treeSize, receipt.rootHash, laterCheckpoint.treeSize, laterCheckpoint.rootHash, proof,
|
|
586
|
+
);
|
|
587
|
+
if (consistent) {
|
|
588
|
+
result.steps.push({
|
|
589
|
+
name: "consistency",
|
|
590
|
+
pass: true,
|
|
591
|
+
detail: `receipt tree (size ${receipt.treeSize}) is an append-only prefix of the checkpoint (size ${laterCheckpoint.treeSize})`,
|
|
592
|
+
});
|
|
593
|
+
} else {
|
|
594
|
+
result.steps.push({
|
|
595
|
+
name: "consistency",
|
|
596
|
+
pass: false,
|
|
597
|
+
detail: `receipt tree (size ${receipt.treeSize}) is NOT an append-only prefix of the checkpoint (size ${laterCheckpoint.treeSize}); the witness forked its history`,
|
|
598
|
+
});
|
|
599
|
+
result.valid = false;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
458
604
|
console.log(`Receipt: eventId=${receipt?.eventId} leafIndex=${receipt?.leafIndex} treeSize=${receipt?.treeSize}`);
|
|
459
605
|
console.log(`Witness: ${witnessBase}`);
|
|
460
606
|
if (laterCheckpoint) {
|
|
461
|
-
console.log(`Current checkpoint: treeSize=${laterCheckpoint.treeSize} rootHash=${laterCheckpoint.rootHash}`);
|
|
607
|
+
console.log(`Current checkpoint (signature verified): treeSize=${laterCheckpoint.treeSize} rootHash=${laterCheckpoint.rootHash}`);
|
|
608
|
+
} else if (!args.origin) {
|
|
609
|
+
console.log("Current checkpoint: cross-check skipped (pass --origin <witness-origin> to enable the anti-rollback check)");
|
|
462
610
|
} else {
|
|
463
|
-
console.log("Current checkpoint: not available (skipping checkpoint cross-check)");
|
|
611
|
+
console.log("Current checkpoint: not available or signature unverified (skipping checkpoint cross-check)");
|
|
464
612
|
}
|
|
465
613
|
console.log("");
|
|
466
614
|
for (const step of result.steps) {
|
|
467
|
-
const mark = step.pass ? "PASS" : "FAIL";
|
|
615
|
+
const mark = step.skip ? "SKIP" : step.pass ? "PASS" : "FAIL";
|
|
468
616
|
console.log(` [${mark}] ${step.name}${step.detail ? ": " + step.detail : ""}`);
|
|
469
617
|
}
|
|
470
618
|
console.log("");
|
|
@@ -26,21 +26,31 @@ export interface InclusionReceiptVerifyResult {
|
|
|
26
26
|
* - Service signature verification against `witnessPublicKey`
|
|
27
27
|
*
|
|
28
28
|
* Optionally performs (when the corresponding input is provided):
|
|
29
|
-
* - Leaf-to-root proof walk
|
|
29
|
+
* - Leaf-to-root proof walk: pass `event` (recommended — recomputes the leaf
|
|
30
|
+
* hash and binds it to `receipt.eventId`) or `eventHash` (legacy, unbound)
|
|
30
31
|
* - Cross-check against a later signed checkpoint (`laterCheckpoint`)
|
|
31
32
|
*/
|
|
32
33
|
export declare function verifyInclusionReceipt(opts: {
|
|
33
34
|
receipt: InclusionReceipt;
|
|
34
35
|
/** Raw 32-byte Ed25519 public key of the witness service. */
|
|
35
36
|
witnessPublicKey: Uint8Array;
|
|
36
|
-
/** Optional
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
37
|
+
/** Optional audit event the receipt claims inclusion for. This is the
|
|
38
|
+
* RECOMMENDED way to verify the proof: the leaf hash is recomputed from the
|
|
39
|
+
* event with `computeAuditMerkleLeafHash`, and `event.id` is bound to
|
|
40
|
+
* `receipt.eventId`, so the proof attests that the event named by the
|
|
41
|
+
* receipt is in the tree — not merely that some caller-chosen hash is. */
|
|
42
|
+
event?: Record<string, unknown>;
|
|
43
|
+
/** Optional pre-computed RFC 6962 leaf hash (hex). LEGACY / lower-assurance:
|
|
44
|
+
* unlike `event`, a bare hash is NOT bound to `receipt.eventId`, so the proof
|
|
45
|
+
* only attests "this hash is in the tree", not "the event the receipt names
|
|
46
|
+
* is in the tree". Prefer `event`. Ignored when `event` is provided. */
|
|
40
47
|
eventHash?: string;
|
|
41
|
-
/** Optional later checkpoint to cross-check the receipt against.
|
|
42
|
-
*
|
|
43
|
-
* has
|
|
48
|
+
/** Optional later checkpoint to cross-check the receipt against. This MUST be
|
|
49
|
+
* the parsed body of a checkpoint whose Ed25519 signature and origin the
|
|
50
|
+
* caller has already verified with `verifyCheckpoint` against the witness
|
|
51
|
+
* key. Passing an unverified (merely parsed) checkpoint gives the
|
|
52
|
+
* anti-rollback / fork cross-check no security, because the treeSize and
|
|
53
|
+
* rootHash would then be attacker-controllable. */
|
|
44
54
|
laterCheckpoint?: {
|
|
45
55
|
treeSize: number;
|
|
46
56
|
rootHash: string;
|
|
@@ -140,3 +150,18 @@ export declare function verifyAuditQueryResponse(opts: {
|
|
|
140
150
|
* audit), they MUST explicitly pass a callback that does so. */
|
|
141
151
|
verifyEventSignature: (event: Record<string, unknown>) => Promise<boolean>;
|
|
142
152
|
}): Promise<AuditQueryResponseVerifyResult>;
|
|
153
|
+
/**
|
|
154
|
+
* Verify an RFC 6962 Section 2.1.2 consistency proof: that the Merkle tree of
|
|
155
|
+
* `first` leaves with root `firstRoot` is a prefix of the tree of `second`
|
|
156
|
+
* leaves with root `secondRoot`. A valid proof is proof the log only ever
|
|
157
|
+
* appended; it is what detects a witness that forks its history (split view)
|
|
158
|
+
* rather than merely growing, which the `second >= first` size comparison
|
|
159
|
+
* alone cannot.
|
|
160
|
+
*
|
|
161
|
+
* `firstRoot`, `secondRoot` and every proof entry are 64 lowercase hex chars
|
|
162
|
+
* (SHA-256). Internal nodes are hashed as SHA-256(0x01 || left || right), the
|
|
163
|
+
* same construction the inclusion verifier uses, so the two agree on tree
|
|
164
|
+
* shape. Returns false (never throws) for any malformed input or any proof that
|
|
165
|
+
* does not reconstruct both roots with every element consumed.
|
|
166
|
+
*/
|
|
167
|
+
export declare function verifyConsistencyProof(first: number, firstRoot: string, second: number, secondRoot: string, proof: string[]): Promise<boolean>;
|
|
@@ -30,12 +30,13 @@ import { base64urlDecode, jcsCanonicalize, hexToBytes, bytesToHex, computeAuditM
|
|
|
30
30
|
* - Service signature verification against `witnessPublicKey`
|
|
31
31
|
*
|
|
32
32
|
* Optionally performs (when the corresponding input is provided):
|
|
33
|
-
* - Leaf-to-root proof walk
|
|
33
|
+
* - Leaf-to-root proof walk: pass `event` (recommended — recomputes the leaf
|
|
34
|
+
* hash and binds it to `receipt.eventId`) or `eventHash` (legacy, unbound)
|
|
34
35
|
* - Cross-check against a later signed checkpoint (`laterCheckpoint`)
|
|
35
36
|
*/
|
|
36
37
|
export async function verifyInclusionReceipt(opts) {
|
|
37
38
|
const steps = [];
|
|
38
|
-
const { receipt, witnessPublicKey, eventHash, laterCheckpoint } = opts;
|
|
39
|
+
const { receipt, witnessPublicKey, event, eventHash, laterCheckpoint } = opts;
|
|
39
40
|
// ── Step 1: structural validation ──
|
|
40
41
|
const structuralProblem = checkReceiptShape(receipt);
|
|
41
42
|
if (structuralProblem) {
|
|
@@ -55,7 +56,7 @@ export async function verifyInclusionReceipt(opts) {
|
|
|
55
56
|
let sigValid = false;
|
|
56
57
|
try {
|
|
57
58
|
const sig = base64urlDecode(receipt.serviceSignature);
|
|
58
|
-
sigValid = await ed.verifyAsync(sig, new TextEncoder().encode(sigBase), witnessPublicKey);
|
|
59
|
+
sigValid = await ed.verifyAsync(sig, new TextEncoder().encode(sigBase), witnessPublicKey, { zip215: false });
|
|
59
60
|
}
|
|
60
61
|
catch (e) {
|
|
61
62
|
steps.push({
|
|
@@ -71,12 +72,35 @@ export async function verifyInclusionReceipt(opts) {
|
|
|
71
72
|
}
|
|
72
73
|
steps.push({ name: "signature", pass: true });
|
|
73
74
|
// ── Step 3: inclusion-proof walk (optional) ──
|
|
74
|
-
|
|
75
|
+
// Prefer the `event` path: recompute the leaf hash from the event and bind it
|
|
76
|
+
// to receipt.eventId, so the proof attests the named event's inclusion.
|
|
77
|
+
let leafHash;
|
|
78
|
+
if (event !== undefined) {
|
|
79
|
+
if (typeof event.id !== "string") {
|
|
80
|
+
steps.push({ name: "proof", pass: false, detail: "event.id is missing or not a string" });
|
|
81
|
+
return { valid: false, steps };
|
|
82
|
+
}
|
|
83
|
+
if (event.id !== receipt.eventId) {
|
|
84
|
+
steps.push({ name: "proof", pass: false, detail: "event.id does not match receipt.eventId" });
|
|
85
|
+
return { valid: false, steps };
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
leafHash = await computeAuditMerkleLeafHash(event);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
steps.push({ name: "proof", pass: false, detail: "could not compute leaf hash from event" });
|
|
92
|
+
return { valid: false, steps };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
else if (eventHash !== undefined) {
|
|
75
96
|
if (!/^[0-9a-f]{64}$/.test(eventHash)) {
|
|
76
97
|
steps.push({ name: "proof", pass: false, detail: "eventHash must be 64 lowercase hex chars" });
|
|
77
98
|
return { valid: false, steps };
|
|
78
99
|
}
|
|
79
|
-
|
|
100
|
+
leafHash = eventHash;
|
|
101
|
+
}
|
|
102
|
+
if (leafHash !== undefined) {
|
|
103
|
+
const verified = await verifyInclusionProof(leafHash, receipt.inclusionProof, receipt.leafIndex, receipt.treeSize, receipt.rootHash);
|
|
80
104
|
if (!verified) {
|
|
81
105
|
steps.push({ name: "proof", pass: false, detail: "leaf-to-root walk did not reach claimed rootHash" });
|
|
82
106
|
return { valid: false, steps };
|
|
@@ -494,3 +518,99 @@ async function verifyInclusionProof(leafHash, proof, leafIndex, treeSize, expect
|
|
|
494
518
|
return false;
|
|
495
519
|
}
|
|
496
520
|
}
|
|
521
|
+
function isMerkleHashHex(s) {
|
|
522
|
+
return typeof s === "string" && /^[0-9a-f]{64}$/.test(s);
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Verify an RFC 6962 Section 2.1.2 consistency proof: that the Merkle tree of
|
|
526
|
+
* `first` leaves with root `firstRoot` is a prefix of the tree of `second`
|
|
527
|
+
* leaves with root `secondRoot`. A valid proof is proof the log only ever
|
|
528
|
+
* appended; it is what detects a witness that forks its history (split view)
|
|
529
|
+
* rather than merely growing, which the `second >= first` size comparison
|
|
530
|
+
* alone cannot.
|
|
531
|
+
*
|
|
532
|
+
* `firstRoot`, `secondRoot` and every proof entry are 64 lowercase hex chars
|
|
533
|
+
* (SHA-256). Internal nodes are hashed as SHA-256(0x01 || left || right), the
|
|
534
|
+
* same construction the inclusion verifier uses, so the two agree on tree
|
|
535
|
+
* shape. Returns false (never throws) for any malformed input or any proof that
|
|
536
|
+
* does not reconstruct both roots with every element consumed.
|
|
537
|
+
*/
|
|
538
|
+
export async function verifyConsistencyProof(first, firstRoot, second, secondRoot, proof) {
|
|
539
|
+
if (!Number.isSafeInteger(first) || !Number.isSafeInteger(second))
|
|
540
|
+
return false;
|
|
541
|
+
if (first < 0 || second < 0)
|
|
542
|
+
return false;
|
|
543
|
+
if (!isMerkleHashHex(firstRoot) || !isMerkleHashHex(secondRoot))
|
|
544
|
+
return false;
|
|
545
|
+
if (!Array.isArray(proof) || !proof.every(isMerkleHashHex))
|
|
546
|
+
return false;
|
|
547
|
+
// A consistency proof for a tree of `second` safe-integer leaves has at most
|
|
548
|
+
// ceil(log2(second)) + 1 nodes, comfortably under 64. Cap it so a hostile
|
|
549
|
+
// caller cannot force unbounded hashing work.
|
|
550
|
+
if (proof.length > 64)
|
|
551
|
+
return false;
|
|
552
|
+
if (first > second)
|
|
553
|
+
return false;
|
|
554
|
+
// Same size: the roots must match and there is nothing to prove.
|
|
555
|
+
if (first === second)
|
|
556
|
+
return proof.length === 0 && firstRoot === secondRoot;
|
|
557
|
+
// The empty tree is a prefix of every tree; its root is fixed and the proof
|
|
558
|
+
// carries no nodes.
|
|
559
|
+
if (first === 0)
|
|
560
|
+
return proof.length === 0 && firstRoot === EMPTY_TREE_ROOT;
|
|
561
|
+
// 0 < first < second. Walk the path from leaf `first - 1` up to the roots,
|
|
562
|
+
// shifting `node`/`last` together. `node` indexes the first tree's rightmost
|
|
563
|
+
// leaf, `last` the second tree's.
|
|
564
|
+
let node = first - 1;
|
|
565
|
+
let last = second - 1;
|
|
566
|
+
while (node % 2 === 1) {
|
|
567
|
+
node = Math.floor(node / 2);
|
|
568
|
+
last = Math.floor(last / 2);
|
|
569
|
+
}
|
|
570
|
+
let i = 0;
|
|
571
|
+
const take = () => (i < proof.length ? proof[i++] : null);
|
|
572
|
+
// When `first` is an exact power of two, `node` has shifted to 0 and the old
|
|
573
|
+
// subtree hash is `firstRoot` itself; otherwise it is the first proof node.
|
|
574
|
+
let oldHash;
|
|
575
|
+
if (node > 0) {
|
|
576
|
+
const h = take();
|
|
577
|
+
if (h === null)
|
|
578
|
+
return false;
|
|
579
|
+
oldHash = h;
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
oldHash = firstRoot;
|
|
583
|
+
}
|
|
584
|
+
let newHash = oldHash;
|
|
585
|
+
while (node > 0) {
|
|
586
|
+
if (node % 2 === 1) {
|
|
587
|
+
// Right child: the sibling on the left is shared by both trees.
|
|
588
|
+
const h = take();
|
|
589
|
+
if (h === null)
|
|
590
|
+
return false;
|
|
591
|
+
oldHash = await hashPair(h, oldHash);
|
|
592
|
+
newHash = await hashPair(h, newHash);
|
|
593
|
+
}
|
|
594
|
+
else if (node < last) {
|
|
595
|
+
// Left child with a right sibling that exists only in the second tree.
|
|
596
|
+
const h = take();
|
|
597
|
+
if (h === null)
|
|
598
|
+
return false;
|
|
599
|
+
newHash = await hashPair(newHash, h);
|
|
600
|
+
}
|
|
601
|
+
node = Math.floor(node / 2);
|
|
602
|
+
last = Math.floor(last / 2);
|
|
603
|
+
}
|
|
604
|
+
// Remaining nodes extend only the second tree to its root.
|
|
605
|
+
while (last > 0) {
|
|
606
|
+
const h = take();
|
|
607
|
+
if (h === null)
|
|
608
|
+
return false;
|
|
609
|
+
newHash = await hashPair(newHash, h);
|
|
610
|
+
last = Math.floor(last / 2);
|
|
611
|
+
}
|
|
612
|
+
// Every proof element must be consumed, and both reconstructions must match.
|
|
613
|
+
if (i !== proof.length)
|
|
614
|
+
return false;
|
|
615
|
+
return oldHash === firstRoot && newHash === secondRoot;
|
|
616
|
+
}
|
package/dist/crypto/ink.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as ed from "@noble/ed25519";
|
|
2
2
|
import { x25519 } from "@noble/curves/ed25519.js";
|
|
3
3
|
import canonicalize from "canonicalize";
|
|
4
|
+
import { isJcsSafeNumber } from "./sign.js";
|
|
4
5
|
// ── Encoding helpers ──
|
|
5
6
|
const MAX_ENCODE_INPUT_BYTES = 2_000_000;
|
|
6
7
|
function base64urlEncode(bytes) {
|
|
@@ -137,6 +138,11 @@ function isWithinCanonicalizeBounds(value) {
|
|
|
137
138
|
if (chars > MAX_PRECHECK_CHARS)
|
|
138
139
|
return false;
|
|
139
140
|
}
|
|
141
|
+
else if (typeof v === "number" && !isJcsSafeNumber(v)) {
|
|
142
|
+
// Reject numbers that don't canonicalize identically across JSON
|
|
143
|
+
// serializers (non-finite, -0, exponential notation). See sign.ts.
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
140
146
|
return true;
|
|
141
147
|
}
|
|
142
148
|
if (Array.isArray(v)) {
|
|
@@ -236,7 +242,9 @@ export async function verifyInkSignature(input, signatureBase64url, publicKey) {
|
|
|
236
242
|
const bytes = new TextEncoder().encode(sigBase);
|
|
237
243
|
try {
|
|
238
244
|
const sig = base64urlDecode(signatureBase64url);
|
|
239
|
-
|
|
245
|
+
// RFC 8032 strict verification (not the default ZIP-215): reject
|
|
246
|
+
// small-order keys and non-canonical encodings. See verifyMessage.
|
|
247
|
+
return await ed.verifyAsync(sig, bytes, publicKey, { zip215: false });
|
|
240
248
|
}
|
|
241
249
|
catch {
|
|
242
250
|
return false;
|
|
@@ -629,7 +637,7 @@ export async function verifyAuditEventSignature(event, publicKey) {
|
|
|
629
637
|
if (bytes.length > MAX_SIGBASE_BODY_BYTES)
|
|
630
638
|
return false;
|
|
631
639
|
const sig = base64urlDecode(signature);
|
|
632
|
-
return await ed.verifyAsync(sig, bytes, publicKey);
|
|
640
|
+
return await ed.verifyAsync(sig, bytes, publicKey, { zip215: false });
|
|
633
641
|
}
|
|
634
642
|
catch {
|
|
635
643
|
return false;
|
|
@@ -739,7 +747,7 @@ export async function verifyAuditResponseSignature(events, signature, publicKey)
|
|
|
739
747
|
if (bytes.length > MAX_SIGBASE_BODY_BYTES)
|
|
740
748
|
return false;
|
|
741
749
|
const sig = base64urlDecode(signature);
|
|
742
|
-
return await ed.verifyAsync(sig, bytes, publicKey);
|
|
750
|
+
return await ed.verifyAsync(sig, bytes, publicKey, { zip215: false });
|
|
743
751
|
}
|
|
744
752
|
catch {
|
|
745
753
|
return false;
|
|
@@ -905,7 +913,7 @@ export async function verifyAuditQueryResponseSignature(responseWithoutSignature
|
|
|
905
913
|
if (bytes.length > MAX_SIGBASE_BODY_BYTES)
|
|
906
914
|
return false;
|
|
907
915
|
const sig = base64urlDecode(signature);
|
|
908
|
-
return await ed.verifyAsync(sig, bytes, publicKey);
|
|
916
|
+
return await ed.verifyAsync(sig, bytes, publicKey, { zip215: false });
|
|
909
917
|
}
|
|
910
918
|
catch {
|
|
911
919
|
return false;
|