veritrail 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/LICENSE +21 -0
- package/README.md +163 -0
- package/dist/checkpoint.d.ts +19 -0
- package/dist/checkpoint.js +27 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +127 -0
- package/dist/hash.d.ts +16 -0
- package/dist/hash.js +43 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/log.d.ts +23 -0
- package/dist/log.js +44 -0
- package/dist/merkle.d.ts +21 -0
- package/dist/merkle.js +135 -0
- package/dist/serialize.d.ts +35 -0
- package/dist/serialize.js +96 -0
- package/dist/smt.d.ts +19 -0
- package/dist/smt.js +107 -0
- package/dist/store.d.ts +38 -0
- package/dist/store.js +147 -0
- package/docs/adr/0001-hashing.md +20 -0
- package/docs/adr/0002-storage-and-signing.md +22 -0
- package/docs/adr/0003-sparse-merkle-tree.md +22 -0
- package/package.json +32 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aris Rhiannon
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# veritrail
|
|
2
|
+
|
|
3
|
+
[](https://github.com/ArisRhiannon/veritrail/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
**Tamper-evident, verifiable append-only logs and maps for TypeScript.** Zero
|
|
6
|
+
dependencies. RFC 6962 / RFC 9162 Merkle trees with **inclusion *and* consistency**
|
|
7
|
+
proofs, Ed25519-signed checkpoints, and a sparse-Merkle **verifiable map** (inclusion +
|
|
8
|
+
non-inclusion proofs). Runs anywhere Node or Bun runs. No server, no network.
|
|
9
|
+
|
|
10
|
+
## Why
|
|
11
|
+
|
|
12
|
+
The JS/TS ecosystem has generic Merkle-tree libraries, but few modern, dependency-light,
|
|
13
|
+
spec-faithful primitives for building your own *transparency logs* / tamper-evident audit
|
|
14
|
+
trails with both inclusion and consistency proofs. Robust implementations live in Rust
|
|
15
|
+
(`ct-merkle`) or in server-scale infrastructure (Trillian). `veritrail` fills that gap as
|
|
16
|
+
a small, auditable library: its proofs are byte-for-byte interoperable with the RFC 6962
|
|
17
|
+
reference implementation (see the cross-implementation vectors in `test/`), and the whole
|
|
18
|
+
core fits in a few hundred lines you can read in an afternoon.
|
|
19
|
+
|
|
20
|
+
It targets small-to-moderate logs (in-memory or single-file). It is **not** a replacement
|
|
21
|
+
for Trillian/CT at billions of entries — see [Performance & scale](#performance--scale).
|
|
22
|
+
|
|
23
|
+
Use it for: supply-chain / release transparency, compliance audit trails (provably
|
|
24
|
+
append-only), verifiable action logs for agents/automation, secure timestamping, and
|
|
25
|
+
key-transparency-style verifiable maps.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
npm i veritrail # or: bun add veritrail / pnpm add veritrail / yarn add veritrail
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Ships compiled ESM + type declarations (`dist/`), so it imports under plain Node ≥ 20 (no
|
|
34
|
+
loader needed) and Bun. The `veritrail` CLI runs on Node. From source: `git clone … &&
|
|
35
|
+
bun install && bun run check`.
|
|
36
|
+
|
|
37
|
+
## Data model
|
|
38
|
+
|
|
39
|
+
Hashing follows RFC 6962 §2.1 (domain-separated):
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
leafHash(entry) = SHA256(0x00 || entry)
|
|
43
|
+
nodeHash(left, right) = SHA256(0x01 || left || right)
|
|
44
|
+
emptyRoot() = SHA256("")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- **Log**: an ordered list of entries; its *Merkle Tree Head* (root) commits to the whole
|
|
48
|
+
history. An **inclusion proof** shows an entry is in the tree; a **consistency proof**
|
|
49
|
+
shows tree *n* is an append-only extension of tree *m* (nothing was edited or removed).
|
|
50
|
+
- **Checkpoint**: `{size, rootHash, timestamp}`, optionally **Ed25519-signed** — a
|
|
51
|
+
portable, independently verifiable commitment to the log state.
|
|
52
|
+
- **Verifiable map** (sparse Merkle tree, 256-bit keys): proves `key → value`
|
|
53
|
+
(inclusion) *and* `key is absent` (non-inclusion).
|
|
54
|
+
|
|
55
|
+
## Library quickstart
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { Log, verifyInclusion, verifyConsistency, generateKeyPair, verifyCheckpoint, utf8 } from "veritrail";
|
|
59
|
+
|
|
60
|
+
const log = new Log();
|
|
61
|
+
log.append(utf8("event-1"));
|
|
62
|
+
log.append(utf8("event-2"));
|
|
63
|
+
|
|
64
|
+
// Inclusion: prove entry 0 is in the log.
|
|
65
|
+
const n = log.size, root = log.root();
|
|
66
|
+
const proof = log.inclusionProof(0);
|
|
67
|
+
verifyInclusion(proof, 0, n, log.leaf(0), root); // => true
|
|
68
|
+
|
|
69
|
+
// Consistency: prove the size-2 tree extends the size-1 tree.
|
|
70
|
+
const before = new Log(); before.append(utf8("event-1"));
|
|
71
|
+
const cproof = log.consistencyProof(1);
|
|
72
|
+
verifyConsistency(cproof, 1, 2, before.root(), root); // => true
|
|
73
|
+
|
|
74
|
+
// Signed checkpoint.
|
|
75
|
+
const { publicKey, privateKey } = generateKeyPair();
|
|
76
|
+
const { checkpoint, signature } = log.signedCheckpoint(privateKey);
|
|
77
|
+
verifyCheckpoint(checkpoint, signature, publicKey); // => true
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Verifiable map:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import { SparseMerkleTree, smtKey, verifyMapInclusion, verifyMapNonInclusion, utf8 } from "veritrail";
|
|
84
|
+
|
|
85
|
+
const t = new SparseMerkleTree();
|
|
86
|
+
t.set(smtKey("alice"), utf8("100"));
|
|
87
|
+
const root = t.root();
|
|
88
|
+
verifyMapInclusion(smtKey("alice"), utf8("100"), t.proof(smtKey("alice")), root); // true
|
|
89
|
+
verifyMapNonInclusion(smtKey("bob"), t.proof(smtKey("bob")), root); // true
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## CLI
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
veritrail keygen priv.pem pub.pem
|
|
96
|
+
veritrail append log.json "first entry" # -> size=1 root=…
|
|
97
|
+
veritrail append log.json "second entry" # -> size=2 root=…
|
|
98
|
+
veritrail prove log.json 0 > incl.json # JSON inclusion proof
|
|
99
|
+
veritrail verify incl.json # -> OK (exit 0)
|
|
100
|
+
veritrail consistency log.json 1 2 > cons.json
|
|
101
|
+
veritrail verify cons.json # -> OK
|
|
102
|
+
veritrail sign log.json priv.pem > signed.json
|
|
103
|
+
veritrail audit log.json signed.json pub.pem # -> OK (recomputes root + checks signature)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`verify` and `audit` exit `0` when valid and non-zero when tampering is detected.
|
|
107
|
+
|
|
108
|
+
## Threat model
|
|
109
|
+
|
|
110
|
+
Protects against **undetectable mutation of history**: editing, reordering, deleting, or
|
|
111
|
+
truncating past entries is caught by inclusion/consistency proof verification, and a
|
|
112
|
+
signed checkpoint binds a specific `(size, root)` to a key holder. It does **not** provide
|
|
113
|
+
confidentiality (entries are not encrypted), availability, or protection against an
|
|
114
|
+
attacker who controls the verifier's copy of the trusted root/public key. Crypto uses the
|
|
115
|
+
platform `node:crypto` (SHA-256, Ed25519); verification performs no secret-dependent work.
|
|
116
|
+
|
|
117
|
+
## Performance & scale
|
|
118
|
+
|
|
119
|
+
Honest complexity (the core favors auditability over asymptotics):
|
|
120
|
+
|
|
121
|
+
- `merkleRoot` / `inclusionProof` / `consistencyProof` are **recomputed from the full
|
|
122
|
+
entry set** — O(n) hashes per call (proof generation does not yet use cached subtrees).
|
|
123
|
+
- `FileStore.append` rewrites the whole file — O(n) I/O per append.
|
|
124
|
+
- `SparseMerkleTree` recomputes over populated nodes — O(k·256) for k entries.
|
|
125
|
+
- Verification (`verifyInclusion` / `verifyConsistency`) is O(log n) and supports tree
|
|
126
|
+
sizes up to `Number.MAX_SAFE_INTEGER` (2⁵³−1).
|
|
127
|
+
|
|
128
|
+
This is comfortable for thousands to low-millions of entries. For billion-entry,
|
|
129
|
+
high-throughput logs use Trillian / a tiled log; incremental hashing and cached subtrees
|
|
130
|
+
are on the roadmap.
|
|
131
|
+
|
|
132
|
+
**Concurrency:** `FileStore` serializes appends across processes with an exclusive lock
|
|
133
|
+
file (stale-lock recovery included) and writes atomically (`temp → fsync → rename`), so a
|
|
134
|
+
crash or a concurrent writer can neither corrupt the store nor lose an entry. The lock is
|
|
135
|
+
advisory with a 10 s stale timeout (a crashed writer's lock is reclaimed); it is **not**
|
|
136
|
+
intended for high-contention or suspendable workloads, and it is a single-machine store,
|
|
137
|
+
not a distributed one.
|
|
138
|
+
|
|
139
|
+
## API summary
|
|
140
|
+
|
|
141
|
+
| Area | Exports |
|
|
142
|
+
|------|---------|
|
|
143
|
+
| Hash | `sha256`, `leafHash`, `nodeHash`, `emptyRoot`, `equal`, `toHex`, `fromHex`, `utf8` |
|
|
144
|
+
| Merkle | `merkleRoot`, `inclusionPath`, `verifyInclusion`, `consistencyProof`, `verifyConsistency` |
|
|
145
|
+
| Log/Store | `Log`, `Store`, `MemoryStore`, `FileStore` |
|
|
146
|
+
| Checkpoint | `Checkpoint`, `generateKeyPair`, `signCheckpoint`, `verifyCheckpoint`, `encodeCheckpoint`, PEM import/export |
|
|
147
|
+
| Verifiable map | `SparseMerkleTree`, `smtKey`, `smtEmptyRoot`, `verifyMapInclusion`, `verifyMapNonInclusion` |
|
|
148
|
+
| Serialization | `inclusionToJSON`, `consistencyToJSON`, `verifyBundleJSON`, checkpoint (de)serializers |
|
|
149
|
+
|
|
150
|
+
## Status
|
|
151
|
+
|
|
152
|
+
v0.2 covers the full v1 scope: Merkle core, append-only log with signed checkpoints,
|
|
153
|
+
verifiable map, CLI, RFC 6962 cross-implementation vectors + 500-trial property tests, CI.
|
|
154
|
+
Proof verifiers are total (never throw on untrusted input) and support tree sizes up to
|
|
155
|
+
`Number.MAX_SAFE_INTEGER`; the file store writes atomically and durably. Key design
|
|
156
|
+
decisions are recorded in `docs/adr/`. Roadmap: networked witness/gossip, tiled logs,
|
|
157
|
+
inclusion-proof batching.
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
[MIT](LICENSE) © 2026 Aris Rhiannon. Permissive, OSI-approved open source — free to
|
|
162
|
+
use, modify, and embed (including in commercial and networked services) with no
|
|
163
|
+
revenue or headcount restrictions.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type KeyObject } from "node:crypto";
|
|
2
|
+
/** A signed-tree-head: a commitment to the log state at a point in time. */
|
|
3
|
+
export interface Checkpoint {
|
|
4
|
+
size: number;
|
|
5
|
+
rootHash: Uint8Array;
|
|
6
|
+
timestamp: number;
|
|
7
|
+
}
|
|
8
|
+
/** Deterministic, unambiguous byte encoding used as the signing payload. */
|
|
9
|
+
export declare function encodeCheckpoint(c: Checkpoint): Uint8Array;
|
|
10
|
+
export declare function generateKeyPair(): {
|
|
11
|
+
publicKey: KeyObject;
|
|
12
|
+
privateKey: KeyObject;
|
|
13
|
+
};
|
|
14
|
+
export declare function signCheckpoint(c: Checkpoint, privateKey: KeyObject): Uint8Array;
|
|
15
|
+
export declare function verifyCheckpoint(c: Checkpoint, signature: Uint8Array, publicKey: KeyObject): boolean;
|
|
16
|
+
export declare function exportPublicKeyPem(key: KeyObject): string;
|
|
17
|
+
export declare function exportPrivateKeyPem(key: KeyObject): string;
|
|
18
|
+
export declare function importPublicKeyPem(pem: string): KeyObject;
|
|
19
|
+
export declare function importPrivateKeyPem(pem: string): KeyObject;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { generateKeyPairSync, sign, verify, createPublicKey, createPrivateKey } from "node:crypto";
|
|
2
|
+
import { toHex, utf8 } from "./hash.js";
|
|
3
|
+
/** Deterministic, unambiguous byte encoding used as the signing payload. */
|
|
4
|
+
export function encodeCheckpoint(c) {
|
|
5
|
+
return utf8(`veritrail-checkpoint\n${c.size}\n${toHex(c.rootHash)}\n${c.timestamp}\n`);
|
|
6
|
+
}
|
|
7
|
+
export function generateKeyPair() {
|
|
8
|
+
return generateKeyPairSync("ed25519");
|
|
9
|
+
}
|
|
10
|
+
export function signCheckpoint(c, privateKey) {
|
|
11
|
+
return new Uint8Array(sign(null, encodeCheckpoint(c), privateKey));
|
|
12
|
+
}
|
|
13
|
+
export function verifyCheckpoint(c, signature, publicKey) {
|
|
14
|
+
return verify(null, encodeCheckpoint(c), publicKey, signature);
|
|
15
|
+
}
|
|
16
|
+
export function exportPublicKeyPem(key) {
|
|
17
|
+
return key.export({ type: "spki", format: "pem" }).toString();
|
|
18
|
+
}
|
|
19
|
+
export function exportPrivateKeyPem(key) {
|
|
20
|
+
return key.export({ type: "pkcs8", format: "pem" }).toString();
|
|
21
|
+
}
|
|
22
|
+
export function importPublicKeyPem(pem) {
|
|
23
|
+
return createPublicKey(pem);
|
|
24
|
+
}
|
|
25
|
+
export function importPrivateKeyPem(pem) {
|
|
26
|
+
return createPrivateKey(pem);
|
|
27
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { FileStore } from "./store.js";
|
|
4
|
+
import { Log } from "./log.js";
|
|
5
|
+
import { merkleRoot, consistencyProof } from "./merkle.js";
|
|
6
|
+
import { toHex, utf8, equal } from "./hash.js";
|
|
7
|
+
import { inclusionToJSON, consistencyToJSON, verifyBundleJSON, signedCheckpointToJSON, signedCheckpointFromJSON, } from "./serialize.js";
|
|
8
|
+
import { generateKeyPair, exportPublicKeyPem, exportPrivateKeyPem, importPrivateKeyPem, importPublicKeyPem, verifyCheckpoint, } from "./checkpoint.js";
|
|
9
|
+
function die(msg, code = 2) {
|
|
10
|
+
console.error(msg);
|
|
11
|
+
process.exit(code);
|
|
12
|
+
}
|
|
13
|
+
// Convert any unexpected error (missing/unreadable file, malformed JSON, bad
|
|
14
|
+
// hex, bad PEM, …) into a clean message with exit code 2. This keeps the
|
|
15
|
+
// pipeline contract crisp: 0 = valid, 1 = verification FAIL, 2 = usage/format
|
|
16
|
+
// error — and never leaks a stack trace on untrusted input.
|
|
17
|
+
process.on("uncaughtException", (e) => die(`error: ${e instanceof Error ? e.message : String(e)}`, 2));
|
|
18
|
+
const HELP = `veritrail — tamper-evident verifiable logs
|
|
19
|
+
commands:
|
|
20
|
+
append <store> <entry> append an entry; prints size + root
|
|
21
|
+
root <store> print current size + root
|
|
22
|
+
prove <store> <index> emit a JSON inclusion proof
|
|
23
|
+
consistency <store> <m> <n> emit a JSON consistency proof (sizes m<=n)
|
|
24
|
+
verify <bundle.json|-> verify a proof bundle (exit 0 ok / 1 fail)
|
|
25
|
+
keygen <priv.pem> <pub.pem> generate an Ed25519 key pair
|
|
26
|
+
sign <store> <priv.pem> emit a signed checkpoint for current state
|
|
27
|
+
audit <store> <signed.json> <pub.pem> verify root + signature (exit 0/1)`;
|
|
28
|
+
const [cmd, ...args] = process.argv.slice(2);
|
|
29
|
+
const open = (f) => new Log(new FileStore(f));
|
|
30
|
+
switch (cmd) {
|
|
31
|
+
case "append": {
|
|
32
|
+
const [f, entry] = args;
|
|
33
|
+
if (!f || entry === undefined)
|
|
34
|
+
die("usage: append <store> <entry>");
|
|
35
|
+
const l = open(f);
|
|
36
|
+
const size = l.append(utf8(entry));
|
|
37
|
+
console.log(`size=${size} root=${toHex(l.root())}`);
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
case "root": {
|
|
41
|
+
const [f] = args;
|
|
42
|
+
if (!f)
|
|
43
|
+
die("usage: root <store>");
|
|
44
|
+
const l = open(f);
|
|
45
|
+
console.log(`size=${l.size} root=${toHex(l.root())}`);
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
case "prove": {
|
|
49
|
+
const [f, idx] = args;
|
|
50
|
+
if (!f || idx === undefined)
|
|
51
|
+
die("usage: prove <store> <index>");
|
|
52
|
+
const l = open(f);
|
|
53
|
+
const i = Number(idx);
|
|
54
|
+
if (!Number.isInteger(i) || i < 0 || i >= l.size)
|
|
55
|
+
die(`index out of range (size=${l.size})`);
|
|
56
|
+
const b = { type: "inclusion", index: i, treeSize: l.size, leaf: l.leaf(i), path: l.inclusionProof(i), root: l.root() };
|
|
57
|
+
console.log(inclusionToJSON(b));
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
case "consistency": {
|
|
61
|
+
const [f, ms, ns] = args;
|
|
62
|
+
if (!f || ms === undefined || ns === undefined)
|
|
63
|
+
die("usage: consistency <store> <m> <n>");
|
|
64
|
+
const l = open(f);
|
|
65
|
+
const m = Number(ms), n = Number(ns), es = l.entries();
|
|
66
|
+
if (!(Number.isInteger(m) && Number.isInteger(n) && 0 < m && m <= n && n <= es.length))
|
|
67
|
+
die(`bad range (size=${es.length})`);
|
|
68
|
+
const sub = es.slice(0, n);
|
|
69
|
+
const b = { type: "consistency", first: m, second: n, firstRoot: merkleRoot(es.slice(0, m)), secondRoot: merkleRoot(sub), path: consistencyProof(m, sub) };
|
|
70
|
+
console.log(consistencyToJSON(b));
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
case "verify": {
|
|
74
|
+
const [f] = args;
|
|
75
|
+
const json = f && f !== "-" ? readFileSync(f, "utf8") : readFileSync(0, "utf8");
|
|
76
|
+
const ok = verifyBundleJSON(json);
|
|
77
|
+
console.log(ok ? "OK" : "FAIL");
|
|
78
|
+
process.exit(ok ? 0 : 1);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
case "keygen": {
|
|
82
|
+
const [priv, pub] = args;
|
|
83
|
+
if (!priv || !pub)
|
|
84
|
+
die("usage: keygen <priv.pem> <pub.pem>");
|
|
85
|
+
const { publicKey, privateKey } = generateKeyPair();
|
|
86
|
+
writeFileSync(priv, exportPrivateKeyPem(privateKey));
|
|
87
|
+
writeFileSync(pub, exportPublicKeyPem(publicKey));
|
|
88
|
+
console.log("keys written");
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
case "sign": {
|
|
92
|
+
const [f, priv] = args;
|
|
93
|
+
if (!f || !priv)
|
|
94
|
+
die("usage: sign <store> <priv.pem>");
|
|
95
|
+
const l = open(f);
|
|
96
|
+
const sc = l.signedCheckpoint(importPrivateKeyPem(readFileSync(priv, "utf8")), Date.now());
|
|
97
|
+
console.log(signedCheckpointToJSON(sc));
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
case "audit": {
|
|
101
|
+
const [f, scFile, pubFile] = args;
|
|
102
|
+
if (!f || !scFile || !pubFile)
|
|
103
|
+
die("usage: audit <store> <signed.json> <pub.pem>");
|
|
104
|
+
const l = open(f);
|
|
105
|
+
const sc = signedCheckpointFromJSON(readFileSync(scFile, "utf8"));
|
|
106
|
+
const pub = importPublicKeyPem(readFileSync(pubFile, "utf8"));
|
|
107
|
+
if (sc.checkpoint.size > l.size)
|
|
108
|
+
die(`TAMPERED: checkpoint size ${sc.checkpoint.size} > log size ${l.size}`, 1);
|
|
109
|
+
const recomputed = merkleRoot(l.entries().slice(0, sc.checkpoint.size));
|
|
110
|
+
const rootOk = equal(recomputed, sc.checkpoint.rootHash);
|
|
111
|
+
const sigOk = verifyCheckpoint(sc.checkpoint, sc.signature, pub);
|
|
112
|
+
if (rootOk && sigOk) {
|
|
113
|
+
console.log(`OK size=${sc.checkpoint.size} root=${toHex(sc.checkpoint.rootHash)}`);
|
|
114
|
+
process.exit(0);
|
|
115
|
+
}
|
|
116
|
+
die(`TAMPERED rootOk=${rootOk} sigOk=${sigOk}`, 1);
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
case "help":
|
|
120
|
+
case "--help":
|
|
121
|
+
case "-h":
|
|
122
|
+
case undefined:
|
|
123
|
+
console.log(HELP);
|
|
124
|
+
break;
|
|
125
|
+
default:
|
|
126
|
+
die(`unknown command: ${cmd}\n\n${HELP}`, 2);
|
|
127
|
+
}
|
package/dist/hash.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Domain-separation prefixes per RFC 6962 §2.1. */
|
|
2
|
+
export declare const LEAF_PREFIX = 0;
|
|
3
|
+
export declare const NODE_PREFIX = 1;
|
|
4
|
+
/** SHA-256 over the concatenation of chunks. */
|
|
5
|
+
export declare function sha256(...chunks: Uint8Array[]): Uint8Array;
|
|
6
|
+
/** Leaf hash: SHA-256(0x00 || entry). */
|
|
7
|
+
export declare function leafHash(entry: Uint8Array): Uint8Array;
|
|
8
|
+
/** Interior node hash: SHA-256(0x01 || left || right). */
|
|
9
|
+
export declare function nodeHash(left: Uint8Array, right: Uint8Array): Uint8Array;
|
|
10
|
+
/** Root of the empty tree: SHA-256(""). */
|
|
11
|
+
export declare function emptyRoot(): Uint8Array;
|
|
12
|
+
/** Constant-time-ish equality for hashes (no early exit). */
|
|
13
|
+
export declare function equal(a: Uint8Array, b: Uint8Array): boolean;
|
|
14
|
+
export declare function toHex(b: Uint8Array): string;
|
|
15
|
+
export declare function fromHex(s: string): Uint8Array;
|
|
16
|
+
export declare function utf8(s: string): Uint8Array;
|
package/dist/hash.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
/** Domain-separation prefixes per RFC 6962 §2.1. */
|
|
3
|
+
export const LEAF_PREFIX = 0x00;
|
|
4
|
+
export const NODE_PREFIX = 0x01;
|
|
5
|
+
/** SHA-256 over the concatenation of chunks. */
|
|
6
|
+
export function sha256(...chunks) {
|
|
7
|
+
const h = createHash("sha256");
|
|
8
|
+
for (const c of chunks)
|
|
9
|
+
h.update(c);
|
|
10
|
+
return new Uint8Array(h.digest());
|
|
11
|
+
}
|
|
12
|
+
/** Leaf hash: SHA-256(0x00 || entry). */
|
|
13
|
+
export function leafHash(entry) {
|
|
14
|
+
return sha256(Uint8Array.of(LEAF_PREFIX), entry);
|
|
15
|
+
}
|
|
16
|
+
/** Interior node hash: SHA-256(0x01 || left || right). */
|
|
17
|
+
export function nodeHash(left, right) {
|
|
18
|
+
return sha256(Uint8Array.of(NODE_PREFIX), left, right);
|
|
19
|
+
}
|
|
20
|
+
/** Root of the empty tree: SHA-256(""). */
|
|
21
|
+
export function emptyRoot() {
|
|
22
|
+
return sha256(new Uint8Array(0));
|
|
23
|
+
}
|
|
24
|
+
/** Constant-time-ish equality for hashes (no early exit). */
|
|
25
|
+
export function equal(a, b) {
|
|
26
|
+
if (a.length !== b.length)
|
|
27
|
+
return false;
|
|
28
|
+
let d = 0;
|
|
29
|
+
for (let i = 0; i < a.length; i++)
|
|
30
|
+
d |= a[i] ^ b[i];
|
|
31
|
+
return d === 0;
|
|
32
|
+
}
|
|
33
|
+
export function toHex(b) {
|
|
34
|
+
return Buffer.from(b).toString("hex");
|
|
35
|
+
}
|
|
36
|
+
export function fromHex(s) {
|
|
37
|
+
if (s.length % 2 !== 0 || /[^0-9a-fA-F]/.test(s))
|
|
38
|
+
throw new Error("invalid hex");
|
|
39
|
+
return new Uint8Array(Buffer.from(s, "hex"));
|
|
40
|
+
}
|
|
41
|
+
export function utf8(s) {
|
|
42
|
+
return new TextEncoder().encode(s);
|
|
43
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/log.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type Store } from "./store.js";
|
|
2
|
+
import { type Checkpoint } from "./checkpoint.js";
|
|
3
|
+
import type { KeyObject } from "node:crypto";
|
|
4
|
+
/** An append-only, tamper-evident log over a pluggable Store. */
|
|
5
|
+
export declare class Log {
|
|
6
|
+
private readonly store;
|
|
7
|
+
constructor(store?: Store);
|
|
8
|
+
get size(): number;
|
|
9
|
+
append(entry: Uint8Array): number;
|
|
10
|
+
entries(): Uint8Array[];
|
|
11
|
+
entry(index: number): Uint8Array;
|
|
12
|
+
/** Leaf hash of the entry at `index`. */
|
|
13
|
+
leaf(index: number): Uint8Array;
|
|
14
|
+
/** Current Merkle tree head (root). */
|
|
15
|
+
root(): Uint8Array;
|
|
16
|
+
inclusionProof(index: number): Uint8Array[];
|
|
17
|
+
consistencyProof(first: number): Uint8Array[];
|
|
18
|
+
checkpoint(timestamp?: number): Checkpoint;
|
|
19
|
+
signedCheckpoint(privateKey: KeyObject, timestamp?: number): {
|
|
20
|
+
checkpoint: Checkpoint;
|
|
21
|
+
signature: Uint8Array;
|
|
22
|
+
};
|
|
23
|
+
}
|
package/dist/log.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { leafHash } from "./hash.js";
|
|
2
|
+
import { merkleRoot, inclusionPath, consistencyProof } from "./merkle.js";
|
|
3
|
+
import { MemoryStore } from "./store.js";
|
|
4
|
+
import { signCheckpoint } from "./checkpoint.js";
|
|
5
|
+
/** An append-only, tamper-evident log over a pluggable Store. */
|
|
6
|
+
export class Log {
|
|
7
|
+
store;
|
|
8
|
+
constructor(store = new MemoryStore()) {
|
|
9
|
+
this.store = store;
|
|
10
|
+
}
|
|
11
|
+
get size() {
|
|
12
|
+
return this.store.size();
|
|
13
|
+
}
|
|
14
|
+
append(entry) {
|
|
15
|
+
return this.store.append(entry);
|
|
16
|
+
}
|
|
17
|
+
entries() {
|
|
18
|
+
return this.store.all();
|
|
19
|
+
}
|
|
20
|
+
entry(index) {
|
|
21
|
+
return this.store.get(index);
|
|
22
|
+
}
|
|
23
|
+
/** Leaf hash of the entry at `index`. */
|
|
24
|
+
leaf(index) {
|
|
25
|
+
return leafHash(this.store.get(index));
|
|
26
|
+
}
|
|
27
|
+
/** Current Merkle tree head (root). */
|
|
28
|
+
root() {
|
|
29
|
+
return merkleRoot(this.store.all());
|
|
30
|
+
}
|
|
31
|
+
inclusionProof(index) {
|
|
32
|
+
return inclusionPath(index, this.store.all());
|
|
33
|
+
}
|
|
34
|
+
consistencyProof(first) {
|
|
35
|
+
return consistencyProof(first, this.store.all());
|
|
36
|
+
}
|
|
37
|
+
checkpoint(timestamp = Date.now()) {
|
|
38
|
+
return { size: this.size, rootHash: this.root(), timestamp };
|
|
39
|
+
}
|
|
40
|
+
signedCheckpoint(privateKey, timestamp = Date.now()) {
|
|
41
|
+
const checkpoint = this.checkpoint(timestamp);
|
|
42
|
+
return { checkpoint, signature: signCheckpoint(checkpoint, privateKey) };
|
|
43
|
+
}
|
|
44
|
+
}
|
package/dist/merkle.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merkle Tree Hash (RFC 6962 §2.1). `entries` are raw leaf data (NOT pre-hashed).
|
|
3
|
+
* MTH({}) = SHA256(""); MTH({d0}) = leafHash(d0);
|
|
4
|
+
* MTH(D) = nodeHash(MTH(D[0:k]), MTH(D[k:n])), k = largest power of 2 < n.
|
|
5
|
+
*/
|
|
6
|
+
export declare function merkleRoot(entries: Uint8Array[]): Uint8Array;
|
|
7
|
+
/** Inclusion (audit) path for leaf index m in a tree of entries (RFC 6962 §2.1.1). */
|
|
8
|
+
export declare function inclusionPath(m: number, entries: Uint8Array[]): Uint8Array[];
|
|
9
|
+
/**
|
|
10
|
+
* Verify an inclusion proof (RFC 9162 §2.1.3.2). `leaf` is the leaf hash.
|
|
11
|
+
* Reconstructs the root from `leaf` + `path` and compares to `root`.
|
|
12
|
+
* Tree sizes up to Number.MAX_SAFE_INTEGER (2^53 − 1) are supported.
|
|
13
|
+
*/
|
|
14
|
+
export declare function verifyInclusion(path: Uint8Array[], index: number, treeSize: number, leaf: Uint8Array, root: Uint8Array): boolean;
|
|
15
|
+
/** Consistency proof between first size m and full tree (RFC 6962 §2.1.2). 0 < m <= n. */
|
|
16
|
+
export declare function consistencyProof(m: number, entries: Uint8Array[]): Uint8Array[];
|
|
17
|
+
/**
|
|
18
|
+
* Verify a consistency proof (RFC 9162 §2.1.4.2).
|
|
19
|
+
* Sizes up to Number.MAX_SAFE_INTEGER (2^53 − 1) are supported.
|
|
20
|
+
*/
|
|
21
|
+
export declare function verifyConsistency(path: Uint8Array[], first: number, second: number, firstRoot: Uint8Array, secondRoot: Uint8Array): boolean;
|
package/dist/merkle.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { emptyRoot, leafHash, nodeHash, equal } from "./hash.js";
|
|
2
|
+
/** Largest power of two strictly less than n (n >= 2). Uses multiplication to
|
|
3
|
+
* stay correct beyond 2^31 (a signed `<<` would overflow). */
|
|
4
|
+
function splitPoint(n) {
|
|
5
|
+
let k = 1;
|
|
6
|
+
while (k * 2 < n)
|
|
7
|
+
k *= 2;
|
|
8
|
+
return k;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Merkle Tree Hash (RFC 6962 §2.1). `entries` are raw leaf data (NOT pre-hashed).
|
|
12
|
+
* MTH({}) = SHA256(""); MTH({d0}) = leafHash(d0);
|
|
13
|
+
* MTH(D) = nodeHash(MTH(D[0:k]), MTH(D[k:n])), k = largest power of 2 < n.
|
|
14
|
+
*/
|
|
15
|
+
export function merkleRoot(entries) {
|
|
16
|
+
const n = entries.length;
|
|
17
|
+
if (n === 0)
|
|
18
|
+
return emptyRoot();
|
|
19
|
+
if (n === 1)
|
|
20
|
+
return leafHash(entries[0]);
|
|
21
|
+
const k = splitPoint(n);
|
|
22
|
+
return nodeHash(merkleRoot(entries.slice(0, k)), merkleRoot(entries.slice(k)));
|
|
23
|
+
}
|
|
24
|
+
/** Inclusion (audit) path for leaf index m in a tree of entries (RFC 6962 §2.1.1). */
|
|
25
|
+
export function inclusionPath(m, entries) {
|
|
26
|
+
const n = entries.length;
|
|
27
|
+
if (m < 0 || m >= n)
|
|
28
|
+
throw new RangeError(`index ${m} out of range for size ${n}`);
|
|
29
|
+
if (n === 1)
|
|
30
|
+
return [];
|
|
31
|
+
const k = splitPoint(n);
|
|
32
|
+
if (m < k)
|
|
33
|
+
return [...inclusionPath(m, entries.slice(0, k)), merkleRoot(entries.slice(k))];
|
|
34
|
+
return [...inclusionPath(m - k, entries.slice(k)), merkleRoot(entries.slice(0, k))];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Verify an inclusion proof (RFC 9162 §2.1.3.2). `leaf` is the leaf hash.
|
|
38
|
+
* Reconstructs the root from `leaf` + `path` and compares to `root`.
|
|
39
|
+
* Tree sizes up to Number.MAX_SAFE_INTEGER (2^53 − 1) are supported.
|
|
40
|
+
*/
|
|
41
|
+
export function verifyInclusion(path, index, treeSize, leaf, root) {
|
|
42
|
+
if (!Number.isSafeInteger(index) || !Number.isSafeInteger(treeSize) || index < 0 || index >= treeSize)
|
|
43
|
+
return false;
|
|
44
|
+
let fn = index;
|
|
45
|
+
let sn = treeSize - 1;
|
|
46
|
+
let r = leaf;
|
|
47
|
+
for (const p of path) {
|
|
48
|
+
if (sn === 0)
|
|
49
|
+
return false;
|
|
50
|
+
if (fn % 2 === 1 || fn === sn) {
|
|
51
|
+
r = nodeHash(p, r);
|
|
52
|
+
if (fn % 2 === 0) {
|
|
53
|
+
do {
|
|
54
|
+
fn = Math.floor(fn / 2);
|
|
55
|
+
sn = Math.floor(sn / 2);
|
|
56
|
+
} while (fn % 2 === 0 && fn !== 0);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
r = nodeHash(r, p);
|
|
61
|
+
}
|
|
62
|
+
fn = Math.floor(fn / 2);
|
|
63
|
+
sn = Math.floor(sn / 2);
|
|
64
|
+
}
|
|
65
|
+
return sn === 0 && equal(r, root);
|
|
66
|
+
}
|
|
67
|
+
function isPowerOfTwo(x) {
|
|
68
|
+
if (x <= 0)
|
|
69
|
+
return false;
|
|
70
|
+
const b = BigInt(x);
|
|
71
|
+
return (b & (b - 1n)) === 0n;
|
|
72
|
+
}
|
|
73
|
+
/** SUBPROOF helper (RFC 6962 §2.1.2). */
|
|
74
|
+
function subproof(m, entries, b) {
|
|
75
|
+
const n = entries.length;
|
|
76
|
+
if (m === n)
|
|
77
|
+
return b ? [] : [merkleRoot(entries)];
|
|
78
|
+
const k = splitPoint(n);
|
|
79
|
+
if (m <= k)
|
|
80
|
+
return [...subproof(m, entries.slice(0, k), b), merkleRoot(entries.slice(k))];
|
|
81
|
+
return [...subproof(m - k, entries.slice(k), false), merkleRoot(entries.slice(0, k))];
|
|
82
|
+
}
|
|
83
|
+
/** Consistency proof between first size m and full tree (RFC 6962 §2.1.2). 0 < m <= n. */
|
|
84
|
+
export function consistencyProof(m, entries) {
|
|
85
|
+
const n = entries.length;
|
|
86
|
+
if (m <= 0 || m > n)
|
|
87
|
+
throw new RangeError(`first size ${m} out of range for size ${n}`);
|
|
88
|
+
if (m === n)
|
|
89
|
+
return [];
|
|
90
|
+
return subproof(m, entries, true);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Verify a consistency proof (RFC 9162 §2.1.4.2).
|
|
94
|
+
* Sizes up to Number.MAX_SAFE_INTEGER (2^53 − 1) are supported.
|
|
95
|
+
*/
|
|
96
|
+
export function verifyConsistency(path, first, second, firstRoot, secondRoot) {
|
|
97
|
+
if (!Number.isSafeInteger(first) || !Number.isSafeInteger(second) || first < 0 || first > second)
|
|
98
|
+
return false;
|
|
99
|
+
if (first === 0)
|
|
100
|
+
return path.length === 0;
|
|
101
|
+
if (first === second)
|
|
102
|
+
return path.length === 0 && equal(firstRoot, secondRoot);
|
|
103
|
+
const work = isPowerOfTwo(first) ? [firstRoot, ...path] : path;
|
|
104
|
+
if (work.length === 0)
|
|
105
|
+
return false;
|
|
106
|
+
let fn = first - 1;
|
|
107
|
+
let sn = second - 1;
|
|
108
|
+
while (fn % 2 === 1) {
|
|
109
|
+
fn = Math.floor(fn / 2);
|
|
110
|
+
sn = Math.floor(sn / 2);
|
|
111
|
+
}
|
|
112
|
+
let fr = work[0];
|
|
113
|
+
let sr = work[0];
|
|
114
|
+
for (let i = 1; i < work.length; i++) {
|
|
115
|
+
const c = work[i];
|
|
116
|
+
if (sn === 0)
|
|
117
|
+
return false;
|
|
118
|
+
if (fn % 2 === 1 || fn === sn) {
|
|
119
|
+
fr = nodeHash(c, fr);
|
|
120
|
+
sr = nodeHash(c, sr);
|
|
121
|
+
if (fn % 2 === 0) {
|
|
122
|
+
do {
|
|
123
|
+
fn = Math.floor(fn / 2);
|
|
124
|
+
sn = Math.floor(sn / 2);
|
|
125
|
+
} while (fn % 2 === 0 && fn !== 0);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
sr = nodeHash(sr, c);
|
|
130
|
+
}
|
|
131
|
+
fn = Math.floor(fn / 2);
|
|
132
|
+
sn = Math.floor(sn / 2);
|
|
133
|
+
}
|
|
134
|
+
return sn === 0 && equal(fr, firstRoot) && equal(sr, secondRoot);
|
|
135
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Checkpoint } from "./checkpoint.js";
|
|
2
|
+
export interface InclusionBundle {
|
|
3
|
+
type: "inclusion";
|
|
4
|
+
index: number;
|
|
5
|
+
treeSize: number;
|
|
6
|
+
leaf: Uint8Array;
|
|
7
|
+
path: Uint8Array[];
|
|
8
|
+
root: Uint8Array;
|
|
9
|
+
}
|
|
10
|
+
export interface ConsistencyBundle {
|
|
11
|
+
type: "consistency";
|
|
12
|
+
first: number;
|
|
13
|
+
second: number;
|
|
14
|
+
firstRoot: Uint8Array;
|
|
15
|
+
secondRoot: Uint8Array;
|
|
16
|
+
path: Uint8Array[];
|
|
17
|
+
}
|
|
18
|
+
export declare function inclusionToJSON(b: InclusionBundle): string;
|
|
19
|
+
export declare function consistencyToJSON(b: ConsistencyBundle): string;
|
|
20
|
+
/**
|
|
21
|
+
* Verify a serialized proof bundle. This is the untrusted-input trust boundary:
|
|
22
|
+
* it is TOTAL — any malformed input (bad JSON, missing/typed-wrong fields,
|
|
23
|
+
* invalid hex, unknown type) yields `false` rather than throwing.
|
|
24
|
+
*/
|
|
25
|
+
export declare function verifyBundleJSON(json: string): boolean;
|
|
26
|
+
export declare function checkpointToJSON(c: Checkpoint): string;
|
|
27
|
+
export declare function checkpointFromJSON(s: string): Checkpoint;
|
|
28
|
+
export declare function signedCheckpointToJSON(sc: {
|
|
29
|
+
checkpoint: Checkpoint;
|
|
30
|
+
signature: Uint8Array;
|
|
31
|
+
}): string;
|
|
32
|
+
export declare function signedCheckpointFromJSON(s: string): {
|
|
33
|
+
checkpoint: Checkpoint;
|
|
34
|
+
signature: Uint8Array;
|
|
35
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { toHex, fromHex } from "./hash.js";
|
|
2
|
+
import { verifyInclusion, verifyConsistency } from "./merkle.js";
|
|
3
|
+
function hexStr(x) {
|
|
4
|
+
if (typeof x !== "string")
|
|
5
|
+
throw new TypeError("expected hex string");
|
|
6
|
+
return fromHex(x);
|
|
7
|
+
}
|
|
8
|
+
function hexArray(a) {
|
|
9
|
+
if (!Array.isArray(a))
|
|
10
|
+
throw new Error("expected array");
|
|
11
|
+
return a.map(hexStr);
|
|
12
|
+
}
|
|
13
|
+
export function inclusionToJSON(b) {
|
|
14
|
+
return JSON.stringify({
|
|
15
|
+
type: "inclusion", index: b.index, treeSize: b.treeSize,
|
|
16
|
+
leaf: toHex(b.leaf), path: b.path.map(toHex), root: toHex(b.root),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
export function consistencyToJSON(b) {
|
|
20
|
+
return JSON.stringify({
|
|
21
|
+
type: "consistency", first: b.first, second: b.second,
|
|
22
|
+
firstRoot: toHex(b.firstRoot), secondRoot: toHex(b.secondRoot), path: b.path.map(toHex),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/** Parse a proof bundle and verify it self-consistently. Returns the verdict. */
|
|
26
|
+
function uint(x) {
|
|
27
|
+
const v = Number(x);
|
|
28
|
+
return Number.isSafeInteger(v) && v >= 0 ? v : NaN;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Verify a serialized proof bundle. This is the untrusted-input trust boundary:
|
|
32
|
+
* it is TOTAL — any malformed input (bad JSON, missing/typed-wrong fields,
|
|
33
|
+
* invalid hex, unknown type) yields `false` rather than throwing.
|
|
34
|
+
*/
|
|
35
|
+
export function verifyBundleJSON(json) {
|
|
36
|
+
let o;
|
|
37
|
+
try {
|
|
38
|
+
o = JSON.parse(json);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
if (o?.type === "inclusion") {
|
|
45
|
+
const index = uint(o.index), treeSize = uint(o.treeSize);
|
|
46
|
+
if (Number.isNaN(index) || Number.isNaN(treeSize))
|
|
47
|
+
return false;
|
|
48
|
+
return verifyInclusion(hexArray(o.path), index, treeSize, hexStr(o.leaf), hexStr(o.root));
|
|
49
|
+
}
|
|
50
|
+
if (o?.type === "consistency") {
|
|
51
|
+
const first = uint(o.first), second = uint(o.second);
|
|
52
|
+
if (Number.isNaN(first) || Number.isNaN(second))
|
|
53
|
+
return false;
|
|
54
|
+
return verifyConsistency(hexArray(o.path), first, second, hexStr(o.firstRoot), hexStr(o.secondRoot));
|
|
55
|
+
}
|
|
56
|
+
return false; // unknown or missing bundle type
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false; // invalid hex, wrong field types, etc.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function reqUint(x, field) {
|
|
63
|
+
const v = Number(x);
|
|
64
|
+
if (!Number.isSafeInteger(v) || v < 0)
|
|
65
|
+
throw new Error(`invalid checkpoint field: ${field}`);
|
|
66
|
+
return v;
|
|
67
|
+
}
|
|
68
|
+
function reqFinite(x, field) {
|
|
69
|
+
const v = Number(x);
|
|
70
|
+
if (!Number.isFinite(v))
|
|
71
|
+
throw new Error(`invalid checkpoint field: ${field}`);
|
|
72
|
+
return v;
|
|
73
|
+
}
|
|
74
|
+
export function checkpointToJSON(c) {
|
|
75
|
+
return JSON.stringify({ size: c.size, rootHash: toHex(c.rootHash), timestamp: c.timestamp });
|
|
76
|
+
}
|
|
77
|
+
export function checkpointFromJSON(s) {
|
|
78
|
+
const o = JSON.parse(s);
|
|
79
|
+
return { size: reqUint(o.size, "size"), rootHash: hexStr(o.rootHash), timestamp: reqFinite(o.timestamp, "timestamp") };
|
|
80
|
+
}
|
|
81
|
+
export function signedCheckpointToJSON(sc) {
|
|
82
|
+
return JSON.stringify({
|
|
83
|
+
checkpoint: { size: sc.checkpoint.size, rootHash: toHex(sc.checkpoint.rootHash), timestamp: sc.checkpoint.timestamp },
|
|
84
|
+
signature: toHex(sc.signature),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
export function signedCheckpointFromJSON(s) {
|
|
88
|
+
const o = JSON.parse(s);
|
|
89
|
+
const c = o.checkpoint;
|
|
90
|
+
if (typeof c !== "object" || c === null)
|
|
91
|
+
throw new Error("invalid signed checkpoint: missing checkpoint");
|
|
92
|
+
return {
|
|
93
|
+
checkpoint: { size: reqUint(c.size, "size"), rootHash: hexStr(c.rootHash), timestamp: reqFinite(c.timestamp, "timestamp") },
|
|
94
|
+
signature: hexStr(o.signature),
|
|
95
|
+
};
|
|
96
|
+
}
|
package/dist/smt.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Root of the empty map (= DEFAULTS[0]). */
|
|
2
|
+
export declare function smtEmptyRoot(): Uint8Array;
|
|
3
|
+
/** Derive a 32-byte key from an arbitrary string. */
|
|
4
|
+
export declare function smtKey(s: string): Uint8Array;
|
|
5
|
+
export declare class SparseMerkleTree {
|
|
6
|
+
private items;
|
|
7
|
+
private static hex;
|
|
8
|
+
private static checkKey;
|
|
9
|
+
get size(): number;
|
|
10
|
+
set(key: Uint8Array, value: Uint8Array): void;
|
|
11
|
+
delete(key: Uint8Array): void;
|
|
12
|
+
has(key: Uint8Array): boolean;
|
|
13
|
+
root(): Uint8Array;
|
|
14
|
+
proof(key: Uint8Array): Uint8Array[];
|
|
15
|
+
}
|
|
16
|
+
/** Verify that `key` maps to `value` under `root`. */
|
|
17
|
+
export declare function verifyMapInclusion(key: Uint8Array, value: Uint8Array, proof: Uint8Array[], root: Uint8Array): boolean;
|
|
18
|
+
/** Verify that `key` is absent under `root` (its leaf is the empty default). */
|
|
19
|
+
export declare function verifyMapNonInclusion(key: Uint8Array, proof: Uint8Array[], root: Uint8Array): boolean;
|
package/dist/smt.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { sha256, leafHash, nodeHash, equal, utf8 } from "./hash.js";
|
|
2
|
+
/**
|
|
3
|
+
* Sparse Merkle Tree over a 256-bit key space (depth 256).
|
|
4
|
+
* - Empty subtree at level L is a precomputed default: D[256] = 32 zero bytes,
|
|
5
|
+
* D[L] = nodeHash(D[L+1], D[L+1]).
|
|
6
|
+
* - An occupied leaf is `leafHash(value)` (domain-separated, never all-zero).
|
|
7
|
+
* - Supports inclusion AND non-inclusion proofs (a non-inclusion proof shows the
|
|
8
|
+
* leaf at the key's path is the empty default).
|
|
9
|
+
*/
|
|
10
|
+
const KEY_BYTES = 32;
|
|
11
|
+
const DEPTH = 256;
|
|
12
|
+
const ZERO = new Uint8Array(KEY_BYTES);
|
|
13
|
+
const DEFAULTS = (() => {
|
|
14
|
+
const d = new Array(DEPTH + 1);
|
|
15
|
+
d[DEPTH] = ZERO;
|
|
16
|
+
for (let L = DEPTH - 1; L >= 0; L--)
|
|
17
|
+
d[L] = nodeHash(d[L + 1], d[L + 1]);
|
|
18
|
+
return d;
|
|
19
|
+
})();
|
|
20
|
+
/** Root of the empty map (= DEFAULTS[0]). */
|
|
21
|
+
export function smtEmptyRoot() {
|
|
22
|
+
return DEFAULTS[0];
|
|
23
|
+
}
|
|
24
|
+
/** Derive a 32-byte key from an arbitrary string. */
|
|
25
|
+
export function smtKey(s) {
|
|
26
|
+
return sha256(utf8(s));
|
|
27
|
+
}
|
|
28
|
+
function bit(key, index) {
|
|
29
|
+
return (key[index >> 3] >> (7 - (index & 7))) & 1;
|
|
30
|
+
}
|
|
31
|
+
function computeRoot(level, items) {
|
|
32
|
+
if (items.length === 0)
|
|
33
|
+
return DEFAULTS[level];
|
|
34
|
+
if (level === DEPTH)
|
|
35
|
+
return items[0].leaf;
|
|
36
|
+
const left = [];
|
|
37
|
+
const right = [];
|
|
38
|
+
for (const it of items)
|
|
39
|
+
(bit(it.key, level) === 0 ? left : right).push(it);
|
|
40
|
+
return nodeHash(computeRoot(level + 1, left), computeRoot(level + 1, right));
|
|
41
|
+
}
|
|
42
|
+
function proofPath(level, items, key) {
|
|
43
|
+
if (level === DEPTH)
|
|
44
|
+
return [];
|
|
45
|
+
const left = [];
|
|
46
|
+
const right = [];
|
|
47
|
+
for (const it of items)
|
|
48
|
+
(bit(it.key, level) === 0 ? left : right).push(it);
|
|
49
|
+
if (bit(key, level) === 0)
|
|
50
|
+
return [computeRoot(level + 1, right), ...proofPath(level + 1, left, key)];
|
|
51
|
+
return [computeRoot(level + 1, left), ...proofPath(level + 1, right, key)];
|
|
52
|
+
}
|
|
53
|
+
/** Reconstruct the root from a leaf + 256 sibling hashes (top→bottom order). */
|
|
54
|
+
function reconstruct(key, leaf, proof) {
|
|
55
|
+
if (proof.length !== DEPTH)
|
|
56
|
+
return null;
|
|
57
|
+
let cur = leaf;
|
|
58
|
+
for (let L = DEPTH - 1; L >= 0; L--) {
|
|
59
|
+
const sib = proof[L];
|
|
60
|
+
cur = bit(key, L) === 0 ? nodeHash(cur, sib) : nodeHash(sib, cur);
|
|
61
|
+
}
|
|
62
|
+
return cur;
|
|
63
|
+
}
|
|
64
|
+
export class SparseMerkleTree {
|
|
65
|
+
items = new Map();
|
|
66
|
+
static hex(k) {
|
|
67
|
+
return Buffer.from(k).toString("hex");
|
|
68
|
+
}
|
|
69
|
+
static checkKey(key) {
|
|
70
|
+
if (key.length !== KEY_BYTES)
|
|
71
|
+
throw new RangeError(`key must be ${KEY_BYTES} bytes`);
|
|
72
|
+
}
|
|
73
|
+
get size() {
|
|
74
|
+
return this.items.size;
|
|
75
|
+
}
|
|
76
|
+
set(key, value) {
|
|
77
|
+
SparseMerkleTree.checkKey(key);
|
|
78
|
+
this.items.set(SparseMerkleTree.hex(key), { key: Uint8Array.from(key), leaf: leafHash(value) });
|
|
79
|
+
}
|
|
80
|
+
delete(key) {
|
|
81
|
+
this.items.delete(SparseMerkleTree.hex(key));
|
|
82
|
+
}
|
|
83
|
+
has(key) {
|
|
84
|
+
return this.items.has(SparseMerkleTree.hex(key));
|
|
85
|
+
}
|
|
86
|
+
root() {
|
|
87
|
+
return computeRoot(0, [...this.items.values()]);
|
|
88
|
+
}
|
|
89
|
+
proof(key) {
|
|
90
|
+
SparseMerkleTree.checkKey(key);
|
|
91
|
+
return proofPath(0, [...this.items.values()], key);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Verify that `key` maps to `value` under `root`. */
|
|
95
|
+
export function verifyMapInclusion(key, value, proof, root) {
|
|
96
|
+
if (key.length !== KEY_BYTES)
|
|
97
|
+
return false;
|
|
98
|
+
const r = reconstruct(key, leafHash(value), proof);
|
|
99
|
+
return r !== null && equal(r, root);
|
|
100
|
+
}
|
|
101
|
+
/** Verify that `key` is absent under `root` (its leaf is the empty default). */
|
|
102
|
+
export function verifyMapNonInclusion(key, proof, root) {
|
|
103
|
+
if (key.length !== KEY_BYTES)
|
|
104
|
+
return false;
|
|
105
|
+
const r = reconstruct(key, ZERO, proof);
|
|
106
|
+
return r !== null && equal(r, root);
|
|
107
|
+
}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Append-only entry storage. Implementations must preserve insertion order. */
|
|
2
|
+
export interface Store {
|
|
3
|
+
size(): number;
|
|
4
|
+
get(index: number): Uint8Array;
|
|
5
|
+
append(entry: Uint8Array): number;
|
|
6
|
+
all(): Uint8Array[];
|
|
7
|
+
}
|
|
8
|
+
export declare class MemoryStore implements Store {
|
|
9
|
+
private entries;
|
|
10
|
+
constructor(initial?: Uint8Array[]);
|
|
11
|
+
size(): number;
|
|
12
|
+
get(index: number): Uint8Array;
|
|
13
|
+
append(entry: Uint8Array): number;
|
|
14
|
+
all(): Uint8Array[];
|
|
15
|
+
}
|
|
16
|
+
/** File-backed store. Persists entries as a JSON array of hex strings.
|
|
17
|
+
* Note: each append rewrites the whole file (O(n)); fine at library scale.
|
|
18
|
+
* Concurrency: appends across processes are serialized with an exclusive
|
|
19
|
+
* lock file, so concurrent writers cannot lose or corrupt entries. */
|
|
20
|
+
export declare class FileStore implements Store {
|
|
21
|
+
private readonly path;
|
|
22
|
+
private entries;
|
|
23
|
+
constructor(path: string);
|
|
24
|
+
/** (Re)read entries from disk; throws on a corrupt store (fail-closed). */
|
|
25
|
+
private load;
|
|
26
|
+
/** Run `fn` while holding an exclusive lock file (O_CREAT|O_EXCL), with a
|
|
27
|
+
* bounded wait and stale-lock recovery so a crashed writer can't deadlock. */
|
|
28
|
+
private withLock;
|
|
29
|
+
/** Atomic + durable: write a temp file, fsync it, then rename over the target.
|
|
30
|
+
* A crash mid-write can never leave a partially written or truncated store.
|
|
31
|
+
* The directory is then fsync'd so the rename itself survives power loss
|
|
32
|
+
* (best-effort: silently skipped on platforms without directory fsync). */
|
|
33
|
+
private persist;
|
|
34
|
+
size(): number;
|
|
35
|
+
get(index: number): Uint8Array;
|
|
36
|
+
append(entry: Uint8Array): number;
|
|
37
|
+
all(): Uint8Array[];
|
|
38
|
+
}
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { readFileSync, existsSync, openSync, writeSync, fsyncSync, closeSync, renameSync, unlinkSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { toHex, fromHex } from "./hash.js";
|
|
4
|
+
const LOCK_STALE_MS = 10_000;
|
|
5
|
+
const LOCK_TIMEOUT_MS = 10_000;
|
|
6
|
+
const LOCK_POLL_MS = 10;
|
|
7
|
+
/** Synchronous sleep with no busy-spin (zero-dependency). */
|
|
8
|
+
function sleepSync(ms) {
|
|
9
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
10
|
+
}
|
|
11
|
+
function copy(e) {
|
|
12
|
+
return Uint8Array.from(e);
|
|
13
|
+
}
|
|
14
|
+
export class MemoryStore {
|
|
15
|
+
entries;
|
|
16
|
+
constructor(initial) {
|
|
17
|
+
this.entries = initial ? initial.map(copy) : [];
|
|
18
|
+
}
|
|
19
|
+
size() {
|
|
20
|
+
return this.entries.length;
|
|
21
|
+
}
|
|
22
|
+
get(index) {
|
|
23
|
+
const e = this.entries[index];
|
|
24
|
+
if (!e)
|
|
25
|
+
throw new RangeError(`index ${index} out of range for size ${this.entries.length}`);
|
|
26
|
+
return copy(e);
|
|
27
|
+
}
|
|
28
|
+
append(entry) {
|
|
29
|
+
this.entries.push(copy(entry));
|
|
30
|
+
return this.entries.length;
|
|
31
|
+
}
|
|
32
|
+
all() {
|
|
33
|
+
return this.entries.map(copy);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** File-backed store. Persists entries as a JSON array of hex strings.
|
|
37
|
+
* Note: each append rewrites the whole file (O(n)); fine at library scale.
|
|
38
|
+
* Concurrency: appends across processes are serialized with an exclusive
|
|
39
|
+
* lock file, so concurrent writers cannot lose or corrupt entries. */
|
|
40
|
+
export class FileStore {
|
|
41
|
+
path;
|
|
42
|
+
entries = [];
|
|
43
|
+
constructor(path) {
|
|
44
|
+
this.path = path;
|
|
45
|
+
this.load();
|
|
46
|
+
}
|
|
47
|
+
/** (Re)read entries from disk; throws on a corrupt store (fail-closed). */
|
|
48
|
+
load() {
|
|
49
|
+
if (!existsSync(this.path)) {
|
|
50
|
+
this.entries = [];
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const arr = JSON.parse(readFileSync(this.path, "utf8"));
|
|
54
|
+
if (!Array.isArray(arr))
|
|
55
|
+
throw new Error(`corrupt store: ${this.path}`);
|
|
56
|
+
this.entries = arr.map((h) => fromHex(String(h)));
|
|
57
|
+
}
|
|
58
|
+
/** Run `fn` while holding an exclusive lock file (O_CREAT|O_EXCL), with a
|
|
59
|
+
* bounded wait and stale-lock recovery so a crashed writer can't deadlock. */
|
|
60
|
+
withLock(fn) {
|
|
61
|
+
const lock = `${this.path}.lock`;
|
|
62
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
63
|
+
for (;;) {
|
|
64
|
+
try {
|
|
65
|
+
closeSync(openSync(lock, "wx"));
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
if (e.code !== "EEXIST")
|
|
70
|
+
throw e;
|
|
71
|
+
try {
|
|
72
|
+
if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) {
|
|
73
|
+
unlinkSync(lock); // steal a stale lock left by a crashed writer
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
continue; // lock vanished between stat and use; retry immediately
|
|
79
|
+
}
|
|
80
|
+
if (Date.now() > deadline)
|
|
81
|
+
throw new Error(`timeout acquiring lock: ${lock}`);
|
|
82
|
+
sleepSync(LOCK_POLL_MS);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
return fn();
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
try {
|
|
90
|
+
unlinkSync(lock);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
/* already removed */
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Atomic + durable: write a temp file, fsync it, then rename over the target.
|
|
98
|
+
* A crash mid-write can never leave a partially written or truncated store.
|
|
99
|
+
* The directory is then fsync'd so the rename itself survives power loss
|
|
100
|
+
* (best-effort: silently skipped on platforms without directory fsync). */
|
|
101
|
+
persist() {
|
|
102
|
+
const tmp = `${this.path}.tmp`;
|
|
103
|
+
const data = Buffer.from(JSON.stringify(this.entries.map(toHex)));
|
|
104
|
+
const fd = openSync(tmp, "w");
|
|
105
|
+
try {
|
|
106
|
+
writeSync(fd, data);
|
|
107
|
+
fsyncSync(fd);
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
closeSync(fd);
|
|
111
|
+
}
|
|
112
|
+
renameSync(tmp, this.path);
|
|
113
|
+
try {
|
|
114
|
+
const dir = openSync(dirname(this.path) || ".", "r");
|
|
115
|
+
try {
|
|
116
|
+
fsyncSync(dir);
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
closeSync(dir);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// directory fsync is unsupported on some platforms (e.g. Windows); the
|
|
124
|
+
// temp+fsync+rename above already guarantees the store is never corrupt.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
size() {
|
|
128
|
+
return this.entries.length;
|
|
129
|
+
}
|
|
130
|
+
get(index) {
|
|
131
|
+
const e = this.entries[index];
|
|
132
|
+
if (!e)
|
|
133
|
+
throw new RangeError(`index ${index} out of range for size ${this.entries.length}`);
|
|
134
|
+
return copy(e);
|
|
135
|
+
}
|
|
136
|
+
append(entry) {
|
|
137
|
+
return this.withLock(() => {
|
|
138
|
+
this.load(); // pick up entries appended by other processes since we loaded
|
|
139
|
+
this.entries.push(copy(entry));
|
|
140
|
+
this.persist();
|
|
141
|
+
return this.entries.length;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
all() {
|
|
145
|
+
return this.entries.map(copy);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# ADR-0001: RFC 6962 domain-separated SHA-256 hashing
|
|
2
|
+
|
|
3
|
+
**Status**: Accepted · **Date**: 2026-05-31 · **Decider**: Aris Rhiannon
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
We need a hash construction for the Merkle tree that is interoperable, well-reviewed, and
|
|
7
|
+
resistant to second-preimage/leaf-node confusion attacks.
|
|
8
|
+
|
|
9
|
+
## Decision
|
|
10
|
+
Adopt RFC 6962 §2.1 exactly: `leafHash = SHA256(0x00 ‖ entry)`,
|
|
11
|
+
`nodeHash = SHA256(0x01 ‖ left ‖ right)`, `emptyRoot = SHA256("")`. SHA-256 comes from the
|
|
12
|
+
platform `node:crypto` (works on Node and Bun); no third-party crypto.
|
|
13
|
+
|
|
14
|
+
## Consequences
|
|
15
|
+
- **+** Interoperable with the large RFC 6962/9162 ecosystem; testable against the spec.
|
|
16
|
+
- **+** Domain separation prevents leaf/node confusion (a leaf hash can never be mistaken
|
|
17
|
+
for an interior node), defeating a class of second-preimage attacks.
|
|
18
|
+
- **+** Zero runtime dependencies; trivially auditable.
|
|
19
|
+
- **−** SHA-256 is fixed for v1 (no agility). Acceptable; revisit only if a successor
|
|
20
|
+
standard emerges. Domain tags make a future migration straightforward.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# ADR-0002: Pluggable storage + Ed25519 signed checkpoints
|
|
2
|
+
|
|
3
|
+
**Status**: Accepted · **Date**: 2026-05-31 · **Decider**: Aris Rhiannon
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
The library must stay self-contained (no DB/server) yet support persistence, and let a log
|
|
7
|
+
publish a portable, independently verifiable commitment to its state.
|
|
8
|
+
|
|
9
|
+
## Decision
|
|
10
|
+
- Define a tiny `Store` interface (`size/get/append/all`) with `MemoryStore` and
|
|
11
|
+
`FileStore` (JSON array of hex) implementations; `Log` depends only on the interface.
|
|
12
|
+
- A `Checkpoint` is `{size, rootHash, timestamp}` with a **deterministic** byte encoding
|
|
13
|
+
(`veritrail-checkpoint\n{size}\n{rootHex}\n{timestamp}\n`) used as the signing payload.
|
|
14
|
+
- Signatures use **Ed25519** via `node:crypto` (`sign(null, …)`); keys are `KeyObject`s
|
|
15
|
+
with PEM import/export for portability.
|
|
16
|
+
|
|
17
|
+
## Consequences
|
|
18
|
+
- **+** Embeddable; users can supply their own `Store` (e.g. SQLite) without touching core.
|
|
19
|
+
- **+** Deterministic encoding ⇒ signatures are reproducible and unambiguous.
|
|
20
|
+
- **+** Ed25519: small, fast, misuse-resistant, built into the platform.
|
|
21
|
+
- **−** `FileStore` rewrites the whole file per append (O(n)); fine for moderate logs.
|
|
22
|
+
A tiled/append-only on-disk format is future work.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# ADR-0003: Sparse Merkle tree for the verifiable map
|
|
2
|
+
|
|
3
|
+
**Status**: Accepted · **Date**: 2026-05-31 · **Decider**: Aris Rhiannon
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
We want a verifiable key→value map supporting both inclusion and **non-inclusion** proofs,
|
|
7
|
+
without materializing a 2^256-node tree.
|
|
8
|
+
|
|
9
|
+
## Decision
|
|
10
|
+
Use a fixed-depth (256) sparse Merkle tree keyed by a 32-byte key (`smtKey = SHA256(s)`).
|
|
11
|
+
Empty subtrees collapse to precomputed default nodes: `D[256] = 32 zero bytes`,
|
|
12
|
+
`D[L] = nodeHash(D[L+1], D[L+1])`. Occupied leaves are `leafHash(value)` (never all-zero,
|
|
13
|
+
so an occupied leaf is distinguishable from the empty default). Roots and proofs are
|
|
14
|
+
computed by recursing only over populated branches; a non-inclusion proof reconstructs the
|
|
15
|
+
root with the empty-leaf default at the key's path.
|
|
16
|
+
|
|
17
|
+
## Consequences
|
|
18
|
+
- **+** Inclusion and non-inclusion proofs share one verification routine.
|
|
19
|
+
- **+** Order-independent: the root depends only on the key/value *set*.
|
|
20
|
+
- **+** Reuses the same `nodeHash`/`leafHash` domain separation as the log.
|
|
21
|
+
- **−** Proof generation recomputes subtree roots (O(k·depth)); fine at library scale.
|
|
22
|
+
Caching/persistent nodes are future work.
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "veritrail",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Tamper-evident, verifiable append-only logs and maps for TypeScript. RFC 6962 Merkle trees with inclusion & consistency proofs, Ed25519-signed checkpoints, and a sparse Merkle verifiable map. Zero dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Aris Rhiannon",
|
|
8
|
+
"homepage": "https://github.com/ArisRhiannon/veritrail#readme",
|
|
9
|
+
"repository": { "type": "git", "url": "git+https://github.com/ArisRhiannon/veritrail.git" },
|
|
10
|
+
"bugs": { "url": "https://github.com/ArisRhiannon/veritrail/issues" },
|
|
11
|
+
"keywords": ["merkle-tree", "transparency-log", "rfc6962", "rfc9162", "tamper-evident", "inclusion-proof", "consistency-proof", "sparse-merkle-tree", "audit-log", "verifiable", "zero-dependency", "typescript"],
|
|
12
|
+
"main": "dist/index.js",
|
|
13
|
+
"module": "dist/index.js",
|
|
14
|
+
"types": "dist/index.d.ts",
|
|
15
|
+
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
|
16
|
+
"bin": { "veritrail": "dist/cli.js" },
|
|
17
|
+
"files": ["dist", "docs", "LICENSE", "README.md"],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.build.json",
|
|
20
|
+
"test": "bun test",
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"check": "bun run typecheck && bun test",
|
|
23
|
+
"prepack": "tsc -p tsconfig.build.json"
|
|
24
|
+
},
|
|
25
|
+
"engines": { "bun": ">=1.1.0", "node": ">=20" },
|
|
26
|
+
"dependencies": {},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/bun": "^1.1.0",
|
|
29
|
+
"@types/node": "^22.0.0",
|
|
30
|
+
"typescript": "^5.6.0"
|
|
31
|
+
}
|
|
32
|
+
}
|