@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/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,50 @@
|
|
|
3
3
|
All notable changes are documented here. Format: [Keep a Changelog](https://keepachangelog.com/);
|
|
4
4
|
versioning: [SemVer](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## [0.2.0] — 2026-06-20
|
|
7
|
+
|
|
8
|
+
Verifiable-trust release: make every result independently checkable against a
|
|
9
|
+
published spec, and close the draw-fairness gap with real drand verification.
|
|
10
|
+
Backwards-compatible — all 0.1.0 APIs are unchanged; everything below is additive.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Independent, zero-dependency verifier** (`@kirkelabs/walletless-kit/verify`) —
|
|
15
|
+
`verifyDrawProof`, `verifyBundle`, `bundleProof`. Re-derives a draw / receipt
|
|
16
|
+
chain / trail from scratch with only `node:crypto`, so a result can be checked
|
|
17
|
+
without trusting the producing code (runs in a browser / offline). `bundleProof`
|
|
18
|
+
packs a draw + entries + seed source + receipts + trail into one portable,
|
|
19
|
+
self-verifying artifact (`walletless-proof/v1`).
|
|
20
|
+
- **Proof format spec** (`SPEC.md`) + frozen conformance vectors
|
|
21
|
+
(`test/vectors.json`) — a second implementation that reproduces them interoperates.
|
|
22
|
+
- **RFC 6962 consistency proofs** (`consistencyProof`, `verifyConsistencyProof`,
|
|
23
|
+
`trailConsistencyProof`, `verifyTrailConsistency`) — prove an audit log only ever
|
|
24
|
+
grew between two anchored roots (provably append-only history).
|
|
25
|
+
- **Entrant inclusion proofs** for draws (`entryProof`, `verifyEntryProof`) — a
|
|
26
|
+
participant verifies "my entry was counted" against the published `entriesRoot`
|
|
27
|
+
without seeing the rest of the field.
|
|
28
|
+
- **Non-manipulable drand randomness** (`drandSeed`, `drandRoundAt`,
|
|
29
|
+
`fetchDrandRound`) — commit a future drand round before entries close; enforces
|
|
30
|
+
the `randomness == SHA256(signature)` binding (SHA-256 only).
|
|
31
|
+
- **Real BLS verification of drand beacons** (`@kirkelabs/walletless-kit/drand-bls`:
|
|
32
|
+
`makeDrandVerifier`, `verifyDrandBeacon`, `DRAND_QUICKNET_SCHEME`,
|
|
33
|
+
`DRAND_DEFAULT_SCHEME`) — proves a seed is a genuine League-of-Entropy threshold
|
|
34
|
+
signature (quicknet + legacy chained). Cross-checked against live beacon rounds
|
|
35
|
+
frozen in `test/drand-vectors.json`. Backed by `@noble/curves` as an **optional
|
|
36
|
+
peer dependency**, exposed only at the subpath, so the core entrypoint and the
|
|
37
|
+
verifier stay dependency-free.
|
|
38
|
+
- **Ledger conservation invariants** (`Ledger.conservation`,
|
|
39
|
+
`Ledger.assertConservation`) — allocations + fees can never exceed inflow.
|
|
40
|
+
- **`canonicalJson`** exported — the deterministic JSON primitive shared by the kit.
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- `audit.js` now sources its Merkle math from a new zero-dependency `src/merkle.js`
|
|
45
|
+
core; the Merkle roots are byte-for-byte identical (frozen vectors unchanged).
|
|
46
|
+
- `fetchDrandRound` / `drandSeed` now carry `previousSignature` (needed for chained
|
|
47
|
+
beacon verification).
|
|
48
|
+
- Test suite expanded from 47 to 85 passing; lint and `npm audit` clean.
|
|
49
|
+
|
|
6
50
|
## [0.1.0] — 2026-06-20
|
|
7
51
|
|
|
8
52
|
- Initial release. A walletless web-architecture toolkit built on
|
package/README.md
CHANGED
|
@@ -22,11 +22,15 @@ Built on [`@kirkelabs/open-agent-access-core`](https://www.npmjs.com/package/@ki
|
|
|
22
22
|
| **onboarding** | `createEphemeralAccount` / `rotateAccount` / `expireAccount` / `isExpired` — tightly-scoped, **round-relative auto-expiring** custodial accounts; authority bounded by an oaa-agent-kit mandate. |
|
|
23
23
|
| **identity** | `OtpIdentity` — email/SMS OTP: CSPRNG codes, single-use, expiring, rate-limited, lockout, constant-time compare; stores only **keyed (peppered) pseudonymous** contact refs. |
|
|
24
24
|
| **receipt** | `buildOrderReceipt` / `deterministicOrderId` / `signReceipt` / `verifyReceiptChain` / `attestOnChain` — hash-chained, signed, **non-PII** receipts; only the receipt *hash* goes on-chain. x402 actions via `chargeForAction`. |
|
|
25
|
-
| **audit** | `createTrail` / `append` / `merkleRoot` / `merkleProof` / `verifyMerkleProof` / `anchor` / `verifyTrail` — append-only hash-chained events + an **RFC 6962** Merkle root, periodically anchored on-chain. |
|
|
26
|
-
| **ledger** | `createLedger` — three segregated append-only books (inflow / charity / escrow), **integer-only money**, immutable snapshots,
|
|
27
|
-
| **draw** | `runDraw` / `publishDrawProof` / `verifyDraw` + `commitSeedSource` / `blockHashSeed` / `vrfSeed` / `beaconSeed` — deterministic, recomputable winner selection (no `Math.random`). |
|
|
25
|
+
| **audit** | `createTrail` / `append` / `merkleRoot` / `merkleProof` / `verifyMerkleProof` / `anchor` / `verifyTrail` + `consistencyProof` / `verifyConsistencyProof` / `trailConsistencyProof` — append-only hash-chained events + an **RFC 6962** Merkle root and **consistency proofs** (provably append-only between two anchors), periodically anchored on-chain. |
|
|
26
|
+
| **ledger** | `createLedger` — three segregated append-only books (inflow / charity / escrow), **integer-only money**, immutable snapshots, a per-draw `reconciliationSheet`, and `conservation()` / `assertConservation()` invariants (allocations + fees can never exceed inflow). |
|
|
27
|
+
| **draw** | `runDraw` / `publishDrawProof` / `verifyDraw` + `entryProof` / `verifyEntryProof` (entrant "was my ticket counted?" proofs) + `commitSeedSource` / `blockHashSeed` / `vrfSeed` / `beaconSeed` / `drandSeed` / `drandRoundAt` / `fetchDrandRound` — deterministic, recomputable winner selection (no `Math.random`) with **non-manipulable drand** randomness. |
|
|
28
|
+
| **verify** | `bundleProof` / `verifyBundle` / `verifyDrawProof` — a **zero-dependency** verifier (runs in a browser / offline) and a portable, self-verifying **proof bundle**. Don't trust the producer — recompute it. |
|
|
29
|
+
| **drand-bls** | `makeDrandVerifier` / `verifyDrandBeacon` (`@kirkelabs/walletless-kit/drand-bls`) — **real BLS12-381 verification** that a drand seed is a genuine League-of-Entropy threshold signature, not just well-formed. **Opt-in:** install the optional peer dep (`npm i @noble/curves`); not loaded by the core entrypoint, so the rest of the kit installs no pairing crypto. |
|
|
28
30
|
| **privacy** | `hashPii` / `pseudonymRef` / `eraseSubject` / `assertNoPii` — keyed hashing and random, **erasable** references; PII stays off-chain. |
|
|
29
31
|
|
|
32
|
+
> **Proof format spec:** the on-the-wire formats are specified in **[SPEC.md](./SPEC.md)** (`walletless-proof/v1`) with frozen conformance vectors in [`test/vectors.json`](./test/vectors.json) — anything that reproduces them interoperates and can verify a draw, trail, or receipt chain independently of this package.
|
|
33
|
+
|
|
30
34
|
## Quickstart
|
|
31
35
|
|
|
32
36
|
```js
|
package/SPEC.md
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# walletless-kit proof formats — `SPEC.md`
|
|
2
|
+
|
|
3
|
+
**Status:** v1 · **Scope:** the on-the-wire formats a *second implementation* must
|
|
4
|
+
reproduce to interoperate with `@kirkelabs/walletless-kit`. This is the contract
|
|
5
|
+
behind the project's core claim — *anyone can recompute the result without trusting
|
|
6
|
+
the software that produced it.* The frozen conformance vectors live in
|
|
7
|
+
[`test/vectors.json`](./test/vectors.json); a conforming implementation MUST
|
|
8
|
+
reproduce every hash, root, and proof there byte-for-byte.
|
|
9
|
+
|
|
10
|
+
Everything in this spec is computable with only SHA-256 and canonical JSON — no
|
|
11
|
+
blockchain, no network, no dependency on this package. The reference verifier
|
|
12
|
+
([`src/verify.js`](./src/verify.js)) depends only on
|
|
13
|
+
[`src/merkle.js`](./src/merkle.js), which depends only on `node:crypto`.
|
|
14
|
+
|
|
15
|
+
The key words MUST / MUST NOT / SHOULD are used per RFC 2119.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 1. Canonical JSON
|
|
20
|
+
|
|
21
|
+
All hashing is over **canonical JSON**:
|
|
22
|
+
|
|
23
|
+
- Objects: keys sorted by Unicode code point (ascending), no insignificant
|
|
24
|
+
whitespace, `undefined`-valued keys omitted.
|
|
25
|
+
- Arrays: element order preserved.
|
|
26
|
+
- Primitives: standard JSON encoding (`JSON.stringify` semantics); `null` kept.
|
|
27
|
+
|
|
28
|
+
This is byte-identical to `@kirkelabs/open-agent-access-core`'s `canonicalizeJson`
|
|
29
|
+
(cross-checked in `test/canonical.test.js`), so roots match whichever side produced
|
|
30
|
+
them. Reference: [`canonicalJson`](./src/merkle.js).
|
|
31
|
+
|
|
32
|
+
## 2. Merkle tree (RFC 6962)
|
|
33
|
+
|
|
34
|
+
Domain-separated, to prevent a leaf being reinterpreted as an internal node
|
|
35
|
+
(second-preimage forgery):
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
leaf(d) = SHA256( 0x00 ‖ canonicalJSON(d) )
|
|
39
|
+
node(l, r) = SHA256( 0x01 ‖ l ‖ r )
|
|
40
|
+
MTH({}) = SHA256("") # empty tree
|
|
41
|
+
MTH({d0}) = leaf(d0)
|
|
42
|
+
MTH(D[0:n]), n>1:
|
|
43
|
+
k = largest power of two strictly less than n
|
|
44
|
+
MTH = node( MTH(D[0:k]), MTH(D[k:n]) )
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
A lone (odd) node is **promoted** unchanged to the next level — never duplicated
|
|
48
|
+
(duplicating it is the Bitcoin CVE-2012-2459 ambiguity). The iterative
|
|
49
|
+
"pair-adjacent, promote-odd" construction in this kit equals the recursive
|
|
50
|
+
definition above for all `n` (verified `n = 0..40`).
|
|
51
|
+
|
|
52
|
+
- **Root:** lowercase hex of `MTH`.
|
|
53
|
+
- **Inclusion proof:** `{ index, leaf, path[] }`, each `path` step
|
|
54
|
+
`{ side: "left"|"right", hash }`. Verify by folding siblings into `leaf` and
|
|
55
|
+
comparing to the root. Reference: [`merkleProof` / `verifyMerkleProof`](./src/merkle.js).
|
|
56
|
+
|
|
57
|
+
## 3. Consistency proof (RFC 6962 §2.1.2)
|
|
58
|
+
|
|
59
|
+
Proves the tree of size `m` is a **prefix** of the tree of size `n` (`m ≤ n`): the
|
|
60
|
+
log only grew and was never rewritten. Format:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
{ oldSize: m, newSize: n, proof: [ hex, … ] }
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Verification (against `oldRoot`, `newRoot`) follows the certificate-transparency
|
|
67
|
+
reference algorithm; see [`verifyConsistencyProof`](./src/merkle.js). Pair with the
|
|
68
|
+
on-chain anchors (§7): anchor `oldRoot` at size `m`, later anchor `newRoot` at size
|
|
69
|
+
`n`, and the `m → n` transition is provably append-only.
|
|
70
|
+
|
|
71
|
+
## 4. Draw proof
|
|
72
|
+
|
|
73
|
+
Winner selection is a deterministic function of `(seed, entryCount)` — no
|
|
74
|
+
`Math.random`. Algorithm id `fisher-yates-sha256-v1`:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
rng stream: block_i = SHA256( seed ‖ ":" ‖ uint64LE(i) ), i = 0,1,2,…
|
|
78
|
+
uniformInt(maxExclusive): # unbiased, rejection-sampled
|
|
79
|
+
span = 2^48
|
|
80
|
+
limit = floor(span / maxExclusive) * maxExclusive
|
|
81
|
+
draw 6 bytes big-endian as v; reject while v ≥ limit; return v mod maxExclusive
|
|
82
|
+
shuffle: for i = n-1 … 1: j = uniformInt(i+1); swap(a[i], a[j])
|
|
83
|
+
winners: first k of shuffle([0..n-1]); map indices → entries
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Proof object:
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
{ algorithm, seed, entryCount, entriesRoot, winners[], winnerIndices[] }
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`entriesRoot` is the Merkle root (§2) over the **exact ordered entry set**.
|
|
93
|
+
Verify by (a) recomputing `entriesRoot` from the entries, (b) re-running the
|
|
94
|
+
shuffle, (c) comparing indices and mapped winners. Reference:
|
|
95
|
+
[`verifyDrawProof`](./src/verify.js) (independent of the producer code).
|
|
96
|
+
|
|
97
|
+
### 4.1 Entrant inclusion proof
|
|
98
|
+
|
|
99
|
+
A single entrant proves membership against `entriesRoot` alone, without seeing the
|
|
100
|
+
rest of the field: `{ entry, index, leaf, path }`. Verify that `leaf == leaf(entry)`
|
|
101
|
+
**and** the path recomputes `entriesRoot`. Reference: [`verifyEntryProof`](./src/draw.js).
|
|
102
|
+
|
|
103
|
+
## 5. Seed sources
|
|
104
|
+
|
|
105
|
+
A seed is `{ source, value, … }`. Fairness equals the seed — no more. Sources:
|
|
106
|
+
|
|
107
|
+
| `source` | Manipulable? | Notes |
|
|
108
|
+
|-----------------------|--------------|-------|
|
|
109
|
+
| `algorand-block-seed` | **yes** (`manipulable:true`) | A proposer can withhold/grind. Only with `commitSeedSource` + low value. |
|
|
110
|
+
| `drand` | no | BLS threshold beacon. MUST satisfy `randomness == SHA256(signature)`; optionally full BLS-verified. |
|
|
111
|
+
| `vrf` | depends | Caller supplies a verified VRF value+proof. |
|
|
112
|
+
| `beacon` | depends | Generic public beacon wrapper. |
|
|
113
|
+
|
|
114
|
+
**drand binding (verifiable with SHA-256 only):** `randomness = SHA256(signature)`,
|
|
115
|
+
both lowercase hex; `signature` is the BLS signature bytes. A future round committed
|
|
116
|
+
**before entries close** (via `commitSeedSource` / `drandRoundAt`) is unpredictable
|
|
117
|
+
and non-grindable. Reference: [`drandSeed`](./src/draw.js).
|
|
118
|
+
|
|
119
|
+
**drand BLS verification (full provenance):** that the signature is a valid League-
|
|
120
|
+
of-Entropy threshold signature — i.e. the randomness really came from the network —
|
|
121
|
+
is checked with BLS12-381 pairings. Message hashing per scheme:
|
|
122
|
+
|
|
123
|
+
| `schemeID` | sig group | pubkey group | message digest | hash-to-curve DST |
|
|
124
|
+
|---|---|---|---|---|
|
|
125
|
+
| `bls-unchained-g1-rfc9380` (quicknet) | G1 (48 B) | G2 (96 B) | `SHA256(round_be8)` | `BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_` |
|
|
126
|
+
| `pedersen-bls-chained` (legacy mainnet) | G2 (96 B) | G1 (48 B) | `SHA256(previous_signature ‖ round_be8)` | `BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_` |
|
|
127
|
+
|
|
128
|
+
`round_be8` is the round as an unsigned 64-bit big-endian integer. This check is
|
|
129
|
+
**optional** and lives in a separate module/import subpath
|
|
130
|
+
([`src/drand-bls.js`](./src/drand-bls.js), `@kirkelabs/walletless-kit/drand-bls`).
|
|
131
|
+
It is backed by `@noble/curves`, declared as an **optional peer dependency** —
|
|
132
|
+
install it (`npm i @noble/curves`) only if you want BLS verification; the core
|
|
133
|
+
package and the §1–§4 / §8 verifier pull in no pairing crypto. Frozen real-beacon
|
|
134
|
+
vectors: [`test/drand-vectors.json`](./test/drand-vectors.json).
|
|
135
|
+
|
|
136
|
+
## 6. Receipt chain
|
|
137
|
+
|
|
138
|
+
Each receipt is non-PII and hash-chained:
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
receiptHash = SHA256( canonicalJSON( receipt without {receiptHash, signature} ) )
|
|
142
|
+
receipt[i].previousHash == receipt[i-1].receiptHash (null for the first)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Verify by recomputing each `receiptHash` and checking the links. An optional
|
|
146
|
+
ed25519 `signature` over the receipt MAY be present (verified by the producing side
|
|
147
|
+
via oaa-core); the zero-dep verifier checks hashes and links only. Reference:
|
|
148
|
+
[`verifyReceiptChain`](./src/verify.js).
|
|
149
|
+
|
|
150
|
+
## 7. On-chain anchors (optional, non-normative for verification)
|
|
151
|
+
|
|
152
|
+
Commitments are written on-chain as a 0-amount self-payment whose note is
|
|
153
|
+
ASCII-tagged and ≤ 1 KB — **never** any PII or receipt/event body:
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
audit anchor: walletless-anchor:v1:<merkleRoot hex>
|
|
157
|
+
receipt attest: walletless-receipt:v1:<receiptHash hex>
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The chain is a timestamping/availability layer only; all verification in §1–§6 is
|
|
161
|
+
independent of it.
|
|
162
|
+
|
|
163
|
+
## 8. Proof bundle (`walletless-proof/v1`)
|
|
164
|
+
|
|
165
|
+
A single portable artifact packing a draw + optional entries, seed source, receipt
|
|
166
|
+
chain, trail root, and anchors:
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
{ bundleVersion: "walletless-proof/v1",
|
|
170
|
+
draw, seedSource, entries|null, receipts|null,
|
|
171
|
+
trail: { root, count }|null, anchors|null, meta|null,
|
|
172
|
+
bundleHash } # SHA256(canonicalJSON(bundle without bundleHash))
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`verifyBundle` re-derives each present section and returns a per-section verdict.
|
|
176
|
+
A bundle **with** `entries` is fully self-verifying; **without** them it is a
|
|
177
|
+
commitment that entrants check with their own §4.1 inclusion proofs. Reference:
|
|
178
|
+
[`bundleProof` / `verifyBundle`](./src/verify.js).
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## Conformance
|
|
183
|
+
|
|
184
|
+
An implementation conforms to **walletless-proof/v1** if, given
|
|
185
|
+
[`test/vectors.json`](./test/vectors.json), it reproduces: the Merkle roots (§2),
|
|
186
|
+
the consistency proof and its verification (§3), the draw proof and winners (§4),
|
|
187
|
+
the entrant inclusion proof (§4.1), and the drand `randomness = SHA256(signature)`
|
|
188
|
+
binding (§5). Version this document and the `bundleVersion` tag together; any change
|
|
189
|
+
to a hashing rule is a new major version with new frozen vectors.
|
package/package.json
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kirkelabs/walletless-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Walletless web architecture toolkit: low-friction onboarding (ephemeral custodial accounts), receipt-only on-chain proofs, a tamper-evident hash-chained + Merkle audit trail, segregated money ledgers, and a verifiable, recomputable draw. Charity prize-draws are the flagship example; every module is reusable for any walletless commerce flow. Built on @kirkelabs/open-agent-access-core and @kirkelabs/oaa-agent-kit. Free & open source from Kirke Labs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"walletless": "bin/cli.js"
|
|
8
8
|
},
|
|
9
9
|
"exports": {
|
|
10
|
-
".": "./src/index.js"
|
|
10
|
+
".": "./src/index.js",
|
|
11
|
+
"./drand-bls": "./src/drand-bls.js",
|
|
12
|
+
"./verify": "./src/verify.js"
|
|
11
13
|
},
|
|
12
14
|
"files": [
|
|
13
15
|
"bin/",
|
|
14
16
|
"src/",
|
|
15
17
|
"examples/",
|
|
18
|
+
"test/vectors.json",
|
|
19
|
+
"test/drand-vectors.json",
|
|
16
20
|
"LICENSE",
|
|
17
21
|
"LEGAL.md",
|
|
18
22
|
"README.md",
|
|
23
|
+
"SPEC.md",
|
|
19
24
|
"CHANGELOG.md"
|
|
20
25
|
],
|
|
21
26
|
"publishConfig": {
|
|
@@ -62,11 +67,20 @@
|
|
|
62
67
|
"node": ">=20"
|
|
63
68
|
},
|
|
64
69
|
"dependencies": {
|
|
65
|
-
"
|
|
70
|
+
"@kirkelabs/oaa-agent-kit": "^0.7.1",
|
|
66
71
|
"@kirkelabs/open-agent-access-core": "^0.1.0",
|
|
67
|
-
"
|
|
72
|
+
"algosdk": "^3.6.0"
|
|
73
|
+
},
|
|
74
|
+
"peerDependencies": {
|
|
75
|
+
"@noble/curves": "^1.9.7"
|
|
76
|
+
},
|
|
77
|
+
"peerDependenciesMeta": {
|
|
78
|
+
"@noble/curves": {
|
|
79
|
+
"optional": true
|
|
80
|
+
}
|
|
68
81
|
},
|
|
69
82
|
"devDependencies": {
|
|
83
|
+
"@noble/curves": "^1.9.7",
|
|
70
84
|
"eslint": "^9.0.0",
|
|
71
85
|
"prettier": "^3.0.0"
|
|
72
86
|
}
|
package/src/audit.js
CHANGED
|
@@ -12,96 +12,41 @@
|
|
|
12
12
|
* so it is built defensively (see `merkleRoot`).
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { createHash } from 'node:crypto';
|
|
16
15
|
import algosdk from 'algosdk';
|
|
17
16
|
import {
|
|
18
17
|
appendAccessEvent,
|
|
19
18
|
verifyAccessEventTrail,
|
|
20
19
|
hashAccessEvents,
|
|
21
|
-
canonicalizeJson,
|
|
22
20
|
} from '@kirkelabs/open-agent-access-core';
|
|
21
|
+
// The Merkle math lives in the zero-dependency `merkle.js` core so the standalone
|
|
22
|
+
// verifier (`verify.js`) and any second implementation can reproduce these exact
|
|
23
|
+
// roots without pulling in algosdk or oaa-core.
|
|
24
|
+
import {
|
|
25
|
+
merkleRoot,
|
|
26
|
+
merkleProof,
|
|
27
|
+
verifyMerkleProof,
|
|
28
|
+
consistencyProof,
|
|
29
|
+
verifyConsistencyProof,
|
|
30
|
+
} from './merkle.js';
|
|
23
31
|
|
|
24
|
-
|
|
25
|
-
// RFC 6962 domain-separation tags: leaves and internal nodes are hashed in
|
|
26
|
-
// DIFFERENT domains so a leaf can never be reinterpreted as an internal node
|
|
27
|
-
// (which would otherwise enable second-preimage forgery of the root).
|
|
28
|
-
const LEAF = Buffer.from([0x00]);
|
|
29
|
-
const NODE = Buffer.from([0x01]);
|
|
30
|
-
|
|
31
|
-
function sha256(...bufs) {
|
|
32
|
-
const h = createHash('sha256');
|
|
33
|
-
for (const b of bufs) h.update(b);
|
|
34
|
-
return h.digest();
|
|
35
|
-
}
|
|
36
|
-
/** Injective leaf encoding via canonical JSON, then domain-tagged hash. */
|
|
37
|
-
function leafHash(item) {
|
|
38
|
-
return sha256(LEAF, Buffer.from(canonicalizeJson(item), 'utf8'));
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Deterministic Merkle root (hex) over an ordered list of items.
|
|
43
|
-
*
|
|
44
|
-
* Defensive choices:
|
|
45
|
-
* - Domain separation: leaf = H(0x00‖data), node = H(0x01‖left‖right).
|
|
46
|
-
* - A lone (odd) node is PROMOTED to the next level unchanged — it is NOT
|
|
47
|
-
* duplicated (duplicating the last node is the Bitcoin CVE-2012-2459
|
|
48
|
-
* ambiguity, where two different trees share a root).
|
|
49
|
-
* - Empty list → H("") (RFC 6962 empty-tree root). Single item → its leaf hash.
|
|
50
|
-
*/
|
|
51
|
-
export function merkleRoot(items) {
|
|
52
|
-
if (!Array.isArray(items)) throw new Error('merkleRoot: items must be an array');
|
|
53
|
-
if (items.length > MAX_LEAVES) throw new Error('merkleRoot: too many leaves');
|
|
54
|
-
if (items.length === 0) return sha256(Buffer.alloc(0)).toString('hex');
|
|
55
|
-
let level = items.map(leafHash);
|
|
56
|
-
while (level.length > 1) {
|
|
57
|
-
const next = [];
|
|
58
|
-
for (let i = 0; i < level.length; i += 2) {
|
|
59
|
-
next.push(i + 1 < level.length ? sha256(NODE, level[i], level[i + 1]) : level[i]);
|
|
60
|
-
}
|
|
61
|
-
level = next;
|
|
62
|
-
}
|
|
63
|
-
return level[0].toString('hex');
|
|
64
|
-
}
|
|
32
|
+
export { merkleRoot, merkleProof, verifyMerkleProof, consistencyProof, verifyConsistencyProof };
|
|
65
33
|
|
|
66
34
|
/**
|
|
67
|
-
*
|
|
68
|
-
*
|
|
35
|
+
* Consistency proof that the first `oldSize` events of `trail` (an earlier,
|
|
36
|
+
* already-anchored state) are a verbatim prefix of the trail as it stands now —
|
|
37
|
+
* i.e. the operator only APPENDED and never rewrote history. This is the RFC 6962
|
|
38
|
+
* append-only guarantee; pair it with the on-chain anchors from `anchor()`:
|
|
39
|
+
* anchor the root at size m, later anchor the root at size n, and anyone can prove
|
|
40
|
+
* the m→n transition was append-only with `verifyTrailConsistency`.
|
|
41
|
+
* @returns {{oldSize:number,newSize:number,proof:string[]}}
|
|
69
42
|
*/
|
|
70
|
-
export function
|
|
71
|
-
|
|
72
|
-
throw new Error('merkleProof: index out of range');
|
|
73
|
-
let level = items.map(leafHash);
|
|
74
|
-
let idx = index;
|
|
75
|
-
const path = [];
|
|
76
|
-
while (level.length > 1) {
|
|
77
|
-
const next = [];
|
|
78
|
-
for (let i = 0; i < level.length; i += 2) {
|
|
79
|
-
if (i + 1 < level.length) {
|
|
80
|
-
next.push(sha256(NODE, level[i], level[i + 1]));
|
|
81
|
-
if (i === idx) path.push({ side: 'right', hash: level[i + 1].toString('hex') });
|
|
82
|
-
else if (i + 1 === idx) path.push({ side: 'left', hash: level[i].toString('hex') });
|
|
83
|
-
} else {
|
|
84
|
-
next.push(level[i]); // promoted; no sibling recorded for the lone node
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
idx = Math.floor(idx / 2);
|
|
88
|
-
level = next;
|
|
89
|
-
}
|
|
90
|
-
return { index, leaf: leafHash(items[index]).toString('hex'), path };
|
|
43
|
+
export function trailConsistencyProof(trail, oldSize) {
|
|
44
|
+
return consistencyProof(trail?.events ?? [], oldSize);
|
|
91
45
|
}
|
|
92
46
|
|
|
93
|
-
/** Verify
|
|
94
|
-
export function
|
|
95
|
-
|
|
96
|
-
let acc = Buffer.from(String(leaf), 'hex');
|
|
97
|
-
for (const step of path || []) {
|
|
98
|
-
const sib = Buffer.from(String(step.hash), 'hex');
|
|
99
|
-
acc = step.side === 'left' ? sha256(NODE, sib, acc) : sha256(NODE, acc, sib);
|
|
100
|
-
}
|
|
101
|
-
return { ok: acc.toString('hex') === String(root) };
|
|
102
|
-
} catch (e) {
|
|
103
|
-
return { ok: false, reason: `verify_error:${e.message}` };
|
|
104
|
-
}
|
|
47
|
+
/** Verify a trail consistency proof between two anchored roots. Never throws. */
|
|
48
|
+
export function verifyTrailConsistency(args) {
|
|
49
|
+
return verifyConsistencyProof(args);
|
|
105
50
|
}
|
|
106
51
|
|
|
107
52
|
/** A fresh empty trail. */
|
package/src/drand-bls.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* drand-bls.js — REAL BLS verification of drand beacons.
|
|
3
|
+
*
|
|
4
|
+
* `draw.js`'s `drandSeed` enforces the cheap, SHA-256-only binding
|
|
5
|
+
* `randomness == SHA256(signature)`. This module adds the strong guarantee: that
|
|
6
|
+
* the signature is a valid League-of-Entropy THRESHOLD signature over the round
|
|
7
|
+
* under the network's public key — i.e. the randomness was genuinely produced by
|
|
8
|
+
* the drand network and not fabricated. With this, a drand-seeded draw is
|
|
9
|
+
* non-manipulable AND its provenance is cryptographically proven, not asserted.
|
|
10
|
+
*
|
|
11
|
+
* It is kept in a SEPARATE module (and a separate import subpath) so the
|
|
12
|
+
* zero-dependency verifier in `verify.js` stays dependency-free: pull in the
|
|
13
|
+
* BLS18-381 pairing math (@noble/curves) only if and when you want this check.
|
|
14
|
+
*
|
|
15
|
+
* Two drand schemes are supported, matched by `schemeID`:
|
|
16
|
+
* - `bls-unchained-g1-rfc9380` (quicknet): signatures on G1, public key on G2,
|
|
17
|
+
* message = SHA256(round_be8). The recommended network for new draws.
|
|
18
|
+
* - `pedersen-bls-chained` (legacy mainnet): signatures on G2, public key on
|
|
19
|
+
* G1, message = SHA256(previous_signature ‖ round_be8).
|
|
20
|
+
*
|
|
21
|
+
* Verification math is provided by @noble/curves; its default hash-to-curve DSTs
|
|
22
|
+
* (`BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_` / `…G2…`) match drand's exactly.
|
|
23
|
+
* Cross-checked against live beacon rounds, frozen into `test/drand-vectors.json`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { createHash } from 'node:crypto';
|
|
27
|
+
|
|
28
|
+
// @noble/curves is an OPTIONAL peer dependency: the BLS12-381 pairing math is only
|
|
29
|
+
// needed for this module, so the core package (receipts / audit / ledger / draw /
|
|
30
|
+
// the zero-dep verifier) installs nothing extra. Load it lazily and fail with a
|
|
31
|
+
// clear, actionable message if a consumer reaches for BLS without installing it.
|
|
32
|
+
let bls = null;
|
|
33
|
+
try {
|
|
34
|
+
({ bls12_381: bls } = await import('@noble/curves/bls12-381'));
|
|
35
|
+
} catch {
|
|
36
|
+
bls = null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function requireBls() {
|
|
40
|
+
if (!bls)
|
|
41
|
+
throw new Error(
|
|
42
|
+
'drand BLS verification needs the optional peer dependency "@noble/curves". ' +
|
|
43
|
+
'Install it to enable this check: npm i @noble/curves',
|
|
44
|
+
);
|
|
45
|
+
return bls;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sha256(buf) {
|
|
49
|
+
return createHash('sha256').update(buf).digest();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** drand serialises the round number as an unsigned 64-bit big-endian integer. */
|
|
53
|
+
function roundBytes(round) {
|
|
54
|
+
const b = Buffer.alloc(8);
|
|
55
|
+
b.writeBigUInt64BE(BigInt(round));
|
|
56
|
+
return b;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Frozen drand network parameters. Public keys are published constants; pass your
|
|
61
|
+
* own `publicKey` to `makeDrandVerifier` to pin a different network.
|
|
62
|
+
*/
|
|
63
|
+
export const DRAND_QUICKNET_SCHEME = Object.freeze({
|
|
64
|
+
schemeID: 'bls-unchained-g1-rfc9380',
|
|
65
|
+
chainHash: '52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971',
|
|
66
|
+
publicKey:
|
|
67
|
+
'83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a',
|
|
68
|
+
genesisTime: 1692803367,
|
|
69
|
+
period: 3,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export const DRAND_DEFAULT_SCHEME = Object.freeze({
|
|
73
|
+
schemeID: 'pedersen-bls-chained',
|
|
74
|
+
chainHash: '8990e7a9aaed2ffed73dbd7092123d6f289930540d7651336225dc172e51b2ce',
|
|
75
|
+
publicKey:
|
|
76
|
+
'868f005eb8e6e4ca0a47c8a77ceaa5309a47978a7c71bc5cce96366b5d7a569937c529eeda66c7293784a9402801af31',
|
|
77
|
+
genesisTime: 1595431050,
|
|
78
|
+
period: 30,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/** Look up a known scheme by its `schemeID`. */
|
|
82
|
+
function knownScheme(schemeID) {
|
|
83
|
+
if (schemeID === DRAND_QUICKNET_SCHEME.schemeID) return DRAND_QUICKNET_SCHEME;
|
|
84
|
+
if (schemeID === DRAND_DEFAULT_SCHEME.schemeID) return DRAND_DEFAULT_SCHEME;
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Verify a single drand beacon's BLS signature (and, by default, the
|
|
90
|
+
* `randomness == SHA256(signature)` binding). Returns a boolean and never throws
|
|
91
|
+
* on bad beacon data — but DOES throw if the optional `@noble/curves` peer
|
|
92
|
+
* dependency is not installed (so a missing library is never mistaken for an
|
|
93
|
+
* invalid beacon). Selects the curve/message construction from `schemeID`.
|
|
94
|
+
*
|
|
95
|
+
* @param {object} beacon `{ round, signature, randomness?, previousSignature? }` (hex)
|
|
96
|
+
* @param {object} opts
|
|
97
|
+
* @param {string} opts.publicKey network public key (hex)
|
|
98
|
+
* @param {string} opts.schemeID one of the supported scheme ids
|
|
99
|
+
* @param {boolean} [opts.checkRandomness=true] also enforce randomness binding
|
|
100
|
+
* @returns {boolean}
|
|
101
|
+
*/
|
|
102
|
+
export function verifyDrandBeacon(beacon, { publicKey, schemeID, checkRandomness = true } = {}) {
|
|
103
|
+
const b = requireBls(); // throws a clear install error if the peer dep is absent
|
|
104
|
+
try {
|
|
105
|
+
const { round, signature, randomness } = beacon || {};
|
|
106
|
+
// Accept either the camelCase field (from `fetchDrandRound`) or the raw
|
|
107
|
+
// `previous_signature` field (straight from the drand HTTP API).
|
|
108
|
+
const previousSignature = beacon?.previousSignature ?? beacon?.previous_signature ?? null;
|
|
109
|
+
if (round == null || !signature || !publicKey || !schemeID) return false;
|
|
110
|
+
|
|
111
|
+
if (checkRandomness && randomness != null) {
|
|
112
|
+
if (sha256(Buffer.from(String(signature), 'hex')).toString('hex') !== String(randomness).toLowerCase())
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (schemeID === DRAND_QUICKNET_SCHEME.schemeID) {
|
|
117
|
+
// Unchained, signature on G1: message = SHA256(round_be8).
|
|
118
|
+
const digest = sha256(roundBytes(round));
|
|
119
|
+
const point = b.shortSignatures.hash(digest);
|
|
120
|
+
return b.shortSignatures.verify(String(signature), point, String(publicKey)) === true;
|
|
121
|
+
}
|
|
122
|
+
if (schemeID === DRAND_DEFAULT_SCHEME.schemeID) {
|
|
123
|
+
// Chained, signature on G2: message = SHA256(previous_signature ‖ round_be8).
|
|
124
|
+
if (!previousSignature) return false;
|
|
125
|
+
const digest = sha256(Buffer.concat([Buffer.from(String(previousSignature), 'hex'), roundBytes(round)]));
|
|
126
|
+
const point = b.longSignatures.hash(digest);
|
|
127
|
+
return b.longSignatures.verify(String(signature), point, String(publicKey)) === true;
|
|
128
|
+
}
|
|
129
|
+
return false; // unknown scheme — fail closed
|
|
130
|
+
} catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Build a `verifySignature` function for `drandSeed`. Bind it to a network and it
|
|
137
|
+
* returns an async predicate `({ round, signature, randomness, previousSignature })
|
|
138
|
+
* => boolean` that `drandSeed` will enforce.
|
|
139
|
+
*
|
|
140
|
+
* import { drandSeed, fetchDrandRound } from '@kirkelabs/walletless-kit';
|
|
141
|
+
* import { makeDrandVerifier, DRAND_QUICKNET_SCHEME } from '@kirkelabs/walletless-kit/drand-bls';
|
|
142
|
+
*
|
|
143
|
+
* const verify = makeDrandVerifier(DRAND_QUICKNET_SCHEME);
|
|
144
|
+
* const beacon = await fetchDrandRound(committedRound); // after the round exists
|
|
145
|
+
* const seed = await drandSeed(beacon, verify); // throws unless BLS-valid
|
|
146
|
+
* // seed.blsVerified === true
|
|
147
|
+
*
|
|
148
|
+
* @param {object} scheme `{ schemeID, publicKey }` (e.g. DRAND_QUICKNET_SCHEME). A
|
|
149
|
+
* bare scheme id string also works, resolving a known network's public key.
|
|
150
|
+
* @param {object} [overrides] e.g. `{ publicKey }` to pin a custom network key
|
|
151
|
+
*/
|
|
152
|
+
export function makeDrandVerifier(scheme, overrides = {}) {
|
|
153
|
+
const base = typeof scheme === 'string' ? knownScheme(scheme) : scheme;
|
|
154
|
+
if (!base) throw new Error('makeDrandVerifier: unknown scheme — pass { schemeID, publicKey }');
|
|
155
|
+
const schemeID = base.schemeID;
|
|
156
|
+
const publicKey = overrides.publicKey ?? base.publicKey;
|
|
157
|
+
if (!schemeID || !publicKey)
|
|
158
|
+
throw new Error('makeDrandVerifier: schemeID and publicKey are required');
|
|
159
|
+
// Defensive: public key length must match the scheme's group (G2=96B, G1=48B).
|
|
160
|
+
const expectBytes = schemeID === DRAND_QUICKNET_SCHEME.schemeID ? 96 : 48;
|
|
161
|
+
if (String(publicKey).length !== expectBytes * 2)
|
|
162
|
+
throw new Error(`makeDrandVerifier: publicKey must be ${expectBytes} bytes for ${schemeID}`);
|
|
163
|
+
return async (beacon) => verifyDrandBeacon(beacon, { publicKey, schemeID });
|
|
164
|
+
}
|