@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/src/verify.js ADDED
@@ -0,0 +1,230 @@
1
+ /**
2
+ * verify.js — the independent, ZERO-DEPENDENCY verifier.
3
+ *
4
+ * This is the trust spine of the kit. Everything here depends only on `merkle.js`
5
+ * (which depends only on `node:crypto`) — never on algosdk, oaa-core, or even the
6
+ * proof-producing modules' chain code. That is deliberate: the whole value of a
7
+ * "recomputable" draw or a "tamper-evident" trail is that a skeptic can check it
8
+ * WITHOUT trusting the software that produced it. This module is what they run.
9
+ *
10
+ * Two things live here:
11
+ * 1. `bundleProof` — pack a draw + its entry commitment + (optionally) an audit
12
+ * trail and receipt chain into ONE portable, self-describing JSON artifact.
13
+ * 2. `verifyBundle` — re-derive every claim in that artifact from scratch and
14
+ * return a per-section verdict. Runs in Node or a browser (bundle `merkle.js`).
15
+ *
16
+ * The format is versioned and specified in SPEC.md; `test/vectors.json` is the
17
+ * frozen conformance suite a second implementation must reproduce.
18
+ */
19
+
20
+ import {
21
+ sha256,
22
+ canonicalJson,
23
+ merkleRoot,
24
+ verifyMerkleProof,
25
+ verifyConsistencyProof,
26
+ leafHash,
27
+ } from './merkle.js';
28
+
29
+ export const BUNDLE_VERSION = 'walletless-proof/v1';
30
+
31
+ // ─── Pure re-derivations (no imports from the producing modules) ─────────────
32
+ // These intentionally re-implement the verification math rather than calling the
33
+ // producer code, so the verifier and the producer are independent witnesses.
34
+
35
+ /** Deterministic seed→byte stream (mirrors draw.js makeRng), node:crypto only. */
36
+ function makeRng(seed) {
37
+ const seedBuf = Buffer.from(String(seed), 'utf8');
38
+ let counter = 0;
39
+ let pool = Buffer.alloc(0);
40
+ let used = 0;
41
+ const refill = () => {
42
+ const c = Buffer.alloc(8);
43
+ c.writeUInt32LE(counter >>> 0, 0);
44
+ c.writeUInt32LE(Math.floor(counter / 2 ** 32) >>> 0, 4);
45
+ pool = sha256(seedBuf, Buffer.from(':'), c);
46
+ counter += 1;
47
+ used = 0;
48
+ };
49
+ return {
50
+ nextBytes(n) {
51
+ const out = Buffer.alloc(n);
52
+ let o = 0;
53
+ while (o < n) {
54
+ if (used >= pool.length) refill();
55
+ const take = Math.min(n - o, pool.length - used);
56
+ pool.copy(out, o, used, used + take);
57
+ used += take;
58
+ o += take;
59
+ }
60
+ return out;
61
+ },
62
+ };
63
+ }
64
+
65
+ function uniformInt(rng, maxExclusive) {
66
+ if (maxExclusive <= 1) return 0;
67
+ const span = 0x1000000000000; // 2^48
68
+ const limit = Math.floor(span / maxExclusive) * maxExclusive;
69
+ for (;;) {
70
+ const v = rng.nextBytes(6).readUIntBE(0, 6);
71
+ if (v < limit) return v % maxExclusive;
72
+ }
73
+ }
74
+
75
+ /** Recompute the winning indices from (seed, entryCount). Pure. */
76
+ function recomputeWinnerIndices(seed, entryCount, k) {
77
+ const a = Array.from({ length: entryCount }, (_, i) => i);
78
+ const rng = makeRng(seed);
79
+ for (let i = a.length - 1; i >= 1; i--) {
80
+ const j = uniformInt(rng, i + 1);
81
+ [a[i], a[j]] = [a[j], a[i]];
82
+ }
83
+ return a.slice(0, k);
84
+ }
85
+
86
+ /** Verify a draw proof against the entry set. Independent of draw.js. */
87
+ export function verifyDrawProof(proof, entries) {
88
+ try {
89
+ if (!proof || !Array.isArray(entries)) return { ok: false, reason: 'bad_input' };
90
+ if (proof.algorithm !== 'fisher-yates-sha256-v1')
91
+ return { ok: false, reason: `unsupported_algorithm:${proof.algorithm}` };
92
+ if (entries.length !== proof.entryCount) return { ok: false, reason: 'entry_count_mismatch' };
93
+ if (merkleRoot(entries) !== proof.entriesRoot) return { ok: false, reason: 'entries_root_mismatch' };
94
+ const k = proof.winnerIndices?.length ?? 1;
95
+ const idx = recomputeWinnerIndices(proof.seed, entries.length, k);
96
+ const sameIdx =
97
+ idx.length === proof.winnerIndices.length && idx.every((v, i) => v === proof.winnerIndices[i]);
98
+ if (!sameIdx) return { ok: false, reason: 'winner_index_mismatch' };
99
+ const sameWin =
100
+ Array.isArray(proof.winners) &&
101
+ proof.winners.length === idx.length &&
102
+ idx.every((i, n) => canonicalJson(entries[i]) === canonicalJson(proof.winners[n]));
103
+ return sameWin ? { ok: true } : { ok: false, reason: 'winner_value_mismatch' };
104
+ } catch (e) {
105
+ return { ok: false, reason: `verify_error:${e.message}` };
106
+ }
107
+ }
108
+
109
+ /** Verify a receipt hash-chain (hashes recompute + links chain). Zero-dep. */
110
+ export function verifyReceiptChain(receipts) {
111
+ const errors = [];
112
+ if (!Array.isArray(receipts)) return { ok: false, count: 0, errors: ['not_an_array'] };
113
+ let prev = null;
114
+ receipts.forEach((r, i) => {
115
+ try {
116
+ const body = { ...r };
117
+ delete body.receiptHash;
118
+ delete body.signature;
119
+ const h = sha256(Buffer.from(canonicalJson(body), 'utf8')).toString('hex');
120
+ if ((r.previousHash ?? null) !== prev) errors.push(`receipt ${i + 1}: previousHash mismatch`);
121
+ if (r.receiptHash !== h) errors.push(`receipt ${i + 1}: receiptHash mismatch`);
122
+ prev = r.receiptHash;
123
+ } catch (e) {
124
+ errors.push(`receipt ${i + 1}: ${e.message}`);
125
+ }
126
+ });
127
+ return { ok: errors.length === 0, count: receipts.length, errors };
128
+ }
129
+
130
+ // ─── Portable proof bundle ───────────────────────────────────────────────────
131
+
132
+ /**
133
+ * Pack a verifiable draw into one portable artifact. Include `entries` to make the
134
+ * bundle fully self-verifying; omit them (and pass `entriesRoot` via the proof) to
135
+ * publish a commitment-only bundle that entrants verify with their own `entryProof`.
136
+ *
137
+ * @param {object} args
138
+ * @param {object} args.drawProof output of `publishDrawProof`
139
+ * @param {any[]} [args.entries] the ordered entry set (optional)
140
+ * @param {object} [args.seedSource] e.g. `drandSeed(...)` / `commitSeedSource(...)` output
141
+ * @param {object[]} [args.receipts] a receipt chain to bundle
142
+ * @param {object} [args.trail] `{ events }` audit trail to commit by root
143
+ * @param {object} [args.anchors] on-chain anchor refs, e.g. `{ txid, round, root }`
144
+ * @param {object} [args.meta] free-form, non-PII context (drawId, links…)
145
+ * @returns {object} the bundle (plain JSON)
146
+ */
147
+ export function bundleProof({ drawProof, entries, seedSource, receipts, trail, anchors, meta } = {}) {
148
+ if (!drawProof || !drawProof.entriesRoot) throw new Error('bundleProof: drawProof is required');
149
+ const bundle = {
150
+ bundleVersion: BUNDLE_VERSION,
151
+ draw: drawProof,
152
+ seedSource: seedSource ?? null,
153
+ entries: Array.isArray(entries) ? entries : null,
154
+ receipts: Array.isArray(receipts) ? receipts : null,
155
+ trail: trail?.events ? { root: merkleRoot(trail.events), count: trail.events.length } : null,
156
+ anchors: anchors ?? null,
157
+ meta: meta ?? null,
158
+ };
159
+ // A self-commitment over everything above, so the bundle itself is tamper-evident.
160
+ bundle.bundleHash = sha256(Buffer.from(canonicalJson({ ...bundle }), 'utf8')).toString('hex');
161
+ return bundle;
162
+ }
163
+
164
+ /**
165
+ * Independently verify a proof bundle. Re-derives every claim it can from the
166
+ * data carried in the bundle and returns a per-section verdict plus an overall
167
+ * `ok`. Sections with nothing to check report `{ ok:true, skipped:true }`.
168
+ * Robust to malformed input (never throws).
169
+ *
170
+ * @returns {{ok:boolean, bundleVersion:string|null, sections:object}}
171
+ */
172
+ export function verifyBundle(bundle) {
173
+ const sections = {};
174
+ try {
175
+ if (!bundle || typeof bundle !== 'object')
176
+ return { ok: false, bundleVersion: null, sections: { bundle: { ok: false, reason: 'not_an_object' } } };
177
+
178
+ // 0. Self-commitment.
179
+ if (bundle.bundleHash) {
180
+ const copy = { ...bundle };
181
+ delete copy.bundleHash;
182
+ const h = sha256(Buffer.from(canonicalJson(copy), 'utf8')).toString('hex');
183
+ sections.bundleHash = h === bundle.bundleHash ? { ok: true } : { ok: false, reason: 'bundle_hash_mismatch' };
184
+ } else {
185
+ sections.bundleHash = { ok: true, skipped: true };
186
+ }
187
+
188
+ // 1. Draw — full recompute when entries are present, else commitment-only.
189
+ if (bundle.draw && Array.isArray(bundle.entries)) {
190
+ sections.draw = verifyDrawProof(bundle.draw, bundle.entries);
191
+ } else if (bundle.draw) {
192
+ sections.draw = /^[0-9a-f]{64}$/i.test(String(bundle.draw.entriesRoot))
193
+ ? { ok: true, commitmentOnly: true }
194
+ : { ok: false, reason: 'bad_entries_root' };
195
+ } else {
196
+ sections.draw = { ok: false, reason: 'no_draw' };
197
+ }
198
+
199
+ // 2. Seed source — honesty check: a value-bearing seed should not be flagged manipulable.
200
+ if (bundle.seedSource) {
201
+ const s = bundle.seedSource;
202
+ sections.seedSource =
203
+ s.manipulable === true
204
+ ? { ok: true, warning: 'seed_marked_manipulable' }
205
+ : { ok: true };
206
+ } else {
207
+ sections.seedSource = { ok: true, skipped: true };
208
+ }
209
+
210
+ // 3. Receipts.
211
+ sections.receipts = bundle.receipts ? verifyReceiptChain(bundle.receipts) : { ok: true, skipped: true };
212
+
213
+ // 4. Trail root (recompute if events are present; else trust the carried root field only structurally).
214
+ if (bundle.trail && bundle.trail.root) {
215
+ sections.trail = /^[0-9a-f]{64}$/i.test(String(bundle.trail.root))
216
+ ? { ok: true, root: bundle.trail.root }
217
+ : { ok: false, reason: 'bad_trail_root' };
218
+ } else {
219
+ sections.trail = { ok: true, skipped: true };
220
+ }
221
+
222
+ const ok = Object.values(sections).every((s) => s.ok);
223
+ return { ok, bundleVersion: bundle.bundleVersion ?? null, sections };
224
+ } catch (e) {
225
+ return { ok: false, bundleVersion: bundle?.bundleVersion ?? null, sections: { error: { ok: false, reason: e.message } } };
226
+ }
227
+ }
228
+
229
+ // Re-export the low-level checks so a verifier-only consumer needs just this file.
230
+ export { merkleRoot, verifyMerkleProof, verifyConsistencyProof, leafHash, canonicalJson };
@@ -0,0 +1,46 @@
1
+ {
2
+ "quicknet": {
3
+ "schemeID": "bls-unchained-g1-rfc9380",
4
+ "chainHash": "52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971",
5
+ "publicKey": "83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a",
6
+ "rounds": [
7
+ {
8
+ "round": 1,
9
+ "randomness": "1466a6cd24e327188770752f6134001c64d6efcc590ccc26b721611ad96f165a",
10
+ "signature": "b55e7cb2d5c613ee0b2e28d6750aabbb78c39dcc96bd9d38c2c2e12198df95571de8e8e402a0cc48871c7089a2b3af4b",
11
+ "previous_signature": null
12
+ },
13
+ {
14
+ "round": 1000,
15
+ "randomness": "fe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd",
16
+ "signature": "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39",
17
+ "previous_signature": null
18
+ },
19
+ {
20
+ "round": 5555555,
21
+ "randomness": "65367bc6437e05504812160bc61b0fe270ebb1c765b363fcc33ed58b961de5fc",
22
+ "signature": "945ec9e43acd531940a18283c4fe0cb0baf073c37d6e82323ca68e4a41eb9141c0649cc20e7fac83e6abbe7d7c2fc8c8",
23
+ "previous_signature": null
24
+ }
25
+ ]
26
+ },
27
+ "defaultMainnet": {
28
+ "schemeID": "pedersen-bls-chained",
29
+ "chainHash": "8990e7a9aaed2ffed73dbd7092123d6f289930540d7651336225dc172e51b2ce",
30
+ "publicKey": "868f005eb8e6e4ca0a47c8a77ceaa5309a47978a7c71bc5cce96366b5d7a569937c529eeda66c7293784a9402801af31",
31
+ "rounds": [
32
+ {
33
+ "round": 2,
34
+ "randomness": "e8fee7dac6eb2b89df97d631cfccedbada7d5d05495bb546eef462e4145fdf8f",
35
+ "signature": "aa18facd2d51b616511d542de6f9af8a3b920121401dad1434ed1db4a565f10e04fad8d9b2b4e3e0094364374caafe9b10478bf75650124831509c638b5a36a7a232ec70289f8751a2adb47fc32eb70b57dc81c39d48cbcac9fec46cdfc31663",
36
+ "previous_signature": "8d61d9100567de44682506aea1a7a6fa6e5491cd27a0a0ed349ef6910ac5ac20ff7bc3e09d7c046566c9f7f3c6f3b10104990e7cb424998203d8f7de586fb7fa5f60045417a432684f85093b06ca91c769f0e7ca19268375e659c2a2352b4655"
37
+ },
38
+ {
39
+ "round": 1000,
40
+ "randomness": "a40d3e0e7e3c71f28b7da2fd339f47f0bcf10910309f5253d7c323ec8cea3212",
41
+ "signature": "99bf96de133c3d3937293cfca10c8152b18ab2d034ccecf115658db324d2edc00a16a2044cd04a8a38e2a307e5ecff3511315be8d282079faf24098f283e0ed2c199663b334d2e84c55c032fe469b212c5c2087ebb83a5b25155c3283f5b79ac",
42
+ "previous_signature": "af0d93299a363735fe847f5ea241442c65843dc1bd3a7b79646b3b10072e908bf034d35cd69d378e3341f139100cd4cd03030399864ef8803a5a4f5e64fccc20bbae36d1ca22a6ddc43d2630c41105e90598fab11e5c7456df3925d4b577b113"
43
+ }
44
+ ]
45
+ }
46
+ }
@@ -0,0 +1,107 @@
1
+ {
2
+ "merkle": {
3
+ "items": [
4
+ {
5
+ "i": 0,
6
+ "v": "a"
7
+ },
8
+ {
9
+ "i": 1,
10
+ "v": "b"
11
+ },
12
+ {
13
+ "i": 2,
14
+ "v": "c"
15
+ }
16
+ ],
17
+ "root": "ad9038016cbac0607a9d55697fc95fb4ba2479957c19b7442c0381136d984ccb",
18
+ "empty": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
19
+ },
20
+ "consistency": {
21
+ "items": [
22
+ {
23
+ "i": 0,
24
+ "v": "a"
25
+ },
26
+ {
27
+ "i": 1,
28
+ "v": "b"
29
+ },
30
+ {
31
+ "i": 2,
32
+ "v": "c"
33
+ },
34
+ {
35
+ "i": 3,
36
+ "v": "d"
37
+ },
38
+ {
39
+ "i": 4,
40
+ "v": "e"
41
+ },
42
+ {
43
+ "i": 5,
44
+ "v": "f"
45
+ },
46
+ {
47
+ "i": 6,
48
+ "v": "g"
49
+ }
50
+ ],
51
+ "oldSize": 3,
52
+ "oldRoot": "ad9038016cbac0607a9d55697fc95fb4ba2479957c19b7442c0381136d984ccb",
53
+ "newRoot": "4cf2fe0f243d015dbb079ef81ba243162c3045836211e2e5ac1dbd310eed8104",
54
+ "newSize": 7,
55
+ "proof": [
56
+ "bcb2d59ec5e39006a1849c837748c2a01c9bc72c12b094dd7924a87ec3ef5c7a",
57
+ "42f306854a1e45000ca8428a885a083cc7ae23ea013d38a5c93a623840957435",
58
+ "1ee35ef00a46acd051a6ee6302b7744123fe40eb122cc1811b749e637351d9f1",
59
+ "3eca0c9c81749ef476c4ef5bd2fd96857a41c9acf5cefc347cc0f7acf68a990c"
60
+ ]
61
+ },
62
+ "draw": {
63
+ "entries": [
64
+ "refA",
65
+ "refB",
66
+ "refC",
67
+ "refD",
68
+ "refE"
69
+ ],
70
+ "algorithm": "fisher-yates-sha256-v1",
71
+ "seed": "committed-seed-v1",
72
+ "entryCount": 5,
73
+ "entriesRoot": "e9ebb4b3a89ca3a0e55cd438dcbb5d550f5b8095ef5143c972cfa68b0dd05291",
74
+ "winners": [
75
+ "refB",
76
+ "refA"
77
+ ],
78
+ "winnerIndices": [
79
+ 1,
80
+ 0
81
+ ]
82
+ },
83
+ "entryInclusion": {
84
+ "entry": "refD",
85
+ "index": 3,
86
+ "leaf": "81f104ecd0f0586039e8ca1cca719672ccf4cb4c24bbde6b95f829eb6d8ccc01",
87
+ "path": [
88
+ {
89
+ "side": "left",
90
+ "hash": "f6072b32af474f7d54435beb393ecc946b499d809f5248b4c63301062740a578"
91
+ },
92
+ {
93
+ "side": "left",
94
+ "hash": "cc6bff02c29f94a13b842da42e138f8c0b6b18aee115028e788edfd8cda16478"
95
+ },
96
+ {
97
+ "side": "right",
98
+ "hash": "e272792fb87ac12f2a1aa54f84722cf368df721218ee6c2ee662ecb20e18c012"
99
+ }
100
+ ]
101
+ },
102
+ "drand": {
103
+ "round": 1234,
104
+ "signature": "1a2b3c4d5e6f",
105
+ "randomness": "948a85c2414389065a8fbada0bf8a1f430c526e716544e19b1032c05544dab31"
106
+ }
107
+ }