@kirkelabs/walletless-kit 0.1.0 → 0.2.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 +44 -0
- package/README.md +7 -3
- package/SPEC.md +189 -0
- package/package.json +18 -4
- package/src/audit.js +23 -78
- package/src/drand-bls.js +164 -0
- package/src/draw.js +146 -1
- package/src/index.js +25 -0
- package/src/ledger.js +47 -0
- package/src/merkle.js +250 -0
- package/src/verify.js +230 -0
- package/test/drand-vectors.json +46 -0
- package/test/vectors.json +107 -0
package/src/draw.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { createHash } from 'node:crypto';
|
|
20
|
-
import { merkleRoot } from './
|
|
20
|
+
import { merkleRoot, merkleProof, verifyMerkleProof, leafHash } from './merkle.js';
|
|
21
21
|
|
|
22
22
|
const MAX_ENTRIES = 5_000_000;
|
|
23
23
|
const ALGORITHM = 'fisher-yates-sha256-v1';
|
|
@@ -149,6 +149,43 @@ export function verifyDraw(proof, entries) {
|
|
|
149
149
|
}
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
// ─── Entrant inclusion proofs ────────────────────────────────────────────────
|
|
153
|
+
// A draw proof commits to the exact entry set via `entriesRoot`. These let a
|
|
154
|
+
// single entrant verify "my reference was in the set that produced the winner",
|
|
155
|
+
// against the published root alone — WITHOUT being handed every other entrant's
|
|
156
|
+
// reference (which would leak the field). Answers "were my tickets counted?".
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Inclusion proof that `entries[index]` is committed by `entriesRoot`.
|
|
160
|
+
* Hand the returned object (plus the public `entriesRoot`) to the entrant.
|
|
161
|
+
* @returns {{entry:any, index:number, leaf:string, path:{side:string,hash:string}[]}}
|
|
162
|
+
*/
|
|
163
|
+
export function entryProof(entries, index) {
|
|
164
|
+
if (!Array.isArray(entries) || index < 0 || index >= entries.length)
|
|
165
|
+
throw new Error('entryProof: index out of range');
|
|
166
|
+
const { leaf, path } = merkleProof(entries, index);
|
|
167
|
+
return { entry: entries[index], index, leaf, path };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Verify an entrant inclusion proof against the published `entriesRoot`. Checks
|
|
172
|
+
* BOTH that the proof recomputes the root AND that its leaf is the hash of the
|
|
173
|
+
* claimed `entry` (so the proof can't be replayed for a different value).
|
|
174
|
+
* Robust to malformed input (never throws).
|
|
175
|
+
* @returns {{ok:boolean, reason?:string}}
|
|
176
|
+
*/
|
|
177
|
+
export function verifyEntryProof(args) {
|
|
178
|
+
try {
|
|
179
|
+
const { entry, leaf, path, entriesRoot } = args || {};
|
|
180
|
+
const expected = leafHash(entry).toString('hex');
|
|
181
|
+
if (String(leaf) !== expected) return { ok: false, reason: 'leaf_entry_mismatch' };
|
|
182
|
+
const r = verifyMerkleProof({ leaf, path, root: entriesRoot });
|
|
183
|
+
return r.ok ? { ok: true } : { ok: false, reason: r.reason ?? 'root_mismatch' };
|
|
184
|
+
} catch (e) {
|
|
185
|
+
return { ok: false, reason: `verify_error:${e.message}` };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
152
189
|
// ─── Seed adapters ──────────────────────────────────────────────────────────
|
|
153
190
|
// Each returns { source, value, ...context }. Document manipulability honestly.
|
|
154
191
|
|
|
@@ -195,3 +232,111 @@ export function beaconSeed({ value, round, beacon = 'drand' }) {
|
|
|
195
232
|
if (!value) throw new Error('beaconSeed: value is required');
|
|
196
233
|
return { source: 'beacon', beacon, round: round == null ? null : Number(round), value: String(value) };
|
|
197
234
|
}
|
|
235
|
+
|
|
236
|
+
// ─── drand: non-manipulable public randomness ────────────────────────────────
|
|
237
|
+
// A drand beacon (the League of Entropy "quicknet" network) emits a fresh random
|
|
238
|
+
// value every `period` seconds. Each value is a BLS THRESHOLD signature: no single
|
|
239
|
+
// operator — including you — can predict, withhold, or grind it. Committing to a
|
|
240
|
+
// FUTURE drand round before entries close therefore gives a seed that is both
|
|
241
|
+
// unpredictable in advance and independently checkable after the fact, closing the
|
|
242
|
+
// "a block producer could bias the block hash" hole in `blockHashSeed`.
|
|
243
|
+
//
|
|
244
|
+
// The publicly recomputable binding is `randomness == SHA256(signature)`; this
|
|
245
|
+
// adapter enforces it with `node:crypto` (zero deps). Full BLS verification — that
|
|
246
|
+
// the signature is a valid threshold signature over the round under the network's
|
|
247
|
+
// public key — is stronger still; pass an async `verifySignature({round,signature,
|
|
248
|
+
// previous})` (e.g. backed by @noble/curves bls12_381) to enforce it too.
|
|
249
|
+
|
|
250
|
+
/** drand quicknet defaults (League of Entropy). Override for other chains. */
|
|
251
|
+
export const DRAND_QUICKNET = {
|
|
252
|
+
chainHash: '52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971',
|
|
253
|
+
genesisTime: 1692803367,
|
|
254
|
+
period: 3,
|
|
255
|
+
url: 'https://api.drand.sh',
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
function sha256hex(hexInput) {
|
|
259
|
+
return sha256(Buffer.from(String(hexInput), 'hex')).toString('hex');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The drand round whose randomness will first be available at-or-after `unixTime`.
|
|
264
|
+
* Use this to COMMIT a future round before entries close (publish it via
|
|
265
|
+
* `commitSeedSource({ source:'drand', round })`), so the seed is fixed in advance.
|
|
266
|
+
* @param {number} unixTime seconds since epoch (e.g. your entry-close time)
|
|
267
|
+
* @param {{genesisTime?:number, period?:number}} [chain] defaults to quicknet
|
|
268
|
+
*/
|
|
269
|
+
export function drandRoundAt(unixTime, chain = DRAND_QUICKNET) {
|
|
270
|
+
const t = Number(unixTime);
|
|
271
|
+
const { genesisTime, period } = { ...DRAND_QUICKNET, ...chain };
|
|
272
|
+
if (!Number.isFinite(t) || t < genesisTime) throw new Error('drandRoundAt: unixTime before genesis');
|
|
273
|
+
return Math.floor((t - genesisTime) / period) + 1;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Build a seed from a drand beacon value, verifying the public binding
|
|
278
|
+
* `randomness == SHA256(signature)`. Pass the `{round, randomness, signature}`
|
|
279
|
+
* you fetched from a drand HTTP endpoint (see `fetchDrandRound`).
|
|
280
|
+
* @param {object} beacon
|
|
281
|
+
* @param {number} beacon.round
|
|
282
|
+
* @param {string} beacon.randomness hex
|
|
283
|
+
* @param {string} beacon.signature hex (BLS signature)
|
|
284
|
+
* @param {string} [beacon.chainHash]
|
|
285
|
+
* @param {(b:object)=>Promise<boolean>|boolean} [verifySignature] optional BLS check
|
|
286
|
+
* @returns {Promise<{source:string, value:string, round:number, signature:string, chainHash:string|null, manipulable:false, blsVerified:boolean}>}
|
|
287
|
+
*/
|
|
288
|
+
export async function drandSeed(
|
|
289
|
+
{ round, randomness, signature, previousSignature = null, chainHash = null },
|
|
290
|
+
verifySignature,
|
|
291
|
+
) {
|
|
292
|
+
if (round == null || !Number.isInteger(Number(round)))
|
|
293
|
+
throw new Error('drandSeed: round is required');
|
|
294
|
+
if (!/^[0-9a-f]+$/i.test(String(signature ?? '')))
|
|
295
|
+
throw new Error('drandSeed: signature (hex) is required');
|
|
296
|
+
if (!/^[0-9a-f]{64}$/i.test(String(randomness ?? '')))
|
|
297
|
+
throw new Error('drandSeed: randomness (32-byte hex) is required');
|
|
298
|
+
if (sha256hex(signature) !== String(randomness).toLowerCase())
|
|
299
|
+
throw new Error('drandSeed: randomness does not match SHA256(signature) — beacon value rejected');
|
|
300
|
+
let blsVerified = false;
|
|
301
|
+
if (typeof verifySignature === 'function') {
|
|
302
|
+
blsVerified = !!(await verifySignature({
|
|
303
|
+
round: Number(round),
|
|
304
|
+
randomness,
|
|
305
|
+
signature,
|
|
306
|
+
previousSignature,
|
|
307
|
+
chainHash,
|
|
308
|
+
}));
|
|
309
|
+
if (!blsVerified) throw new Error('drandSeed: BLS signature verification failed');
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
source: 'drand',
|
|
313
|
+
value: String(randomness).toLowerCase(),
|
|
314
|
+
round: Number(round),
|
|
315
|
+
signature: String(signature),
|
|
316
|
+
chainHash: chainHash == null ? null : String(chainHash),
|
|
317
|
+
manipulable: false,
|
|
318
|
+
blsVerified,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Fetch a drand round over HTTP. `fetch` is injectable (defaults to global fetch)
|
|
324
|
+
* so this is testable offline and SSRF-scoped to the drand host you pass.
|
|
325
|
+
* @param {number} round
|
|
326
|
+
* @param {{url?:string, chainHash?:string, fetch?:Function}} [opts]
|
|
327
|
+
*/
|
|
328
|
+
export async function fetchDrandRound(round, opts = {}) {
|
|
329
|
+
const { url, chainHash, fetch: f = globalThis.fetch } = { ...DRAND_QUICKNET, ...opts };
|
|
330
|
+
if (typeof f !== 'function') throw new Error('fetchDrandRound: no fetch available');
|
|
331
|
+
const endpoint = `${url}/${chainHash}/public/${Number(round)}`;
|
|
332
|
+
const res = await f(endpoint);
|
|
333
|
+
if (!res.ok) throw new Error(`fetchDrandRound: HTTP ${res.status}`);
|
|
334
|
+
const body = await res.json();
|
|
335
|
+
return {
|
|
336
|
+
round: Number(body.round),
|
|
337
|
+
randomness: body.randomness,
|
|
338
|
+
signature: body.signature,
|
|
339
|
+
previousSignature: body.previous_signature ?? null, // present on chained beacons
|
|
340
|
+
chainHash,
|
|
341
|
+
};
|
|
342
|
+
}
|
package/src/index.js
CHANGED
|
@@ -27,12 +27,31 @@ export {
|
|
|
27
27
|
merkleRoot,
|
|
28
28
|
merkleProof,
|
|
29
29
|
verifyMerkleProof,
|
|
30
|
+
consistencyProof,
|
|
31
|
+
verifyConsistencyProof,
|
|
32
|
+
trailConsistencyProof,
|
|
33
|
+
verifyTrailConsistency,
|
|
30
34
|
trailHash,
|
|
31
35
|
trailRoot,
|
|
32
36
|
verifyTrail,
|
|
33
37
|
anchor,
|
|
34
38
|
} from './audit.js';
|
|
35
39
|
|
|
40
|
+
// Zero-dependency canonical JSON (the hashing primitive the whole kit shares).
|
|
41
|
+
export { canonicalJson } from './merkle.js';
|
|
42
|
+
|
|
43
|
+
// Independent, dependency-free verifier + portable proof bundle. `verifyDrawProof`
|
|
44
|
+
// re-derives a draw without trusting draw.js; `verifyBundle` checks a whole
|
|
45
|
+
// artifact. See SPEC.md and test/vectors.json (the conformance suite).
|
|
46
|
+
export { bundleProof, verifyBundle, verifyDrawProof, BUNDLE_VERSION } from './verify.js';
|
|
47
|
+
|
|
48
|
+
// NOTE: real BLS verification of drand beacons (`makeDrandVerifier`,
|
|
49
|
+
// `verifyDrandBeacon`, `DRAND_QUICKNET_SCHEME`, …) is intentionally NOT re-exported
|
|
50
|
+
// here. It needs the OPTIONAL peer dependency `@noble/curves`, so it lives only at
|
|
51
|
+
// the `@kirkelabs/walletless-kit/drand-bls` subpath — keeping this core entrypoint
|
|
52
|
+
// (and the zero-dependency verifier) free of pairing-crypto. Import it explicitly:
|
|
53
|
+
// import { makeDrandVerifier } from '@kirkelabs/walletless-kit/drand-bls';
|
|
54
|
+
|
|
36
55
|
export {
|
|
37
56
|
createEphemeralAccount,
|
|
38
57
|
isExpired,
|
|
@@ -57,8 +76,14 @@ export {
|
|
|
57
76
|
runDraw,
|
|
58
77
|
publishDrawProof,
|
|
59
78
|
verifyDraw,
|
|
79
|
+
entryProof,
|
|
80
|
+
verifyEntryProof,
|
|
60
81
|
commitSeedSource,
|
|
61
82
|
blockHashSeed,
|
|
62
83
|
vrfSeed,
|
|
63
84
|
beaconSeed,
|
|
85
|
+
drandSeed,
|
|
86
|
+
drandRoundAt,
|
|
87
|
+
fetchDrandRound,
|
|
88
|
+
DRAND_QUICKNET,
|
|
64
89
|
} from './draw.js';
|
package/src/ledger.js
CHANGED
|
@@ -104,6 +104,53 @@ export class Ledger {
|
|
|
104
104
|
winnerProofLink,
|
|
105
105
|
};
|
|
106
106
|
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Conservation invariant: you can never allocate (to charity + escrow) plus
|
|
110
|
+
* deduct in fees MORE than actually came in. A pure, deterministic check over
|
|
111
|
+
* the three books — the kind of statement an auditor or a charity board can
|
|
112
|
+
* recompute. `retainedMicro` is what's left for the operator after allocations.
|
|
113
|
+
*
|
|
114
|
+
* `fee` entries in ANY book are treated as deductions from inflow; `charity`
|
|
115
|
+
* and `escrow` book balances are allocations. Returns the breakdown plus a
|
|
116
|
+
* boolean `balanced` (allocations + fees ≤ inflow) and the residual.
|
|
117
|
+
* @returns {{balanced:boolean, inflowMicro:number, feesMicro:number, charityMicro:number, escrowMicro:number, retainedMicro:number}}
|
|
118
|
+
*/
|
|
119
|
+
conservation() {
|
|
120
|
+
const sum = (arr) => arr.reduce((s, e) => s + e.amountMicro, 0);
|
|
121
|
+
const inflowMicro = this.balance('inflow');
|
|
122
|
+
const feesMicro = sum(
|
|
123
|
+
[...this._books.inflow, ...this._books.charity, ...this._books.escrow].filter(
|
|
124
|
+
(e) => e.kind === 'fee',
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
const charityMicro = this.balance('charity');
|
|
128
|
+
const escrowMicro = this.balance('escrow');
|
|
129
|
+
const retainedMicro = inflowMicro - feesMicro - charityMicro - escrowMicro;
|
|
130
|
+
return {
|
|
131
|
+
balanced: retainedMicro >= 0,
|
|
132
|
+
inflowMicro,
|
|
133
|
+
feesMicro,
|
|
134
|
+
charityMicro,
|
|
135
|
+
escrowMicro,
|
|
136
|
+
retainedMicro,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Throw unless the conservation invariant holds (allocations + fees ≤ inflow).
|
|
142
|
+
* Call after posting allocations to fail closed on an over-allocation bug
|
|
143
|
+
* before it reaches a reconciliation sheet or an on-chain payout.
|
|
144
|
+
* @returns {Ledger} this (chainable)
|
|
145
|
+
*/
|
|
146
|
+
assertConservation() {
|
|
147
|
+
const c = this.conservation();
|
|
148
|
+
if (!c.balanced)
|
|
149
|
+
throw new Error(
|
|
150
|
+
`ledger: conservation violated — allocated/fees (${c.feesMicro + c.charityMicro + c.escrowMicro}) exceed inflow (${c.inflowMicro}) by ${-c.retainedMicro} microALGO`,
|
|
151
|
+
);
|
|
152
|
+
return this;
|
|
153
|
+
}
|
|
107
154
|
}
|
|
108
155
|
|
|
109
156
|
/** Convenience factory. */
|
package/src/merkle.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* merkle.js — the zero-dependency cryptographic core of the kit.
|
|
3
|
+
*
|
|
4
|
+
* This module deliberately depends on NOTHING but `node:crypto`. It is the shared
|
|
5
|
+
* spine for both the proof-PRODUCING side (`audit.js`, `draw.js`) and the
|
|
6
|
+
* independent proof-VERIFYING side (`verify.js`). Keeping it dependency-free is
|
|
7
|
+
* what lets the verifier run anywhere — a browser, an air-gapped box, a second
|
|
8
|
+
* implementation — without trusting algosdk, oaa-core, or even this package.
|
|
9
|
+
*
|
|
10
|
+
* It provides:
|
|
11
|
+
* - `canonicalJson` — deterministic JSON (sorted keys), byte-identical to
|
|
12
|
+
* @kirkelabs/open-agent-access-core's canonicalizeJson
|
|
13
|
+
* (cross-checked by test), so roots match either path.
|
|
14
|
+
* - `merkleRoot` / proofs — RFC 6962 domain-separated tree (leaf 0x00, node 0x01)
|
|
15
|
+
* with lone-node promotion (no CVE-2012-2459).
|
|
16
|
+
* - `consistencyProof` — RFC 6962 §2.1.2 proof that an append-only log only
|
|
17
|
+
* GREW between two sizes and was never rewritten.
|
|
18
|
+
*
|
|
19
|
+
* Every function here is pure and deterministic.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
|
|
24
|
+
const MAX_LEAVES = 1_000_000; // DoS bound (mirrors audit.js)
|
|
25
|
+
export const LEAF = Buffer.from([0x00]);
|
|
26
|
+
export const NODE = Buffer.from([0x01]);
|
|
27
|
+
|
|
28
|
+
export function sha256(...bufs) {
|
|
29
|
+
const h = createHash('sha256');
|
|
30
|
+
for (const b of bufs) h.update(b);
|
|
31
|
+
return h.digest();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Deterministic JSON: object keys sorted recursively, `undefined` dropped, no
|
|
36
|
+
* insignificant whitespace. Byte-identical to oaa-core's canonicalizeJson for all
|
|
37
|
+
* JSON values the kit hashes (guarded by `test/canonical.test.js`). A primitive
|
|
38
|
+
* (string/number/boolean/null) round-trips through standard JSON.
|
|
39
|
+
*/
|
|
40
|
+
export function canonicalJson(value) {
|
|
41
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
42
|
+
if (Array.isArray(value))
|
|
43
|
+
return '[' + value.map((v) => canonicalJson(v === undefined ? null : v)).join(',') + ']';
|
|
44
|
+
const keys = Object.keys(value)
|
|
45
|
+
.filter((k) => value[k] !== undefined)
|
|
46
|
+
.sort();
|
|
47
|
+
return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(value[k])).join(',') + '}';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Injective leaf encoding: domain-tagged hash of the item's canonical JSON. */
|
|
51
|
+
export function leafHash(item) {
|
|
52
|
+
return sha256(LEAF, Buffer.from(canonicalJson(item), 'utf8'));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Internal node hash: H(0x01 ‖ left ‖ right). */
|
|
56
|
+
export function nodeHash(left, right) {
|
|
57
|
+
return sha256(NODE, left, right);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* RFC 6962 Merkle Tree Hash over an array of leaf buffers. Bottom-up pairing with
|
|
62
|
+
* lone-node PROMOTION (the odd node is carried up unchanged, never duplicated).
|
|
63
|
+
* This equals the recursive RFC 6962 split-at-largest-power-of-two definition
|
|
64
|
+
* (verified for n = 0..40 in test), so consistency proofs below are valid.
|
|
65
|
+
*/
|
|
66
|
+
function mthFromLeaves(leaves) {
|
|
67
|
+
if (leaves.length === 0) return sha256(Buffer.alloc(0));
|
|
68
|
+
let level = leaves;
|
|
69
|
+
while (level.length > 1) {
|
|
70
|
+
const next = [];
|
|
71
|
+
for (let i = 0; i < level.length; i += 2) {
|
|
72
|
+
next.push(i + 1 < level.length ? nodeHash(level[i], level[i + 1]) : level[i]);
|
|
73
|
+
}
|
|
74
|
+
level = next;
|
|
75
|
+
}
|
|
76
|
+
return level[0];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Deterministic Merkle root (hex) over an ordered list of items.
|
|
81
|
+
* Empty list → H("") (RFC 6962 empty-tree root). Single item → its leaf hash.
|
|
82
|
+
*/
|
|
83
|
+
export function merkleRoot(items) {
|
|
84
|
+
if (!Array.isArray(items)) throw new Error('merkleRoot: items must be an array');
|
|
85
|
+
if (items.length > MAX_LEAVES) throw new Error('merkleRoot: too many leaves');
|
|
86
|
+
return mthFromLeaves(items.map(leafHash)).toString('hex');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Inclusion proof for the item at `index`: sibling hashes (hex) + their side,
|
|
91
|
+
* which `verifyMerkleProof` replays to recompute the root.
|
|
92
|
+
*/
|
|
93
|
+
export function merkleProof(items, index) {
|
|
94
|
+
if (!Array.isArray(items) || index < 0 || index >= items.length)
|
|
95
|
+
throw new Error('merkleProof: index out of range');
|
|
96
|
+
let level = items.map(leafHash);
|
|
97
|
+
let idx = index;
|
|
98
|
+
const path = [];
|
|
99
|
+
while (level.length > 1) {
|
|
100
|
+
const next = [];
|
|
101
|
+
for (let i = 0; i < level.length; i += 2) {
|
|
102
|
+
if (i + 1 < level.length) {
|
|
103
|
+
next.push(nodeHash(level[i], level[i + 1]));
|
|
104
|
+
if (i === idx) path.push({ side: 'right', hash: level[i + 1].toString('hex') });
|
|
105
|
+
else if (i + 1 === idx) path.push({ side: 'left', hash: level[i].toString('hex') });
|
|
106
|
+
} else {
|
|
107
|
+
next.push(level[i]); // promoted; no sibling recorded for the lone node
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
idx = Math.floor(idx / 2);
|
|
111
|
+
level = next;
|
|
112
|
+
}
|
|
113
|
+
return { index, leaf: leafHash(items[index]).toString('hex'), path };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Verify an inclusion proof against an expected root. Never throws. */
|
|
117
|
+
export function verifyMerkleProof({ leaf, path, root }) {
|
|
118
|
+
try {
|
|
119
|
+
let acc = Buffer.from(String(leaf), 'hex');
|
|
120
|
+
for (const step of path || []) {
|
|
121
|
+
const sib = Buffer.from(String(step.hash), 'hex');
|
|
122
|
+
acc = step.side === 'left' ? nodeHash(sib, acc) : nodeHash(acc, sib);
|
|
123
|
+
}
|
|
124
|
+
return { ok: acc.toString('hex') === String(root) };
|
|
125
|
+
} catch (e) {
|
|
126
|
+
return { ok: false, reason: `verify_error:${e.message}` };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ─── RFC 6962 consistency proofs ─────────────────────────────────────────────
|
|
131
|
+
// A consistency proof shows that the tree of size `m` is a PREFIX of the tree of
|
|
132
|
+
// size `n` (m ≤ n): every leaf of the old tree is still present, unchanged, in the
|
|
133
|
+
// same position. This is the Certificate-Transparency primitive that turns
|
|
134
|
+
// "tamper-evident snapshot" into "provably append-only history": anyone holding an
|
|
135
|
+
// old anchored root can prove the operator only appended and never rewrote it.
|
|
136
|
+
|
|
137
|
+
/** MTH over a slice of leaf buffers (RFC 6962 recursive split). */
|
|
138
|
+
function mthRange(leaves, start, end) {
|
|
139
|
+
return mthFromLeaves(leaves.slice(start, end));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Largest power of two strictly less than n (the RFC 6962 split point). */
|
|
143
|
+
function splitPoint(n) {
|
|
144
|
+
let k = 1;
|
|
145
|
+
while (k * 2 < n) k *= 2;
|
|
146
|
+
return k;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** SUBPROOF(m, leaves[start:end], b) per RFC 6962 §2.1.2. */
|
|
150
|
+
function subproof(m, leaves, start, end, b) {
|
|
151
|
+
const n = end - start;
|
|
152
|
+
if (m === n) {
|
|
153
|
+
// The subtree is fully contained in the old tree.
|
|
154
|
+
return b ? [] : [mthRange(leaves, start, end)];
|
|
155
|
+
}
|
|
156
|
+
const k = splitPoint(n);
|
|
157
|
+
if (m <= k) {
|
|
158
|
+
const proof = subproof(m, leaves, start, start + k, b);
|
|
159
|
+
proof.push(mthRange(leaves, start + k, end));
|
|
160
|
+
return proof;
|
|
161
|
+
}
|
|
162
|
+
const proof = subproof(m - k, leaves, start + k, end, false);
|
|
163
|
+
proof.push(mthRange(leaves, start, start + k));
|
|
164
|
+
return proof;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Build a consistency proof that the first `oldSize` items (the old tree) are a
|
|
169
|
+
* prefix of the full `items` (the new tree). Returns hex node hashes.
|
|
170
|
+
* @param {any[]} items the FULL current ordered list (length = new size)
|
|
171
|
+
* @param {number} oldSize the earlier, anchored size (1 ≤ oldSize ≤ items.length)
|
|
172
|
+
* @returns {{oldSize:number,newSize:number,proof:string[]}}
|
|
173
|
+
*/
|
|
174
|
+
export function consistencyProof(items, oldSize) {
|
|
175
|
+
if (!Array.isArray(items)) throw new Error('consistencyProof: items must be an array');
|
|
176
|
+
const newSize = items.length;
|
|
177
|
+
const m = Number(oldSize);
|
|
178
|
+
if (!Number.isInteger(m) || m < 1 || m > newSize)
|
|
179
|
+
throw new Error('consistencyProof: oldSize must be an integer in [1, items.length]');
|
|
180
|
+
const leaves = items.map(leafHash);
|
|
181
|
+
const nodes = m === newSize ? [] : subproof(m, leaves, 0, newSize, true);
|
|
182
|
+
return { oldSize: m, newSize, proof: nodes.map((b) => b.toString('hex')) };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Verify a consistency proof: that `oldRoot` (size m) is a prefix of `newRoot`
|
|
187
|
+
* (size n). Pure replay of RFC 6962 §2.1.2 verification. Never throws.
|
|
188
|
+
* @returns {{ok:boolean, reason?:string}}
|
|
189
|
+
*/
|
|
190
|
+
export function verifyConsistencyProof(args) {
|
|
191
|
+
try {
|
|
192
|
+
const { oldRoot, newRoot, oldSize, newSize, proof } = args || {};
|
|
193
|
+
const m = Number(oldSize);
|
|
194
|
+
const n = Number(newSize);
|
|
195
|
+
if (!Number.isInteger(m) || !Number.isInteger(n) || m < 1 || m > n)
|
|
196
|
+
return { ok: false, reason: 'bad_sizes' };
|
|
197
|
+
|
|
198
|
+
// m == n: identical trees — roots must match and the proof must be empty.
|
|
199
|
+
if (m === n) {
|
|
200
|
+
return String(oldRoot) === String(newRoot) && (proof || []).length === 0
|
|
201
|
+
? { ok: true }
|
|
202
|
+
: { ok: false, reason: 'size_equal_mismatch' };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Canonical RFC 6962 consistency-proof verification (certificate-transparency
|
|
206
|
+
// reference). Walk the node/last_node indices, consuming proof nodes in order.
|
|
207
|
+
const nodes = (proof || []).map((h) => Buffer.from(String(h), 'hex'));
|
|
208
|
+
let p = 0;
|
|
209
|
+
const next = () => {
|
|
210
|
+
if (p >= nodes.length) throw new Error('proof_exhausted');
|
|
211
|
+
return nodes[p++];
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
let node = m - 1;
|
|
215
|
+
let last = n - 1;
|
|
216
|
+
while (node % 2 === 1) {
|
|
217
|
+
node = Math.floor(node / 2);
|
|
218
|
+
last = Math.floor(last / 2);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// If `node` reduced to 0, old tree was a perfect subtree and oldRoot is the
|
|
222
|
+
// implicit first hash; otherwise the first proof node seeds both accumulators.
|
|
223
|
+
let oldHash = node ? next() : Buffer.from(String(oldRoot), 'hex');
|
|
224
|
+
let newHash = oldHash;
|
|
225
|
+
|
|
226
|
+
while (node) {
|
|
227
|
+
if (node % 2 === 1) {
|
|
228
|
+
const sib = next(); // right child → sibling on the left
|
|
229
|
+
oldHash = nodeHash(sib, oldHash);
|
|
230
|
+
newHash = nodeHash(sib, newHash);
|
|
231
|
+
} else if (node < last) {
|
|
232
|
+
newHash = nodeHash(newHash, next()); // left child with a right sibling
|
|
233
|
+
}
|
|
234
|
+
node = Math.floor(node / 2);
|
|
235
|
+
last = Math.floor(last / 2);
|
|
236
|
+
}
|
|
237
|
+
while (last) {
|
|
238
|
+
newHash = nodeHash(newHash, next());
|
|
239
|
+
last = Math.floor(last / 2);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (p !== nodes.length) return { ok: false, reason: 'proof_too_long' };
|
|
243
|
+
const ok =
|
|
244
|
+
oldHash.toString('hex') === String(oldRoot) &&
|
|
245
|
+
newHash.toString('hex') === String(newRoot);
|
|
246
|
+
return ok ? { ok: true } : { ok: false, reason: 'root_mismatch' };
|
|
247
|
+
} catch (e) {
|
|
248
|
+
return { ok: false, reason: `verify_error:${e.message}` };
|
|
249
|
+
}
|
|
250
|
+
}
|