init: cryptographic append-only ledger

This commit is contained in:
Vault Sovereign
2025-12-26 23:21:39 +00:00
commit 833c408a30
23 changed files with 3477 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
target/
# local ledger data
log/
.DS_Store
.secrets/
# editor / temp
**/*.log
**/*.tmp
*.swp
*.swo

12
ASSURANCE.md Normal file
View File

@@ -0,0 +1,12 @@
# Assurance Run — 2025-12-18
- Commit: _not tracked (civilization-ledger is a workspace directory, not a git repo)_
- Toolchain: `rustc 1.92.0 (ded5c06cf 2025-12-08)`, `cargo 1.92.0 (344c4567c 2025-10-21)`
| Check | Status | Notes |
| --- | --- | --- |
| `cargo fmt --check` | ✅ | Formatting already matched default rustfmt. |
| `cargo clippy --all -- -D warnings` | ❌ | Multiple Clippy findings (needless range loops in `attestation.rs`/`receipt.rs`, redundant closures in `merkle.rs`, `manual_div_ceil`, `manual_is_multiple_of`, etc.). |
| `cargo test` | ✅ | CLI + `ledger-core` integration/unit tests all passed. |
No files were changed during this pass.

616
Cargo.lock generated Normal file
View File

@@ -0,0 +1,616 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "anyhow"
version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "arrayref"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a"
[[package]]
name = "blake3"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3888aaa89e4b2a40fca9848e400f6a658a5a3978de7be858e209cafa8be9a4a0"
dependencies = [
"arrayref",
"arrayvec",
"cc",
"cfg-if",
"constant_time_eq",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "cc"
version = "1.2.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clap"
version = "4.5.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "constant_time_eq"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"zeroize",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"rand_core",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "find-msvc-tools"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844"
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "half"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "ledger-cli"
version = "0.1.0"
dependencies = [
"anyhow",
"base64",
"blake3",
"clap",
"ed25519-dalek",
"ledger-core",
"rand_core",
"serde_cbor",
"serde_json",
"sha2",
]
[[package]]
name = "ledger-core"
version = "0.1.0"
dependencies = [
"base64",
"blake3",
"ed25519-dalek",
"rand_core",
"serde",
"serde_cbor",
"serde_json",
"thiserror",
]
[[package]]
name = "libc"
version = "0.2.178"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091"
[[package]]
name = "memchr"
version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]]
name = "proc-macro2"
version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "ryu"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_cbor"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5"
dependencies = [
"half",
"serde",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.145"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
"serde_core",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core",
]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicode-ident"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"

4
Cargo.toml Normal file
View File

@@ -0,0 +1,4 @@
[workspace]
resolver = "2"
members = ["crates/ledger-core", "crates/ledger-cli"]

185
FORMAT.md Normal file
View File

@@ -0,0 +1,185 @@
# Format Specification (v0)
This document freezes the v0 formats used by `civilization-ledger`.
Key idea: **signing and verification do not depend on CBOR serialization details**. The canonical signing bytes are defined below and locked by golden-vector tests.
## Entry (v0)
### Fields
`EntryUnsigned` (the data that is signed):
- `prev_hash`: 32 bytes (`[u8;32]`) — previous entry hash, or `00…00` for genesis
- `ts_ms`: `u64` — Unix epoch milliseconds
- `namespace`: UTF-8 string
- `payload_cbor`: bytes — opaque payload (the ledger never interprets it)
- `author_pubkey`: 32 bytes (`[u8;32]`) — Ed25519 verifying key
`Entry` adds:
- `sig`: 64 bytes (`[u8;64]`) — Ed25519 signature over `signing_message_v0`
### Signing bytes (v0)
`signing_message_v0 =` (concatenation, in order):
1. Domain separator: ASCII `CLv0` (4 bytes)
2. `prev_hash` (32 bytes)
3. `ts_ms` as **little-endian** `u64` (8 bytes)
4. `namespace_len` as **little-endian** `u32` (4 bytes)
5. `namespace` bytes (`namespace_len` bytes)
6. `payload_hash = BLAKE3(payload_cbor)` (32 bytes)
7. `author_pubkey` (32 bytes)
This is implemented in `civilization-ledger/crates/ledger-core/src/entry.rs:32` and locked by `civilization-ledger/crates/ledger-core/tests/golden_vectors.rs:10`.
### Entry hash (v0)
`entry_hash = BLAKE3( … )` over:
- Domain separator `CL-entry-v0`
- `prev_hash`
- `ts_ms` (little-endian `u64`)
- `namespace_len` (little-endian `u32`)
- `namespace` bytes
- `payload_hash = BLAKE3(payload_cbor)`
- `author_pubkey`
- `sig_len` (little-endian `u32`, always `64` for v0)
- `sig` bytes (64)
This hash is the hash-chain link and the Merkle leaf value.
## Merkle (v0)
Leaves are entry hashes (`[u8;32]`).
- Empty tree root: `BLAKE3("CL-merkle-empty-v0")`
- Leaf compression: `leaf = BLAKE3("CL-merkle-leaf-v0" || entry_hash)`
- Node compression: `node = BLAKE3("CL-merkle-node-v0" || left || right)`
- If a level has an odd number of nodes, the last node is duplicated (`right = left`).
The Merkle root is computed over the **prefix** of entries included in a checkpoint.
## Checkpoints file (v0)
`log/checkpoints.jsonl` is JSON Lines. Each line is a `Checkpoint`:
- `ts_ms` (`u64`)
- `entry_count` (`u64`) — number of entries covered by this checkpoint
- `merkle_root_hex` (64 hex chars)
- `head_hash_hex` (64 hex chars) — the hash of entry `entry_count-1` (or `00…00` if `entry_count == 0`)
- optional witness fields:
- `witness_pubkey_hex`
- `witness_sig_hex`
## Checkpoint Attestations (v0, v1)
Checkpoint attestations are independent witness signatures over a checkpoint root.
File: `log/checkpoints.attestations.jsonl` (JSON Lines). Each line is a `CheckpointAttestationV0`
or `CheckpointAttestationV1`, distinguished by the `format` field.
- `format`: must equal `civ-ledger-checkpoint-attest-v0`
- `ledger_genesis_hash_hex`: 64 hex chars — the **first entry hash** (ledger identity anchor)
- `checkpoint_entry_count`: `u64` — number of entries covered by the checkpoint
- `checkpoint_merkle_root_hex`: 64 hex chars
- `checkpoint_head_hash_hex`: 64 hex chars — the hash of entry `checkpoint_entry_count-1` (or `00…00` if `checkpoint_entry_count == 0`)
- `ts_seen_ms`: `u64` — witness observation time (Unix epoch milliseconds)
- `witness_pubkey_hex`: 64 hex chars — Ed25519 public key
- `witness_sig_hex`: 128 hex chars — Ed25519 signature over the signing bytes below
### Attestation signing bytes (v0)
`attestation_signing_message_v0 =` (concatenation, in order):
1. Domain separator: ASCII `CIV_LEDGER_CHECKPOINT_ATTEST_V0`
2. `ledger_genesis_hash` (32 bytes)
3. `checkpoint_entry_count` as little-endian `u64` (8 bytes)
4. `checkpoint_merkle_root` (32 bytes)
5. `checkpoint_head_hash` (32 bytes)
6. `ts_seen_ms` as little-endian `u64` (8 bytes)
This is implemented in `civilization-ledger/crates/ledger-core/src/attestation.rs` and locked by
`civilization-ledger/crates/ledger-core/tests/attestation_vectors.rs`.
### Checkpoint Attestation (v1)
`CheckpointAttestationV1` adds one field to bind the witness signature to the specific checkpoint
record timestamp:
- `format`: must equal `civ-ledger-checkpoint-attest-v1`
- all fields from v0, plus:
- `checkpoint_ts_ms`: `u64` — the `Checkpoint.ts_ms` value being attested
Policy:
- `ts_seen_ms >= checkpoint_ts_ms`
### Attestation signing bytes (v1)
`attestation_signing_message_v1 =` (concatenation, in order):
1. Domain separator: ASCII `CIV_LEDGER_CHECKPOINT_ATTEST_V1`
2. `ledger_genesis_hash` (32 bytes)
3. `checkpoint_entry_count` as little-endian `u64` (8 bytes)
4. `checkpoint_merkle_root` (32 bytes)
5. `checkpoint_head_hash` (32 bytes)
6. `checkpoint_ts_ms` as little-endian `u64` (8 bytes)
7. `ts_seen_ms` as little-endian `u64` (8 bytes)
## ReadProof / Receipt (v0)
Read proofs are self-contained JSON objects of type `ReadProofV0`:
- `format`: must equal `civ-ledger-readproof-v0`
- `entry_hash_hex`: 64 hex chars (the entry hash being proven)
- `entry_index`: 0-based index of the entry within the checkpoint prefix
- `entry_count`: number of entries in the checkpoint prefix
- `checkpoint_merkle_root_hex`: 64 hex chars
- `path`: array of Merkle steps, each with:
- `sibling_side`: `"left"` or `"right"` (position of the sibling relative to the current hash)
- `sibling_hash_hex`: 64 hex chars (the sibling hash at that Merkle level)
Verification:
1. `current = BLAKE3("CL-merkle-leaf-v0" || entry_hash)`
2. For each path step:
- if `sibling_side == "left"`: `current = BLAKE3("CL-merkle-node-v0" || sibling || current)`
- if `sibling_side == "right"`: `current = BLAKE3("CL-merkle-node-v0" || current || sibling)`
3. Accept iff `current == checkpoint_merkle_root`
This is implemented in `civilization-ledger/crates/ledger-core/src/proof.rs`.
## Receipt (v0)
Receipts bundle: **entry bytes + inclusion proof + (optional) witness attestations**.
JSON object `ReceiptV0`:
- `format`: must equal `civ-ledger-receipt-v0`
- `entry_cbor_b64`: base64 (no padding) of CBOR-encoded `Entry`
- `entry_hash_hex`: 64 hex chars
- `read_proof`: a `ReadProofV0`
- `attestations`: array of `CheckpointAttestationV0` or `CheckpointAttestationV1` (may be empty)
Verification:
1. Decode `entry_cbor_b64``Entry`, verify the entry signature, and compute `entry_hash`.
2. Verify `read_proof` and require `read_proof.entry_hash_hex == entry_hash_hex`.
3. If witness is required: verify at least one included checkpoint attestation (v0 or v1) and
require it matches `read_proof.entry_count` + `read_proof.checkpoint_merkle_root_hex`.
This is implemented in `civilization-ledger/crates/ledger-core/src/receipt.rs`.
## Payload Type: `file_anchor.v0`
The `ledger anchor-file` CLI emits a CBOR payload with the following JSON shape:
- `type`: `"file_anchor.v0"`
- `path`: string (preferably repo-relative)
- `hash_blake3_hex`: 64 hex chars
- `bytes`: `u64`
- `git`:
- `commit`: string or null
- `dirty`: boolean or null

