init: cryptographic append-only ledger
This commit is contained in:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
target/
|
||||
# local ledger data
|
||||
log/
|
||||
.DS_Store
|
||||
.secrets/
|
||||
|
||||
# editor / temp
|
||||
**/*.log
|
||||
**/*.tmp
|
||||
*.swp
|
||||
*.swo
|
||||
12
ASSURANCE.md
Normal file
12
ASSURANCE.md
Normal 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
616
Cargo.lock
generated
Normal 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
4
Cargo.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/ledger-core", "crates/ledger-cli"]
|
||||
|
||||
185
FORMAT.md
Normal file
185
FORMAT.md
Normal 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
50
README.md
Normal 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`.
|
||||
20
crates/ledger-cli/Cargo.toml
Normal file
20
crates/ledger-cli/Cargo.toml
Normal 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"
|
||||
1030
crates/ledger-cli/src/main.rs
Normal file
1030
crates/ledger-cli/src/main.rs
Normal file
File diff suppressed because it is too large
Load Diff
14
crates/ledger-core/Cargo.toml
Normal file
14
crates/ledger-core/Cargo.toml
Normal 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"
|
||||
212
crates/ledger-core/src/attestation.rs
Normal file
212
crates/ledger-core/src/attestation.rs
Normal 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)
|
||||
}
|
||||
141
crates/ledger-core/src/entry.rs
Normal file
141
crates/ledger-core/src/entry.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
40
crates/ledger-core/src/identity.rs
Normal file
40
crates/ledger-core/src/identity.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
21
crates/ledger-core/src/lib.rs
Normal file
21
crates/ledger-core/src/lib.rs
Normal 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};
|
||||
112
crates/ledger-core/src/merkle.rs
Normal file
112
crates/ledger-core/src/merkle.rs
Normal 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))
|
||||
}
|
||||
146
crates/ledger-core/src/proof.rs
Normal file
146
crates/ledger-core/src/proof.rs
Normal 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, ¤t),
|
||||
SideV0::Right => node_hash(¤t, &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)
|
||||
}
|
||||
127
crates/ledger-core/src/receipt.rs
Normal file
127
crates/ledger-core/src/receipt.rs
Normal 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)
|
||||
}
|
||||
189
crates/ledger-core/src/storage.rs
Normal file
189
crates/ledger-core/src/storage.rs
Normal 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(())
|
||||
}
|
||||
119
crates/ledger-core/src/verify.rs
Normal file
119
crates/ledger-core/src/verify.rs
Normal 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()
|
||||
}
|
||||
147
crates/ledger-core/tests/attestation_vectors.rs
Normal file
147
crates/ledger-core/tests/attestation_vectors.rs
Normal 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());
|
||||
}
|
||||
28
crates/ledger-core/tests/entry_cbor_roundtrip.rs
Normal file
28
crates/ledger-core/tests/entry_cbor_roundtrip.rs
Normal 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);
|
||||
}
|
||||
47
crates/ledger-core/tests/golden_vectors.rs
Normal file
47
crates/ledger-core/tests/golden_vectors.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
29
crates/ledger-core/tests/read_proof.rs
Normal file
29
crates/ledger-core/tests/read_proof.rs
Normal 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());
|
||||
}
|
||||
177
crates/ledger-core/tests/receipt.rs
Normal file
177
crates/ledger-core/tests/receipt.rs
Normal 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());
|
||||
}
|
||||
Reference in New Issue
Block a user