50
README.md Normal file
View File

@@ -0,0 +1,50 @@
# Civilization Ledger
Not a game.
`civilization-ledger` is a cryptographic, append-only, tamper-evident ledger for governance/law/memory/AI accountability.
## Properties (v0)
- Append-only log with a hash-chain.
- Signed entries (Ed25519).
- Deterministic verification: replay from genesis, verify hashes + signatures, emit an audit report.
- Local-first: no services required.
- No plaintext secrets committed; key material lives outside the repo.
## Quick start
```bash
# from this repo root
cd civilization-ledger
cargo run -p ledger-cli -- keygen --out ~/.config/civ-ledger/keys/operator.json
cargo run -p ledger-cli -- init --dir ./my-ledger
ENTRY_HASH="$(cargo run -q -p ledger-cli -- append --dir ./my-ledger \
--key ~/.config/civ-ledger/keys/operator.json \
--namespace law \
--payload-format json \
--payload '{"type":"policy","id":"P-001","text":"No plaintext secrets in Git."}')"
cargo run -p ledger-cli -- checkpoint --dir ./my-ledger
cargo run -p ledger-cli -- keygen --out ~/.config/civ-ledger/keys/witness.json
cargo run -p ledger-cli -- attest --dir ./my-ledger --witness-key ~/.config/civ-ledger/keys/witness.json
cargo run -p ledger-cli -- verify-attestations --dir ./my-ledger --format json
cargo run -p ledger-cli -- receipt --dir ./my-ledger --entry-hash "$ENTRY_HASH" --out ./receipt.json --require-attestation
cargo run -p ledger-cli -- verify-receipt --receipt ./receipt.json --require-attestation
cargo run -p ledger-cli -- verify --dir ./my-ledger --format json
```
## On-disk layout (v0)
- `log/entries.cborseq` — concatenated CBOR-encoded `Entry` items (append-only).
- `log/checkpoints.jsonl` — optional Merkle checkpoints (append-only).
- `log/checkpoints.attestations.jsonl` — witness attestations over checkpoints (append-only).
## Spec
See `civilization-ledger/FORMAT.md`.

View File

@@ -0,0 +1,20 @@
[package]
name = "ledger-cli"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "ledger"
path = "src/main.rs"
[dependencies]
anyhow = "1.0"
base64 = "0.22"
blake3 = "1.6"
sha2 = "0.10"
clap = { version = "4.5", features = ["derive"] }
ed25519-dalek = { version = "2.2", features = ["rand_core"] }
ledger-core = { path = "../ledger-core" }
rand_core = "0.6"
serde_cbor = "0.11"
serde_json = "1.0"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
[package]
name = "ledger-core"
version = "0.1.0"
edition = "2024"
[dependencies]
base64 = "0.22"
blake3 = "1.6"
ed25519-dalek = { version = "2.2", features = ["rand_core"] }
rand_core = { version = "0.6", features = ["getrandom"] }
serde = { version = "1.0", features = ["derive"] }
serde_cbor = "0.11"
serde_json = "1.0"
thiserror = "2.0"

View File

@@ -0,0 +1,212 @@
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::entry::EntryHash;
use crate::merkle::MerkleRoot;
use crate::storage::LedgerError;
pub const CHECKPOINT_ATTESTATION_V0_FORMAT: &str = "civ-ledger-checkpoint-attest-v0";
pub const CHECKPOINT_ATTESTATION_V0_DOMAIN: &[u8] = b"CIV_LEDGER_CHECKPOINT_ATTEST_V0";
pub const CHECKPOINT_ATTESTATION_V1_FORMAT: &str = "civ-ledger-checkpoint-attest-v1";
pub const CHECKPOINT_ATTESTATION_V1_DOMAIN: &[u8] = b"CIV_LEDGER_CHECKPOINT_ATTEST_V1";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckpointAttestationV0 {
pub format: String,
#[serde(with = "serde_hex32")]
pub ledger_genesis_hash_hex: EntryHash,
pub checkpoint_entry_count: u64,
#[serde(with = "serde_hex32")]
pub checkpoint_merkle_root_hex: MerkleRoot,
#[serde(with = "serde_hex32")]
pub checkpoint_head_hash_hex: EntryHash,
pub ts_seen_ms: u64,
#[serde(with = "serde_hex32")]
pub witness_pubkey_hex: [u8; 32],
#[serde(with = "serde_hex64")]
pub witness_sig_hex: [u8; 64],
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckpointAttestationV1 {
pub format: String,
#[serde(with = "serde_hex32")]
pub ledger_genesis_hash_hex: EntryHash,
pub checkpoint_entry_count: u64,
#[serde(with = "serde_hex32")]
pub checkpoint_merkle_root_hex: MerkleRoot,
#[serde(with = "serde_hex32")]
pub checkpoint_head_hash_hex: EntryHash,
pub checkpoint_ts_ms: u64,
pub ts_seen_ms: u64,
#[serde(with = "serde_hex32")]
pub witness_pubkey_hex: [u8; 32],
#[serde(with = "serde_hex64")]
pub witness_sig_hex: [u8; 64],
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum CheckpointAttestation {
V1(CheckpointAttestationV1),
V0(CheckpointAttestationV0),
}
pub fn verify_checkpoint_attestation(att: &CheckpointAttestation) -> Result<(), LedgerError> {
match att {
CheckpointAttestation::V0(a) => verify_checkpoint_attestation_v0(a),
CheckpointAttestation::V1(a) => verify_checkpoint_attestation_v1(a),
}
}
pub fn verify_checkpoint_attestation_v0(att: &CheckpointAttestationV0) -> Result<(), LedgerError> {
if att.format != CHECKPOINT_ATTESTATION_V0_FORMAT {
return Err(LedgerError::InvalidLedger(format!(
"unsupported attestation format: {}",
att.format
)));
}
let vk = VerifyingKey::from_bytes(&att.witness_pubkey_hex)
.map_err(|e| LedgerError::InvalidLedger(format!("bad witness_pubkey: {}", e)))?;
let sig = Signature::from_bytes(&att.witness_sig_hex);
let msg = checkpoint_attestation_signing_message_v0(
&att.ledger_genesis_hash_hex,
att.checkpoint_entry_count,
&att.checkpoint_merkle_root_hex,
&att.checkpoint_head_hash_hex,
att.ts_seen_ms,
);
vk.verify(&msg, &sig)
.map_err(|e| LedgerError::InvalidLedger(format!("attestation signature invalid: {}", e)))?;
Ok(())
}
pub fn verify_checkpoint_attestation_v1(att: &CheckpointAttestationV1) -> Result<(), LedgerError> {
if att.format != CHECKPOINT_ATTESTATION_V1_FORMAT {
return Err(LedgerError::InvalidLedger(format!(
"unsupported attestation format: {}",
att.format
)));
}
if att.ts_seen_ms < att.checkpoint_ts_ms {
return Err(LedgerError::InvalidLedger(
"attestation ts_seen_ms is earlier than checkpoint_ts_ms".into(),
));
}
let vk = VerifyingKey::from_bytes(&att.witness_pubkey_hex)
.map_err(|e| LedgerError::InvalidLedger(format!("bad witness_pubkey: {}", e)))?;
let sig = Signature::from_bytes(&att.witness_sig_hex);
let msg = checkpoint_attestation_signing_message_v1(
&att.ledger_genesis_hash_hex,
att.checkpoint_entry_count,
&att.checkpoint_merkle_root_hex,
&att.checkpoint_head_hash_hex,
att.checkpoint_ts_ms,
att.ts_seen_ms,
);
vk.verify(&msg, &sig)
.map_err(|e| LedgerError::InvalidLedger(format!("attestation signature invalid: {}", e)))?;
Ok(())
}
pub fn checkpoint_attestation_signing_message_v0(
ledger_genesis_hash: &EntryHash,
checkpoint_entry_count: u64,
checkpoint_merkle_root: &MerkleRoot,
checkpoint_head_hash: &EntryHash,
ts_seen_ms: u64,
) -> Vec<u8> {
let mut msg = Vec::with_capacity(CHECKPOINT_ATTESTATION_V0_DOMAIN.len() + 32 + 8 + 32 + 32 + 8);
msg.extend_from_slice(CHECKPOINT_ATTESTATION_V0_DOMAIN);
msg.extend_from_slice(ledger_genesis_hash);
msg.extend_from_slice(&checkpoint_entry_count.to_le_bytes());
msg.extend_from_slice(checkpoint_merkle_root);
msg.extend_from_slice(checkpoint_head_hash);
msg.extend_from_slice(&ts_seen_ms.to_le_bytes());
msg
}
pub fn checkpoint_attestation_signing_message_v1(
ledger_genesis_hash: &EntryHash,
checkpoint_entry_count: u64,
checkpoint_merkle_root: &MerkleRoot,
checkpoint_head_hash: &EntryHash,
checkpoint_ts_ms: u64,
ts_seen_ms: u64,
) -> Vec<u8> {
let mut msg =
Vec::with_capacity(CHECKPOINT_ATTESTATION_V1_DOMAIN.len() + 32 + 8 + 32 + 32 + 8 + 8);
msg.extend_from_slice(CHECKPOINT_ATTESTATION_V1_DOMAIN);
msg.extend_from_slice(ledger_genesis_hash);
msg.extend_from_slice(&checkpoint_entry_count.to_le_bytes());
msg.extend_from_slice(checkpoint_merkle_root);
msg.extend_from_slice(checkpoint_head_hash);
msg.extend_from_slice(&checkpoint_ts_ms.to_le_bytes());
msg.extend_from_slice(&ts_seen_ms.to_le_bytes());
msg
}
mod serde_hex32 {
use super::*;
pub fn serialize<S>(bytes: &[u8; 32], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&hex_encode(bytes))
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<[u8; 32], D::Error>
where
D: Deserializer<'de>,
{
let s: String = Deserialize::deserialize(deserializer)?;
hex_decode_fixed::<32>(&s).map_err(serde::de::Error::custom)
}
}
mod serde_hex64 {
use super::*;
pub fn serialize<S>(bytes: &[u8; 64], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&hex_encode(bytes))
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<[u8; 64], D::Error>
where
D: Deserializer<'de>,
{
let s: String = Deserialize::deserialize(deserializer)?;
hex_decode_fixed::<64>(&s).map_err(serde::de::Error::custom)
}
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
fn hex_decode_fixed<const N: usize>(s: &str) -> Result<[u8; N], String> {
let s = s.trim();
let s = s.strip_prefix("0x").unwrap_or(s);
if s.len() != N * 2 {
return Err(format!(
"hex value must be {} bytes ({} hex chars), got {} chars",
N,
N * 2,
s.len()
));
}
let mut out = [0u8; N];
for i in 0..N {
let idx = i * 2;
out[i] = u8::from_str_radix(&s[idx..idx + 2], 16).map_err(|_| "invalid hex".to_string())?;
}
Ok(out)
}

View File

@@ -0,0 +1,141 @@
use blake3::Hash;
use serde::{Deserialize, Serialize};
pub type EntryHash = [u8; 32];
pub type SignatureBytes = [u8; 64];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EntryUnsigned {
pub prev_hash: EntryHash,
pub ts_ms: u64,
pub namespace: String,
pub payload_cbor: Vec<u8>,
pub author_pubkey: [u8; 32],
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entry {
pub prev_hash: EntryHash,
pub ts_ms: u64,
pub namespace: String,
pub payload_cbor: Vec<u8>,
pub author_pubkey: [u8; 32],
#[serde(with = "serde_sig64")]
pub sig: SignatureBytes,
}
impl EntryUnsigned {
pub fn payload_hash(&self) -> EntryHash {
blake3::hash(&self.payload_cbor).into()
}
pub fn signing_message(&self) -> Vec<u8> {
let mut msg = Vec::with_capacity(
4 + self.prev_hash.len() + 8 + 4 + self.namespace.len() + 32 + self.author_pubkey.len(),
);
msg.extend_from_slice(b"CLv0");
msg.extend_from_slice(&self.prev_hash);
msg.extend_from_slice(&self.ts_ms.to_le_bytes());
let ns = self.namespace.as_bytes();
msg.extend_from_slice(&(ns.len() as u32).to_le_bytes());
msg.extend_from_slice(ns);
msg.extend_from_slice(&self.payload_hash());
msg.extend_from_slice(&self.author_pubkey);
msg
}
pub fn to_entry(self, sig: SignatureBytes) -> Entry {
Entry {
prev_hash: self.prev_hash,
ts_ms: self.ts_ms,
namespace: self.namespace,
payload_cbor: self.payload_cbor,
author_pubkey: self.author_pubkey,
sig,
}
}
}
impl Entry {
pub fn unsigned(&self) -> EntryUnsigned {
EntryUnsigned {
prev_hash: self.prev_hash,
ts_ms: self.ts_ms,
namespace: self.namespace.clone(),
payload_cbor: self.payload_cbor.clone(),
author_pubkey: self.author_pubkey,
}
}
pub fn payload_hash(&self) -> EntryHash {
blake3::hash(&self.payload_cbor).into()
}
pub fn hash(&self) -> EntryHash {
let mut hasher = blake3::Hasher::new();
hasher.update(b"CL-entry-v0");
hasher.update(&self.prev_hash);
hasher.update(&self.ts_ms.to_le_bytes());
hasher.update(&(self.namespace.as_bytes().len() as u32).to_le_bytes());
hasher.update(self.namespace.as_bytes());
hasher.update(&self.payload_hash());
hasher.update(&self.author_pubkey);
hasher.update(&(self.sig.len() as u32).to_le_bytes());
hasher.update(&self.sig);
finalize_32(hasher.finalize())
}
}
fn finalize_32(hash: Hash) -> [u8; 32] {
*hash.as_bytes()
}
mod serde_sig64 {
use std::fmt;
use serde::Serializer;
use serde::de::{self, Deserializer, Visitor};
pub fn serialize<S>(sig: &[u8; 64], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_bytes(sig)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<[u8; 64], D::Error>
where
D: Deserializer<'de>,
{
struct SigVisitor;
impl<'de> Visitor<'de> for SigVisitor {
type Value = [u8; 64];
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a 64-byte signature")
}
fn visit_bytes<E>(self, v: &[u8]) -> Result<Self::Value, E>
where
E: de::Error,
{
if v.len() != 64 {
return Err(E::custom(format!("sig must be 64 bytes, got {}", v.len())));
}
let mut sig = [0u8; 64];
sig.copy_from_slice(v);
Ok(sig)
}
fn visit_byte_buf<E>(self, v: Vec<u8>) -> Result<Self::Value, E>
where
E: de::Error,
{
self.visit_bytes(&v)
}
}
deserializer.deserialize_bytes(SigVisitor)
}
}

View File

@@ -0,0 +1,40 @@
use base64::Engine as _;
use ed25519_dalek::{SigningKey, VerifyingKey};
use rand_core::OsRng;
use serde::{Deserialize, Serialize};
use crate::storage::LedgerError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyFile {
pub seed_b64: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyFilePublic {
pub public_hex: String,
}
impl KeyFile {
pub fn generate() -> Self {
let signing_key = SigningKey::generate(&mut OsRng);
let seed: [u8; 32] = signing_key.to_bytes();
Self {
seed_b64: base64::engine::general_purpose::STANDARD_NO_PAD.encode(seed),
}
}
pub fn signing_key(&self) -> Result<SigningKey, LedgerError> {
let bytes = base64::engine::general_purpose::STANDARD_NO_PAD
.decode(&self.seed_b64)
.map_err(|_| LedgerError::InvalidKeyFile("seed_b64 is not valid base64".into()))?;
let seed: [u8; 32] = bytes
.try_into()
.map_err(|_| LedgerError::InvalidKeyFile("seed_b64 must decode to 32 bytes".into()))?;
Ok(SigningKey::from_bytes(&seed))
}
pub fn verifying_key(&self) -> Result<VerifyingKey, LedgerError> {
Ok(self.signing_key()?.verifying_key())
}
}

View File

@@ -0,0 +1,21 @@
pub mod attestation;
pub mod entry;
pub mod identity;
pub mod merkle;
pub mod proof;
pub mod receipt;
pub mod storage;
pub mod verify;
pub use attestation::{
CHECKPOINT_ATTESTATION_V0_FORMAT, CHECKPOINT_ATTESTATION_V1_FORMAT, CheckpointAttestation,
CheckpointAttestationV0, CheckpointAttestationV1, verify_checkpoint_attestation,
verify_checkpoint_attestation_v0, verify_checkpoint_attestation_v1,
};
pub use entry::{Entry, EntryHash, EntryUnsigned};
pub use identity::{KeyFile, KeyFilePublic};
pub use merkle::{MerkleRoot, merkle_root};
pub use proof::{ReadProofV0, verify_read_proof_v0};
pub use receipt::{RECEIPT_V0_FORMAT, ReceiptV0, verify_receipt_v0};
pub use storage::{Checkpoint, LedgerDir};
pub use verify::{AuditReport, verify_ledger_dir};

View File

@@ -0,0 +1,112 @@
use blake3::Hash;
pub type MerkleRoot = [u8; 32];
pub fn merkle_root(leaves: &[MerkleRoot]) -> MerkleRoot {
if leaves.is_empty() {
return blake3::hash(b"CL-merkle-empty-v0").into();
}
let mut level: Vec<MerkleRoot> = leaves.iter().map(|h| leaf_hash(h)).collect();
while level.len() > 1 {
let mut next = Vec::with_capacity((level.len() + 1) / 2);
let mut i = 0;
while i < level.len() {
let left = level[i];
let right = if i + 1 < level.len() {
level[i + 1]
} else {
level[i]
};
next.push(node_hash(&left, &right));
i += 2;
}
level = next;
}
level[0]
}
pub(crate) fn leaf_hash(leaf: &MerkleRoot) -> MerkleRoot {
let mut hasher = blake3::Hasher::new();
hasher.update(b"CL-merkle-leaf-v0");
hasher.update(leaf);
finalize_32(hasher.finalize())
}
pub(crate) fn node_hash(left: &MerkleRoot, right: &MerkleRoot) -> MerkleRoot {
let mut hasher = blake3::Hasher::new();
hasher.update(b"CL-merkle-node-v0");
hasher.update(left);
hasher.update(right);
finalize_32(hasher.finalize())
}
fn finalize_32(hash: Hash) -> [u8; 32] {
*hash.as_bytes()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MerkleSide {
Left,
Right,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MerklePathItem {
pub sibling: MerkleRoot,
pub sibling_side: MerkleSide,
}
pub fn merkle_inclusion_path(
leaves: &[MerkleRoot],
index: usize,
) -> Result<(MerkleRoot, Vec<MerklePathItem>), String> {
if index >= leaves.len() {
return Err("index out of range".into());
}
if leaves.is_empty() {
return Err("cannot build proof for empty tree".into());
}
let mut path = Vec::new();
let mut idx = index;
let mut level: Vec<MerkleRoot> = leaves.iter().map(|h| leaf_hash(h)).collect();
while level.len() > 1 {
let is_left = idx % 2 == 0;
let sibling_index = if is_left {
if idx + 1 < level.len() { idx + 1 } else { idx }
} else {
idx - 1
};
let sibling_side = if is_left {
MerkleSide::Right
} else {
MerkleSide::Left
};
path.push(MerklePathItem {
sibling: level[sibling_index],
sibling_side,
});
let mut next = Vec::with_capacity((level.len() + 1) / 2);
let mut i = 0;
while i < level.len() {
let left = level[i];
let right = if i + 1 < level.len() {
level[i + 1]
} else {
level[i]
};
next.push(node_hash(&left, &right));
i += 2;
}
idx /= 2;
level = next;
}
Ok((level[0], path))
}

View File

@@ -0,0 +1,146 @@
use serde::{Deserialize, Serialize};
use crate::entry::EntryHash;
use crate::merkle::{
MerklePathItem, MerkleRoot, MerkleSide, leaf_hash, merkle_inclusion_path, node_hash,
};
use crate::storage::LedgerError;
pub const READ_PROOF_V0_FORMAT: &str = "civ-ledger-readproof-v0";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReadProofV0 {
pub format: String,
pub entry_hash_hex: String,
pub entry_index: u64,
pub entry_count: u64,
pub checkpoint_merkle_root_hex: String,
pub path: Vec<PathStepV0>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PathStepV0 {
pub sibling_side: SideV0,
pub sibling_hash_hex: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SideV0 {
Left,
Right,
}
impl ReadProofV0 {
pub fn from_tree(leaves: &[EntryHash], index: usize) -> Result<Self, LedgerError> {
let (root, path) = merkle_inclusion_path(leaves, index)
.map_err(|e| LedgerError::InvalidLedger(format!("cannot build merkle proof: {}", e)))?;
Ok(Self::new(leaves, index, root, path))
}
fn new(
leaves: &[EntryHash],
index: usize,
root: MerkleRoot,
path: Vec<MerklePathItem>,
) -> Self {
Self {
format: READ_PROOF_V0_FORMAT.to_string(),
entry_hash_hex: hex_encode(&leaves[index]),
entry_index: index as u64,
entry_count: leaves.len() as u64,
checkpoint_merkle_root_hex: hex_encode(&root),
path: path.into_iter().map(step_from_merkle).collect(),
}
}
}
pub fn verify_read_proof_v0(proof: &ReadProofV0) -> Result<(), LedgerError> {
if proof.format != READ_PROOF_V0_FORMAT {
return Err(LedgerError::InvalidLedger(format!(
"unsupported proof format: {}",
proof.format
)));
}
if proof.entry_count == 0 {
return Err(LedgerError::InvalidLedger("entry_count must be > 0".into()));
}
if proof.entry_index >= proof.entry_count {
return Err(LedgerError::InvalidLedger(
"entry_index out of range".into(),
));
}
let expected_path_len = expected_merkle_path_len(proof.entry_count);
if proof.path.len() != expected_path_len {
return Err(LedgerError::InvalidLedger(format!(
"unexpected merkle path length: expected {}, got {}",
expected_path_len,
proof.path.len()
)));
}
let entry_hash: [u8; 32] = hex_decode_fixed(&proof.entry_hash_hex)?;
let want_root: [u8; 32] = hex_decode_fixed(&proof.checkpoint_merkle_root_hex)?;
let mut current = leaf_hash(&entry_hash);
for step in &proof.path {
let sibling: [u8; 32] = hex_decode_fixed(&step.sibling_hash_hex)?;
current = match step.sibling_side {
SideV0::Left => node_hash(&sibling, &current),
SideV0::Right => node_hash(&current, &sibling),
};
}
if current != want_root {
return Err(LedgerError::InvalidLedger(
"merkle proof does not match checkpoint root".into(),
));
}
Ok(())
}
fn step_from_merkle(item: MerklePathItem) -> PathStepV0 {
let sibling_side = match item.sibling_side {
MerkleSide::Left => SideV0::Left,
MerkleSide::Right => SideV0::Right,
};
PathStepV0 {
sibling_side,
sibling_hash_hex: hex_encode(&item.sibling),
}
}
fn expected_merkle_path_len(mut leaf_count: u64) -> usize {
let mut depth = 0usize;
while leaf_count > 1 {
depth += 1;
leaf_count = (leaf_count + 1) / 2;
}
depth
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
fn hex_decode_fixed<const N: usize>(s: &str) -> Result<[u8; N], LedgerError> {
let s = s.trim();
if s.len() != N * 2 {
return Err(LedgerError::InvalidLedger(format!(
"hex value must be {} bytes ({} hex chars), got {} chars",
N,
N * 2,
s.len()
)));
}
let mut out = [0u8; N];
for i in 0..N {
let idx = i * 2;
out[i] = u8::from_str_radix(&s[idx..idx + 2], 16)
.map_err(|_| LedgerError::InvalidLedger("invalid hex".into()))?;
}
Ok(out)
}

View File

@@ -0,0 +1,127 @@
use base64::Engine as _;
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use crate::attestation::{CheckpointAttestation, verify_checkpoint_attestation};
use crate::entry::Entry;
use crate::proof::{ReadProofV0, verify_read_proof_v0};
use crate::storage::LedgerError;
pub const RECEIPT_V0_FORMAT: &str = "civ-ledger-receipt-v0";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReceiptV0 {
pub format: String,
pub entry_cbor_b64: String,
pub entry_hash_hex: String,
pub read_proof: ReadProofV0,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attestations: Vec<CheckpointAttestation>,
}
pub fn verify_receipt_v0(
receipt: &ReceiptV0,
require_attestation: bool,
) -> Result<(), LedgerError> {
if receipt.format != RECEIPT_V0_FORMAT {
return Err(LedgerError::InvalidLedger(format!(
"unsupported receipt format: {}",
receipt.format
)));
}
let entry_bytes = base64::engine::general_purpose::STANDARD_NO_PAD
.decode(&receipt.entry_cbor_b64)
.map_err(|_| {
LedgerError::InvalidLedger("receipt entry_cbor_b64 is not valid base64".into())
})?;
let entry: Entry = serde_cbor::from_slice(&entry_bytes)
.map_err(|e| LedgerError::Cbor(format!("receipt entry cbor decode: {}", e)))?;
verify_entry_sig(&entry)?;
let entry_hash = entry.hash();
let receipt_hash = hex_decode_fixed::<32>(&receipt.entry_hash_hex)?;
if receipt_hash != entry_hash {
return Err(LedgerError::InvalidLedger(
"receipt entry_hash_hex mismatch".into(),
));
}
if hex_decode_fixed::<32>(&receipt.read_proof.entry_hash_hex)? != entry_hash {
return Err(LedgerError::InvalidLedger(
"receipt read_proof.entry_hash_hex mismatch".into(),
));
}
verify_read_proof_v0(&receipt.read_proof)?;
if require_attestation && receipt.attestations.is_empty() {
return Err(LedgerError::InvalidLedger(
"receipt missing witness attestations".into(),
));
}
if !receipt.attestations.is_empty() {
let want_root = hex_decode_fixed::<32>(&receipt.read_proof.checkpoint_merkle_root_hex)?;
let want_count = receipt.read_proof.entry_count;
let mut matched_ok = 0usize;
for att in &receipt.attestations {
verify_checkpoint_attestation(att)?;
match att {
CheckpointAttestation::V0(a) => {
if a.checkpoint_entry_count == want_count
&& a.checkpoint_merkle_root_hex == want_root
{
matched_ok += 1;
}
}
CheckpointAttestation::V1(a) => {
if a.checkpoint_entry_count == want_count
&& a.checkpoint_merkle_root_hex == want_root
{
matched_ok += 1;
}
}
}
}
if require_attestation && matched_ok == 0 {
return Err(LedgerError::InvalidLedger(
"no attestation matched receipt checkpoint".into(),
));
}
}
Ok(())
}
fn verify_entry_sig(entry: &Entry) -> Result<(), LedgerError> {
let vk = VerifyingKey::from_bytes(&entry.author_pubkey)
.map_err(|e| LedgerError::InvalidLedger(format!("bad author_pubkey: {}", e)))?;
let sig = Signature::from_bytes(&entry.sig);
let msg = entry.unsigned().signing_message();
vk.verify(&msg, &sig)
.map_err(|e| LedgerError::InvalidLedger(format!("signature verify failed: {}", e)))?;
Ok(())
}
fn hex_decode_fixed<const N: usize>(s: &str) -> Result<[u8; N], LedgerError> {
let s = s.trim();
let s = s.strip_prefix("0x").unwrap_or(s);
if s.len() != N * 2 {
return Err(LedgerError::InvalidLedger(format!(
"hex value must be {} bytes ({} hex chars), got {} chars",
N,
N * 2,
s.len()
)));
}
let mut out = [0u8; N];
for i in 0..N {
let idx = i * 2;
out[i] = u8::from_str_radix(&s[idx..idx + 2], 16)
.map_err(|_| LedgerError::InvalidLedger("invalid hex".into()))?;
}
Ok(out)
}

View File

@@ -0,0 +1,189 @@
use std::fs::{File, OpenOptions};
use std::io::{BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::attestation::CheckpointAttestation;
use crate::entry::Entry;
use crate::merkle::{MerkleRoot, merkle_root};
#[derive(Debug, Error)]
pub enum LedgerError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("cbor error: {0}")]
Cbor(String),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("invalid key file: {0}")]
InvalidKeyFile(String),
#[error("invalid ledger: {0}")]
InvalidLedger(String),
}
#[derive(Debug, Clone)]
pub struct LedgerDir {
pub root: PathBuf,
}
impl LedgerDir {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn log_dir(&self) -> PathBuf {
self.root.join("log")
}
pub fn entries_path(&self) -> PathBuf {
self.log_dir().join("entries.cborseq")
}
pub fn checkpoints_path(&self) -> PathBuf {
self.log_dir().join("checkpoints.jsonl")
}
pub fn checkpoint_attestations_path(&self) -> PathBuf {
self.log_dir().join("checkpoints.attestations.jsonl")
}
pub fn init(&self) -> Result<(), LedgerError> {
std::fs::create_dir_all(self.log_dir())?;
Ok(())
}
pub fn append_entry(&self, entry: &Entry) -> Result<(), LedgerError> {
std::fs::create_dir_all(self.log_dir())?;
let file = OpenOptions::new()
.create(true)
.append(true)
.open(self.entries_path())?;
let mut writer = BufWriter::new(file);
serde_cbor::to_writer(&mut writer, entry).map_err(|e| LedgerError::Cbor(e.to_string()))?;
writer.flush()?;
Ok(())
}
pub fn read_entries(&self) -> Result<Vec<Entry>, LedgerError> {
let path = self.entries_path();
if !path.exists() {
return Ok(Vec::new());
}
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut out = Vec::new();
let iter = serde_cbor::Deserializer::from_reader(reader).into_iter::<Entry>();
for item in iter {
out.push(item.map_err(|e| LedgerError::Cbor(e.to_string()))?);
}
Ok(out)
}
pub fn compute_merkle_root(&self) -> Result<(usize, MerkleRoot), LedgerError> {
let entries = self.read_entries()?;
let hashes: Vec<[u8; 32]> = entries.iter().map(|e| e.hash()).collect();
Ok((hashes.len(), merkle_root(&hashes)))
}
pub fn append_checkpoint(&self, checkpoint: &Checkpoint) -> Result<(), LedgerError> {
std::fs::create_dir_all(self.log_dir())?;
let file = OpenOptions::new()
.create(true)
.append(true)
.open(self.checkpoints_path())?;
let mut writer = BufWriter::new(file);
serde_json::to_writer(&mut writer, checkpoint)?;
writer.write_all(b"\n")?;
writer.flush()?;
Ok(())
}
pub fn read_checkpoints(&self) -> Result<Vec<Checkpoint>, LedgerError> {
let path = self.checkpoints_path();
if !path.exists() {
return Ok(Vec::new());
}
let content = std::fs::read_to_string(path)?;
let mut out = Vec::new();
for (idx, line) in content.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
let cp: Checkpoint = serde_json::from_str(line).map_err(|e| {
LedgerError::InvalidLedger(format!(
"bad checkpoint json at line {}: {}",
idx + 1,
e
))
})?;
out.push(cp);
}
Ok(out)
}
pub fn append_checkpoint_attestation(
&self,
attestation: &CheckpointAttestation,
) -> Result<(), LedgerError> {
std::fs::create_dir_all(self.log_dir())?;
let file = OpenOptions::new()
.create(true)
.append(true)
.open(self.checkpoint_attestations_path())?;
let mut writer = BufWriter::new(file);
serde_json::to_writer(&mut writer, attestation)?;
writer.write_all(b"\n")?;
writer.flush()?;
Ok(())
}
pub fn read_checkpoint_attestations(&self) -> Result<Vec<CheckpointAttestation>, LedgerError> {
let path = self.checkpoint_attestations_path();
if !path.exists() {
return Ok(Vec::new());
}
let content = std::fs::read_to_string(path)?;
let mut out = Vec::new();
for (idx, line) in content.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
let att: CheckpointAttestation = serde_json::from_str(line).map_err(|e| {
LedgerError::InvalidLedger(format!(
"bad checkpoint attestation json at line {}: {}",
idx + 1,
e
))
})?;
out.push(att);
}
Ok(out)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Checkpoint {
pub ts_ms: u64,
pub entry_count: u64,
pub merkle_root_hex: String,
pub head_hash_hex: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub witness_pubkey_hex: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub witness_sig_hex: Option<String>,
}
pub fn ensure_parent_dir(path: &Path) -> Result<(), LedgerError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
Ok(())
}

View File

@@ -0,0 +1,119 @@
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use crate::entry::{Entry, EntryHash};
use crate::storage::{Checkpoint, LedgerDir, LedgerError};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditReport {
pub ok: bool,
pub entry_count: u64,
pub head_hash_hex: String,
pub failures: Vec<String>,
pub verified_checkpoints: u64,
}
pub fn verify_ledger_dir(
dir: &LedgerDir,
verify_checkpoints: bool,
) -> Result<AuditReport, LedgerError> {
let entries = dir.read_entries()?;
let mut failures = Vec::new();
let mut expected_prev: EntryHash = [0u8; 32];
let mut hashes: Vec<EntryHash> = Vec::with_capacity(entries.len());
for (idx, entry) in entries.iter().enumerate() {
if entry.prev_hash != expected_prev {
failures.push(format!(
"entry {} prev_hash mismatch: expected {}, got {}",
idx,
hex(&expected_prev),
hex(&entry.prev_hash)
));
break;
}
if let Err(e) = verify_entry_sig(entry) {
failures.push(format!("entry {} signature invalid: {}", idx, e));
break;
}
let h = entry.hash();
hashes.push(h);
expected_prev = h;
}
let mut verified_checkpoints_count = 0u64;
if failures.is_empty() && verify_checkpoints {
let checkpoints = dir.read_checkpoints()?;
for cp in checkpoints {
if verify_checkpoint(dir, &hashes, &cp).is_ok() {
verified_checkpoints_count += 1;
} else {
failures.push("checkpoint verification failed".into());
break;
}
}
}
Ok(AuditReport {
ok: failures.is_empty(),
entry_count: hashes.len() as u64,
head_hash_hex: hex(&expected_prev),
failures,
verified_checkpoints: verified_checkpoints_count,
})
}
fn verify_entry_sig(entry: &Entry) -> Result<(), LedgerError> {
let vk = VerifyingKey::from_bytes(&entry.author_pubkey)
.map_err(|e| LedgerError::InvalidLedger(format!("bad author_pubkey: {}", e)))?;
let sig = Signature::from_bytes(&entry.sig);
let msg = entry.unsigned().signing_message();
vk.verify(&msg, &sig)
.map_err(|e| LedgerError::InvalidLedger(format!("signature verify failed: {}", e)))?;
Ok(())
}
fn verify_checkpoint(
_dir: &LedgerDir,
hashes: &[EntryHash],
cp: &Checkpoint,
) -> Result<(), LedgerError> {
let want = cp.entry_count as usize;
if want > hashes.len() {
return Err(LedgerError::InvalidLedger(
"checkpoint entry_count exceeds log length".into(),
));
}
let prefix = &hashes[..want];
let root = crate::merkle::merkle_root(prefix);
let got_root = cp
.merkle_root_hex
.as_str()
.to_ascii_lowercase()
.trim()
.to_string();
let expect_root = hex(&root);
if got_root != expect_root {
return Err(LedgerError::InvalidLedger(format!(
"checkpoint merkle root mismatch: expected {}, got {}",
expect_root, got_root
)));
}
let head = if want == 0 {
[0u8; 32]
} else {
hashes[want - 1]
};
if cp.head_hash_hex.to_ascii_lowercase() != hex(&head) {
return Err(LedgerError::InvalidLedger(
"checkpoint head hash mismatch".into(),
));
}
Ok(())
}
pub fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}

View File

@@ -0,0 +1,147 @@
use ed25519_dalek::{Signer, SigningKey};
use ledger_core::attestation::{
checkpoint_attestation_signing_message_v0, checkpoint_attestation_signing_message_v1,
};
use ledger_core::{
CHECKPOINT_ATTESTATION_V0_FORMAT, CHECKPOINT_ATTESTATION_V1_FORMAT, CheckpointAttestationV0,
CheckpointAttestationV1, verify_checkpoint_attestation_v0, verify_checkpoint_attestation_v1,
};
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
#[test]
fn checkpoint_attestation_v0_vectors() {
let signing_key = SigningKey::from_bytes(&[0x44u8; 32]);
let witness_pubkey = signing_key.verifying_key().to_bytes();
let genesis = [0x11u8; 32];
let entry_count = 7u64;
let merkle_root = [0x22u8; 32];
let head_hash = [0x33u8; 32];
let ts_seen_ms = 123_456_789u64;
let msg = checkpoint_attestation_signing_message_v0(
&genesis,
entry_count,
&merkle_root,
&head_hash,
ts_seen_ms,
);
let sig = signing_key.sign(&msg).to_bytes();
let att = CheckpointAttestationV0 {
format: CHECKPOINT_ATTESTATION_V0_FORMAT.to_string(),
ledger_genesis_hash_hex: genesis,
checkpoint_entry_count: entry_count,
checkpoint_merkle_root_hex: merkle_root,
checkpoint_head_hash_hex: head_hash,
ts_seen_ms,
witness_pubkey_hex: witness_pubkey,
witness_sig_hex: sig,
};
verify_checkpoint_attestation_v0(&att).expect("attestation verifies");
assert_eq!(
hex(&witness_pubkey),
"d759793bbc13a2819a827c76adb6fba8a49aee007f49f2d0992d99b825ad2c48"
);
assert_eq!(
hex(&msg),
"4349565f4c45444745525f434845434b504f494e545f4154544553545f5630111111111111111111111111111111111111111111111111111111111111111107000000000000002222222222222222222222222222222222222222222222222222222222222222333333333333333333333333333333333333333333333333333333333333333315cd5b0700000000"
);
assert_eq!(
hex(&sig),
"ebc1627f5f82d848375ca819cad7a95bfd1da7c4eb4ef09478a10129aee5af2ad1096530965efc163742b4b6de11663e9c260f0015053bb6b04e6e00d306b40d"
);
}
#[test]
fn checkpoint_attestation_v1_vectors() {
let signing_key = SigningKey::from_bytes(&[0x44u8; 32]);
let witness_pubkey = signing_key.verifying_key().to_bytes();
let genesis = [0x11u8; 32];
let entry_count = 7u64;
let merkle_root = [0x22u8; 32];
let head_hash = [0x33u8; 32];
let checkpoint_ts_ms = 111_222_333u64;
let ts_seen_ms = 123_456_789u64;
let msg = checkpoint_attestation_signing_message_v1(
&genesis,
entry_count,
&merkle_root,
&head_hash,
checkpoint_ts_ms,
ts_seen_ms,
);
let sig = signing_key.sign(&msg).to_bytes();
let att = CheckpointAttestationV1 {
format: CHECKPOINT_ATTESTATION_V1_FORMAT.to_string(),
ledger_genesis_hash_hex: genesis,
checkpoint_entry_count: entry_count,
checkpoint_merkle_root_hex: merkle_root,
checkpoint_head_hash_hex: head_hash,
checkpoint_ts_ms,
ts_seen_ms,
witness_pubkey_hex: witness_pubkey,
witness_sig_hex: sig,
};
verify_checkpoint_attestation_v1(&att).expect("attestation verifies");
assert_eq!(
hex(&witness_pubkey),
"d759793bbc13a2819a827c76adb6fba8a49aee007f49f2d0992d99b825ad2c48"
);
assert_eq!(
hex(&msg),
"4349565f4c45444745525f434845434b504f494e545f4154544553545f563111111111111111111111111111111111111111111111111111111111111111110700000000000000222222222222222222222222222222222222222222222222222222222222222233333333333333333333333333333333333333333333333333333333333333333d1ea1060000000015cd5b0700000000"
);
assert_eq!(
hex(&sig),
"c682053071a6bcf666eb9561dbdb48a9b6e886e4f2e86a540793fbf8b38b165031e61dbbd8788ee75ec56148148a396ea310e6f765d492f060c68d4c68875e09"
);
}
#[test]
fn checkpoint_attestation_v1_rejects_seen_before_checkpoint() {
let signing_key = SigningKey::from_bytes(&[0x44u8; 32]);
let witness_pubkey = signing_key.verifying_key().to_bytes();
let genesis = [0x11u8; 32];
let entry_count = 7u64;
let merkle_root = [0x22u8; 32];
let head_hash = [0x33u8; 32];
let checkpoint_ts_ms = 200u64;
let ts_seen_ms = 100u64;
let msg = checkpoint_attestation_signing_message_v1(
&genesis,
entry_count,
&merkle_root,
&head_hash,
checkpoint_ts_ms,
ts_seen_ms,
);
let sig = signing_key.sign(&msg).to_bytes();
let att = CheckpointAttestationV1 {
format: CHECKPOINT_ATTESTATION_V1_FORMAT.to_string(),
ledger_genesis_hash_hex: genesis,
checkpoint_entry_count: entry_count,
checkpoint_merkle_root_hex: merkle_root,
checkpoint_head_hash_hex: head_hash,
checkpoint_ts_ms,
ts_seen_ms,
witness_pubkey_hex: witness_pubkey,
witness_sig_hex: sig,
};
assert!(verify_checkpoint_attestation_v1(&att).is_err());
}

View File

@@ -0,0 +1,28 @@
use ed25519_dalek::{Signer, SigningKey};
use ledger_core::EntryUnsigned;
#[test]
fn entry_cbor_roundtrips_with_sig_bytes() {
let signing_key = SigningKey::from_bytes(&[7u8; 32]);
let author_pubkey = signing_key.verifying_key().to_bytes();
let unsigned = EntryUnsigned {
prev_hash: [0u8; 32],
ts_ms: 42,
namespace: "law".to_string(),
payload_cbor: vec![0x65, b'h', b'e', b'l', b'l', b'o'],
author_pubkey,
};
let sig = signing_key.sign(&unsigned.signing_message()).to_bytes();
let entry = unsigned.to_entry(sig);
let bytes = serde_cbor::to_vec(&entry).expect("serialize entry");
let decoded: ledger_core::Entry = serde_cbor::from_slice(&bytes).expect("deserialize entry");
assert_eq!(decoded.hash(), entry.hash());
assert_eq!(decoded.sig, entry.sig);
assert_eq!(decoded.namespace, entry.namespace);
assert_eq!(decoded.payload_cbor, entry.payload_cbor);
assert_eq!(decoded.author_pubkey, entry.author_pubkey);
}

View File

@@ -0,0 +1,47 @@
use ed25519_dalek::{Signer, SigningKey};
use ledger_core::EntryUnsigned;
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
#[test]
fn golden_vectors_v0_signing_message_and_hashes() {
let seed = [0u8; 32];
let signing_key = SigningKey::from_bytes(&seed);
let author_pubkey = signing_key.verifying_key().to_bytes();
let unsigned = EntryUnsigned {
prev_hash: [0u8; 32],
ts_ms: 1,
namespace: "law".to_string(),
payload_cbor: vec![0x65, b'h', b'e', b'l', b'l', b'o'],
author_pubkey,
};
let msg = unsigned.signing_message();
let sig = signing_key.sign(&msg).to_bytes();
let entry = unsigned.to_entry(sig);
assert_eq!(
hex(&author_pubkey),
"3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29"
);
assert_eq!(
hex(&entry.payload_hash()),
"90eeb71f0d4b768a5d449e30035beb7ffccd75d228e5b38e8e9cbfaa01ddfae9"
);
assert_eq!(
hex(&msg),
"434c763000000000000000000000000000000000000000000000000000000000000000000100000000000000030000006c617790eeb71f0d4b768a5d449e30035beb7ffccd75d228e5b38e8e9cbfaa01ddfae93b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29"
);
assert_eq!(
hex(&entry.sig),
"03c91ecb5ab44a4329c12c1e789d22d685b81a0d16483d869d88fa24dd2c20a8b786066d12c8a238c88cea0b969f1c707df305b1edc56238d5d6166ecf986e05"
);
assert_eq!(
hex(&entry.hash()),
"ce13e5272d6705d9cbf2e261d2b9862abb187f737bea6fe3542e8680d689cc8e"
);
}

View File

@@ -0,0 +1,29 @@
use ledger_core::{ReadProofV0, verify_read_proof_v0};
fn leaf(i: u64) -> [u8; 32] {
blake3::hash(format!("leaf-{}", i).as_bytes()).into()
}
#[test]
fn read_proof_verifies_for_all_leaves() {
for n in 1..=33usize {
let leaves: Vec<[u8; 32]> = (0..n as u64).map(leaf).collect();
for idx in 0..n {
let proof = ReadProofV0::from_tree(&leaves, idx).expect("build proof");
verify_read_proof_v0(&proof).expect("verify proof");
}
}
}
#[test]
fn read_proof_rejects_tampering() {
let leaves: Vec<[u8; 32]> = (0..8u64).map(leaf).collect();
let mut proof = ReadProofV0::from_tree(&leaves, 3).expect("build proof");
assert!(verify_read_proof_v0(&proof).is_ok());
// Flip one nibble in the first sibling hash.
let mut s = proof.path[0].sibling_hash_hex.clone();
s.replace_range(0..1, if &s[0..1] == "0" { "1" } else { "0" });
proof.path[0].sibling_hash_hex = s;
assert!(verify_read_proof_v0(&proof).is_err());
}

View File

@@ -0,0 +1,177 @@
use base64::Engine as _;
use ed25519_dalek::{Signer, SigningKey};
use ledger_core::attestation::{
checkpoint_attestation_signing_message_v0, checkpoint_attestation_signing_message_v1,
};
use ledger_core::{
CHECKPOINT_ATTESTATION_V0_FORMAT, CHECKPOINT_ATTESTATION_V1_FORMAT, CheckpointAttestation,
CheckpointAttestationV0, CheckpointAttestationV1, EntryUnsigned, RECEIPT_V0_FORMAT,
ReadProofV0, ReceiptV0, verify_receipt_v0,
};
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
#[test]
fn receipt_v0_verifies_with_attestation() {
let author_key = SigningKey::from_bytes(&[7u8; 32]);
let author_pubkey = author_key.verifying_key().to_bytes();
let unsigned1 = EntryUnsigned {
prev_hash: [0u8; 32],
ts_ms: 1,
namespace: "law".to_string(),
payload_cbor: vec![0x61, 0x41], // "A"
author_pubkey,
};
let sig1 = author_key.sign(&unsigned1.signing_message()).to_bytes();
let entry1 = unsigned1.to_entry(sig1);
let h1 = entry1.hash();
let unsigned2 = EntryUnsigned {
prev_hash: h1,
ts_ms: 2,
namespace: "law".to_string(),
payload_cbor: vec![0x61, 0x42], // "B"
author_pubkey,
};
let sig2 = author_key.sign(&unsigned2.signing_message()).to_bytes();
let entry2 = unsigned2.to_entry(sig2);
let h2 = entry2.hash();
let hashes = vec![h1, h2];
let root = ledger_core::merkle::merkle_root(&hashes);
let head = h2;
let proof = ReadProofV0::from_tree(&hashes, 1).expect("build proof");
let witness_key = SigningKey::from_bytes(&[0x44u8; 32]);
let witness_pubkey = witness_key.verifying_key().to_bytes();
let ts_seen_ms = 123_456u64;
let msg = checkpoint_attestation_signing_message_v0(&h1, 2, &root, &head, ts_seen_ms);
let witness_sig = witness_key.sign(&msg).to_bytes();
let att = CheckpointAttestationV0 {
format: CHECKPOINT_ATTESTATION_V0_FORMAT.to_string(),
ledger_genesis_hash_hex: h1,
checkpoint_entry_count: 2,
checkpoint_merkle_root_hex: root,
checkpoint_head_hash_hex: head,
ts_seen_ms,
witness_pubkey_hex: witness_pubkey,
witness_sig_hex: witness_sig,
};
let entry_cbor = serde_cbor::to_vec(&entry2).expect("encode entry");
let receipt = ReceiptV0 {
format: RECEIPT_V0_FORMAT.to_string(),
entry_cbor_b64: base64::engine::general_purpose::STANDARD_NO_PAD.encode(entry_cbor),
entry_hash_hex: hex(&h2),
read_proof: proof,
attestations: vec![CheckpointAttestation::V0(att)],
};
verify_receipt_v0(&receipt, true).expect("receipt verifies");
}
#[test]
fn receipt_v0_verifies_with_attestation_v1() {
let author_key = SigningKey::from_bytes(&[7u8; 32]);
let author_pubkey = author_key.verifying_key().to_bytes();
let unsigned1 = EntryUnsigned {
prev_hash: [0u8; 32],
ts_ms: 1,
namespace: "law".to_string(),
payload_cbor: vec![0x61, 0x41], // "A"
author_pubkey,
};
let sig1 = author_key.sign(&unsigned1.signing_message()).to_bytes();
let entry1 = unsigned1.to_entry(sig1);
let h1 = entry1.hash();
let unsigned2 = EntryUnsigned {
prev_hash: h1,
ts_ms: 2,
namespace: "law".to_string(),
payload_cbor: vec![0x61, 0x42], // "B"
author_pubkey,
};
let sig2 = author_key.sign(&unsigned2.signing_message()).to_bytes();
let entry2 = unsigned2.to_entry(sig2);
let h2 = entry2.hash();
let hashes = vec![h1, h2];
let root = ledger_core::merkle::merkle_root(&hashes);
let head = h2;
let proof = ReadProofV0::from_tree(&hashes, 1).expect("build proof");
let witness_key = SigningKey::from_bytes(&[0x44u8; 32]);
let witness_pubkey = witness_key.verifying_key().to_bytes();
let checkpoint_ts_ms = 100u64;
let ts_seen_ms = 123_456u64;
let msg = checkpoint_attestation_signing_message_v1(
&h1,
2,
&root,
&head,
checkpoint_ts_ms,
ts_seen_ms,
);
let witness_sig = witness_key.sign(&msg).to_bytes();
let att = CheckpointAttestationV1 {
format: CHECKPOINT_ATTESTATION_V1_FORMAT.to_string(),
ledger_genesis_hash_hex: h1,
checkpoint_entry_count: 2,
checkpoint_merkle_root_hex: root,
checkpoint_head_hash_hex: head,
checkpoint_ts_ms,
ts_seen_ms,
witness_pubkey_hex: witness_pubkey,
witness_sig_hex: witness_sig,
};
let entry_cbor = serde_cbor::to_vec(&entry2).expect("encode entry");
let receipt = ReceiptV0 {
format: RECEIPT_V0_FORMAT.to_string(),
entry_cbor_b64: base64::engine::general_purpose::STANDARD_NO_PAD.encode(entry_cbor),
entry_hash_hex: hex(&h2),
read_proof: proof,
attestations: vec![CheckpointAttestation::V1(att)],
};
verify_receipt_v0(&receipt, true).expect("receipt verifies");
}
#[test]
fn receipt_v0_rejects_tampered_hash() {
let author_key = SigningKey::from_bytes(&[7u8; 32]);
let author_pubkey = author_key.verifying_key().to_bytes();
let unsigned = EntryUnsigned {
prev_hash: [0u8; 32],
ts_ms: 1,
namespace: "law".to_string(),
payload_cbor: vec![0x61, 0x41], // "A"
author_pubkey,
};
let sig = author_key.sign(&unsigned.signing_message()).to_bytes();
let entry = unsigned.to_entry(sig);
let h = entry.hash();
let hashes = vec![h];
let proof = ReadProofV0::from_tree(&hashes, 0).expect("build proof");
let entry_cbor = serde_cbor::to_vec(&entry).expect("encode entry");
let mut receipt = ReceiptV0 {
format: RECEIPT_V0_FORMAT.to_string(),
entry_cbor_b64: base64::engine::general_purpose::STANDARD_NO_PAD.encode(entry_cbor),
entry_hash_hex: "00".repeat(32),
read_proof: proof,
attestations: vec![],
};
assert!(verify_receipt_v0(&receipt, false).is_err());
receipt.entry_hash_hex = hex(&h);
assert!(verify_receipt_v0(&receipt, false).is_ok());
}