commit 833c408a3070166da625114a9f3bc41f7754758b Author: Vault Sovereign Date: Fri Dec 26 23:21:39 2025 +0000 init: cryptographic append-only ledger diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f84938 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +target/ +# local ledger data +log/ +.DS_Store +.secrets/ + +# editor / temp +**/*.log +**/*.tmp +*.swp +*.swo diff --git a/ASSURANCE.md b/ASSURANCE.md new file mode 100644 index 0000000..d365838 --- /dev/null +++ b/ASSURANCE.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..3d24bf9 --- /dev/null +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c9392da --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,4 @@ +[workspace] +resolver = "2" +members = ["crates/ledger-core", "crates/ledger-cli"] + diff --git a/FORMAT.md b/FORMAT.md new file mode 100644 index 0000000..e1518f4 --- /dev/null +++ b/FORMAT.md @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..dd2c2b5 --- /dev/null +++ b/README.md @@ -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`. diff --git a/crates/ledger-cli/Cargo.toml b/crates/ledger-cli/Cargo.toml new file mode 100644 index 0000000..ae6f793 --- /dev/null +++ b/crates/ledger-cli/Cargo.toml @@ -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" diff --git a/crates/ledger-cli/src/main.rs b/crates/ledger-cli/src/main.rs new file mode 100644 index 0000000..c5136c8 --- /dev/null +++ b/crates/ledger-cli/src/main.rs @@ -0,0 +1,1030 @@ +use std::fs; +use std::io::Write; +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use base64::Engine as _; +use clap::{Parser, Subcommand, ValueEnum}; +use ed25519_dalek::Signer; + +use ledger_core::entry::EntryUnsigned; +use ledger_core::identity::KeyFile; +use ledger_core::storage::{Checkpoint, LedgerDir, ensure_parent_dir}; +use ledger_core::verify::verify_ledger_dir; +use ledger_core::{ + CHECKPOINT_ATTESTATION_V1_FORMAT, CheckpointAttestation, CheckpointAttestationV1, + RECEIPT_V0_FORMAT, ReadProofV0, ReceiptV0, verify_checkpoint_attestation, verify_read_proof_v0, + verify_receipt_v0, +}; +use sha2::{Digest, Sha256}; + +#[derive(Parser)] +#[command(name = "ledger", version)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + Init { + #[arg(long)] + dir: PathBuf, + }, + Keygen { + #[arg(long)] + out: PathBuf, + }, + Append { + #[arg(long)] + dir: PathBuf, + #[arg(long)] + key: PathBuf, + #[arg(long, default_value = "default")] + namespace: String, + #[arg(long, value_enum, default_value_t = PayloadFormat::Text)] + payload_format: PayloadFormat, + #[arg(long)] + payload: Option, + #[arg(long)] + payload_file: Option, + }, + AnchorFile { + #[arg(long)] + dir: PathBuf, + #[arg(long)] + key: PathBuf, + #[arg(long, default_value = "ops.anchor")] + namespace: String, + #[arg(long)] + file: PathBuf, + #[arg(long)] + git_root: Option, + #[arg(long)] + git_commit: Option, + }, + Receipt { + #[arg(long)] + dir: PathBuf, + #[arg(long)] + entry_hash: String, + #[arg(long)] + entry_count: Option, + #[arg(long)] + out: Option, + #[arg(long)] + require_attestation: bool, + }, + VerifyReceipt { + #[arg(long)] + receipt: PathBuf, + #[arg(long)] + require_attestation: bool, + }, + Checkpoint { + #[arg(long)] + dir: PathBuf, + }, + Attest { + #[arg(long)] + dir: PathBuf, + #[arg(long)] + witness_key: PathBuf, + #[arg(long)] + entry_count: Option, + #[arg(long)] + ts_seen_ms: Option, + }, + VerifyAttestations { + #[arg(long)] + dir: PathBuf, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, + }, + Prove { + #[arg(long)] + dir: PathBuf, + #[arg(long)] + entry_hash: String, + #[arg(long)] + entry_count: Option, + #[arg(long)] + out: Option, + }, + VerifyProof { + #[arg(long)] + proof: PathBuf, + }, + /// Ingest an external receipt (e.g., vm-cloud) verbatim with hash metadata. + IngestExternalReceipt { + #[arg(long)] + dir: PathBuf, + #[arg(long)] + receipt: PathBuf, + }, + Verify { + #[arg(long)] + dir: PathBuf, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, + #[arg(long)] + no_checkpoints: bool, + }, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +enum PayloadFormat { + Text, + Json, + Cbor, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +enum OutputFormat { + Text, + Json, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + match cli.command { + Command::Init { dir } => { + let ledger = LedgerDir::new(dir); + ledger.init()?; + } + Command::Keygen { out } => { + ensure_parent_dir(&out)?; + let key = KeyFile::generate(); + let json = serde_json::to_vec_pretty(&key)?; + std::fs::write(&out, json)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&out, std::fs::Permissions::from_mode(0o600)).ok(); + } + let vk = key.verifying_key()?; + println!("{}", hex(&vk.to_bytes())); + } + Command::Append { + dir, + key, + namespace, + payload_format, + payload, + payload_file, + } => { + let key_contents = + std::fs::read(&key).with_context(|| format!("read key file {}", key.display()))?; + let key_file: KeyFile = serde_json::from_slice(&key_contents)?; + let signing_key = key_file.signing_key()?; + let author_pubkey = signing_key.verifying_key().to_bytes(); + + let ledger = LedgerDir::new(dir); + ledger.init()?; + let entries = ledger.read_entries()?; + let prev_hash = entries.last().map(|e| e.hash()).unwrap_or([0u8; 32]); + + let payload_cbor = + read_payload(payload_format, payload.as_deref(), payload_file.as_deref())?; + + let unsigned = EntryUnsigned { + prev_hash, + ts_ms: now_ms(), + namespace, + payload_cbor, + author_pubkey, + }; + let sig = signing_key.sign(&unsigned.signing_message()).to_bytes(); + let entry = unsigned.to_entry(sig); + ledger.append_entry(&entry)?; + println!("{}", hex(&entry.hash())); + } + Command::AnchorFile { + dir, + key, + namespace, + file, + git_root, + git_commit, + } => { + let key_contents = + std::fs::read(&key).with_context(|| format!("read key file {}", key.display()))?; + let key_file: KeyFile = serde_json::from_slice(&key_contents)?; + let signing_key = key_file.signing_key()?; + let author_pubkey = signing_key.verifying_key().to_bytes(); + + let ledger = LedgerDir::new(dir); + ledger.init()?; + let entries = ledger.read_entries()?; + let prev_hash = entries.last().map(|e| e.hash()).unwrap_or([0u8; 32]); + + let bytes = std::fs::read(&file) + .with_context(|| format!("read file to anchor {}", file.display()))?; + let file_hash: [u8; 32] = blake3::hash(&bytes).into(); + let file_path = canonical_relpath(&file, git_root.as_deref())?; + + let commit = match (git_commit, git_root.as_deref()) { + (Some(c), _) => Some(c), + (None, Some(root)) => Some(git_head(root)?), + (None, None) => None, + }; + let dirty = match git_root.as_deref() { + Some(root) => Some(git_dirty(root)?), + None => None, + }; + + let payload = serde_json::json!({ + "type": "file_anchor.v0", + "path": file_path, + "hash_blake3_hex": hex(&file_hash), + "bytes": bytes.len() as u64, + "git": { + "commit": commit, + "dirty": dirty, + }, + }); + let payload_cbor = serde_cbor::to_vec(&payload).context("encode payload as cbor")?; + + let unsigned = EntryUnsigned { + prev_hash, + ts_ms: now_ms(), + namespace, + payload_cbor, + author_pubkey, + }; + let sig = signing_key.sign(&unsigned.signing_message()).to_bytes(); + let entry = unsigned.to_entry(sig); + ledger.append_entry(&entry)?; + println!("{}", hex(&entry.hash())); + } + Command::Receipt { + dir, + entry_hash, + entry_count, + out, + require_attestation, + } => { + let ledger = LedgerDir::new(dir); + let entries = ledger.read_entries()?; + let hashes: Vec<[u8; 32]> = entries.iter().map(|e| e.hash()).collect(); + + let want_hash = parse_hex_32(&entry_hash)?; + let index = hashes + .iter() + .position(|h| h == &want_hash) + .context("entry_hash not found")?; + let entry = entries[index].clone(); + + let checkpoints = ledger.read_checkpoints()?; + let cp = select_checkpoint_covering_index(&checkpoints, entry_count, index)?; + let cp_count_usize: usize = cp + .entry_count + .try_into() + .map_err(|_| anyhow::anyhow!("checkpoint entry_count out of range"))?; + if cp_count_usize > hashes.len() { + anyhow::bail!( + "checkpoint entry_count {} exceeds log length {}", + cp_count_usize, + hashes.len() + ); + } + let prefix = &hashes[..cp_count_usize]; + let computed_root = ledger_core::merkle::merkle_root(prefix); + let computed_head = if prefix.is_empty() { + [0u8; 32] + } else { + prefix[prefix.len() - 1] + }; + + let proof = ReadProofV0::from_tree(prefix, index)?; + + let attestations = ledger + .read_checkpoint_attestations()? + .into_iter() + .filter(|a| match a { + CheckpointAttestation::V0(a0) => { + a0.checkpoint_entry_count == cp.entry_count + && a0.checkpoint_merkle_root_hex == computed_root + && a0.checkpoint_head_hash_hex == computed_head + } + CheckpointAttestation::V1(a1) => { + a1.checkpoint_entry_count == cp.entry_count + && a1.checkpoint_merkle_root_hex == computed_root + && a1.checkpoint_head_hash_hex == computed_head + && a1.checkpoint_ts_ms == cp.ts_ms + } + }) + .collect::>(); + + if require_attestation && attestations.is_empty() { + anyhow::bail!("no witness attestations found for selected checkpoint"); + } + + let entry_cbor = serde_cbor::to_vec(&entry).context("encode entry as cbor")?; + let entry_cbor_b64 = + base64::engine::general_purpose::STANDARD_NO_PAD.encode(entry_cbor); + let receipt = ReceiptV0 { + format: RECEIPT_V0_FORMAT.to_string(), + entry_cbor_b64, + entry_hash_hex: hex(&want_hash), + read_proof: proof, + attestations, + }; + verify_receipt_v0(&receipt, require_attestation)?; + + let json = serde_json::to_string_pretty(&receipt)?; + match out { + Some(path) => { + ensure_parent_dir(&path)?; + std::fs::write(path, json)?; + } + None => { + println!("{}", json); + } + } + } + Command::VerifyReceipt { + receipt, + require_attestation, + } => { + let bytes = std::fs::read(&receipt) + .with_context(|| format!("read receipt {}", receipt.display()))?; + let receipt: ReceiptV0 = + serde_json::from_slice(&bytes).context("parse receipt json")?; + verify_receipt_v0(&receipt, require_attestation)?; + println!("ok"); + } + Command::Checkpoint { dir } => { + let ledger = LedgerDir::new(dir); + ledger.init()?; + let entries = ledger.read_entries()?; + let hashes: Vec<[u8; 32]> = entries.iter().map(|e| e.hash()).collect(); + let root = ledger_core::merkle::merkle_root(&hashes); + let head = hashes.last().copied().unwrap_or([0u8; 32]); + let cp = Checkpoint { + ts_ms: now_ms(), + entry_count: hashes.len() as u64, + merkle_root_hex: hex(&root), + head_hash_hex: hex(&head), + witness_pubkey_hex: None, + witness_sig_hex: None, + }; + ledger.append_checkpoint(&cp)?; + println!("{}", cp.merkle_root_hex); + } + Command::Attest { + dir, + witness_key, + entry_count, + ts_seen_ms, + } => { + let ledger = LedgerDir::new(dir); + + let report = verify_ledger_dir(&ledger, false)?; + if !report.ok { + anyhow::bail!("ledger invalid: {}", report.failures.join("; ")); + } + + let entries = ledger.read_entries()?; + let hashes: Vec<[u8; 32]> = entries.iter().map(|e| e.hash()).collect(); + if hashes.is_empty() { + anyhow::bail!("cannot attest: ledger has no entries (no genesis hash)"); + } + let genesis_hash = hashes[0]; + + let checkpoints = ledger.read_checkpoints()?; + let cp = select_checkpoint(&checkpoints, entry_count)?; + let cp_count_usize: usize = cp + .entry_count + .try_into() + .map_err(|_| anyhow::anyhow!("checkpoint entry_count out of range"))?; + if cp_count_usize > hashes.len() { + anyhow::bail!( + "checkpoint entry_count {} exceeds log length {}", + cp_count_usize, + hashes.len() + ); + } + + let prefix = &hashes[..cp_count_usize]; + let computed_root = ledger_core::merkle::merkle_root(prefix); + let computed_head = if prefix.is_empty() { + [0u8; 32] + } else { + prefix[prefix.len() - 1] + }; + + let cp_root = parse_hex_32(&cp.merkle_root_hex)?; + let cp_head = parse_hex_32(&cp.head_hash_hex)?; + if cp_root != computed_root || cp_head != computed_head { + anyhow::bail!("checkpoint does not match computed root/head; refusing to attest"); + } + + let witness_key_contents = std::fs::read(&witness_key) + .with_context(|| format!("read witness key file {}", witness_key.display()))?; + let key_file: KeyFile = serde_json::from_slice(&witness_key_contents)?; + let signing_key = key_file.signing_key()?; + let witness_pubkey = signing_key.verifying_key().to_bytes(); + + let seen_ms = ts_seen_ms.unwrap_or_else(now_ms); + if seen_ms < cp.ts_ms { + anyhow::bail!("ts_seen_ms must be >= checkpoint ts_ms"); + } + let msg = ledger_core::attestation::checkpoint_attestation_signing_message_v1( + &genesis_hash, + cp.entry_count, + &computed_root, + &computed_head, + cp.ts_ms, + seen_ms, + ); + let sig = signing_key.sign(&msg).to_bytes(); + + let att = CheckpointAttestation::V1(CheckpointAttestationV1 { + format: CHECKPOINT_ATTESTATION_V1_FORMAT.to_string(), + ledger_genesis_hash_hex: genesis_hash, + checkpoint_entry_count: cp.entry_count, + checkpoint_merkle_root_hex: computed_root, + checkpoint_head_hash_hex: computed_head, + checkpoint_ts_ms: cp.ts_ms, + ts_seen_ms: seen_ms, + witness_pubkey_hex: witness_pubkey, + witness_sig_hex: sig, + }); + + ledger.append_checkpoint_attestation(&att)?; + println!("{}", serde_json::to_string_pretty(&att)?); + } + Command::VerifyAttestations { dir, format } => { + let ledger = LedgerDir::new(dir); + + let mut failures = Vec::new(); + let report = verify_ledger_dir(&ledger, false)?; + if !report.ok { + failures.push(format!("ledger invalid: {}", report.failures.join("; "))); + } + + let entries = ledger.read_entries()?; + let hashes: Vec<[u8; 32]> = entries.iter().map(|e| e.hash()).collect(); + let genesis = hashes.first().copied().unwrap_or([0u8; 32]); + + let checkpoints = ledger.read_checkpoints()?; + let mut checkpoint_set_v0 = + std::collections::HashSet::<(u64, [u8; 32], [u8; 32])>::new(); + let mut checkpoint_set_v1 = + std::collections::HashSet::<(u64, [u8; 32], [u8; 32], u64)>::new(); + for (cp_idx, cp) in checkpoints.iter().enumerate() { + let cp_root = match parse_hex_32(&cp.merkle_root_hex) { + Ok(v) => v, + Err(e) => { + failures.push(format!( + "checkpoint {} invalid merkle_root_hex: {}", + cp_idx + 1, + e + )); + continue; + } + }; + let cp_head = match parse_hex_32(&cp.head_hash_hex) { + Ok(v) => v, + Err(e) => { + failures.push(format!( + "checkpoint {} invalid head_hash_hex: {}", + cp_idx + 1, + e + )); + continue; + } + }; + let cp_count_usize: usize = match cp.entry_count.try_into() { + Ok(v) => v, + Err(_) => { + failures.push(format!("checkpoint {} invalid entry_count", cp_idx + 1)); + continue; + } + }; + if cp_count_usize > hashes.len() { + failures.push(format!( + "checkpoint {} invalid: entry_count {} exceeds log length {}", + cp_idx + 1, + cp_count_usize, + hashes.len() + )); + continue; + } + let prefix = &hashes[..cp_count_usize]; + let computed_root = ledger_core::merkle::merkle_root(prefix); + let computed_head = if prefix.is_empty() { + [0u8; 32] + } else { + prefix[prefix.len() - 1] + }; + if cp_root == computed_root && cp_head == computed_head { + checkpoint_set_v0.insert((cp.entry_count, cp_root, cp_head)); + checkpoint_set_v1.insert((cp.entry_count, cp_root, cp_head, cp.ts_ms)); + } else { + failures.push(format!( + "checkpoint {} invalid: merkle root/head mismatch", + cp_idx + 1 + )); + } + } + + let attestations = ledger.read_checkpoint_attestations()?; + let total = attestations.len() as u64; + let mut ok_count = 0u64; + let mut last_by_witness = std::collections::HashMap::<[u8; 32], u64>::new(); + + for (idx, att) in attestations.iter().enumerate() { + let mut ok = true; + if let Err(e) = verify_checkpoint_attestation(att) { + failures.push(format!("attestation {} invalid: {}", idx + 1, e)); + ok = false; + } + if hashes.is_empty() { + failures.push(format!( + "attestation {} invalid: ledger has no entries (no genesis)", + idx + 1 + )); + ok = false; + } + + let (ledger_genesis_hash, checkpoint_entry_count, witness_pubkey) = match att { + CheckpointAttestation::V0(a) => ( + a.ledger_genesis_hash_hex, + a.checkpoint_entry_count, + a.witness_pubkey_hex, + ), + CheckpointAttestation::V1(a) => ( + a.ledger_genesis_hash_hex, + a.checkpoint_entry_count, + a.witness_pubkey_hex, + ), + }; + + if !hashes.is_empty() && ledger_genesis_hash != genesis { + failures.push(format!( + "attestation {} invalid: genesis hash mismatch", + idx + 1 + )); + ok = false; + } + + let checkpoint_ok = match att { + CheckpointAttestation::V0(a) => checkpoint_set_v0.contains(&( + a.checkpoint_entry_count, + a.checkpoint_merkle_root_hex, + a.checkpoint_head_hash_hex, + )), + CheckpointAttestation::V1(a) => checkpoint_set_v1.contains(&( + a.checkpoint_entry_count, + a.checkpoint_merkle_root_hex, + a.checkpoint_head_hash_hex, + a.checkpoint_ts_ms, + )), + }; + if !checkpoint_ok { + failures.push(format!( + "attestation {} invalid: referenced checkpoint not found/verified", + idx + 1 + )); + ok = false; + } + + if let Some(prev) = last_by_witness.get(&witness_pubkey) { + if checkpoint_entry_count < *prev { + failures.push(format!( + "attestation {} invalid: witness checkpoint_entry_count decreased ({} -> {})", + idx + 1, + prev, + checkpoint_entry_count + )); + ok = false; + } + } + last_by_witness.insert(witness_pubkey, checkpoint_entry_count); + + if ok { + ok_count += 1; + } + } + + let ok = failures.is_empty(); + match format { + OutputFormat::Text => { + println!("ok: {}", ok); + println!("attestations_total: {}", total); + println!("attestations_ok: {}", ok_count); + for f in failures { + println!("failure: {}", f); + } + } + OutputFormat::Json => { + let v = serde_json::json!({ + "ok": ok, + "attestations_total": total, + "attestations_ok": ok_count, + "failures": failures, + }); + println!("{}", serde_json::to_string_pretty(&v)?); + } + } + if !ok { + std::process::exit(2); + } + } + Command::Prove { + dir, + entry_hash, + entry_count, + out, + } => { + let ledger = LedgerDir::new(dir); + let entries = ledger.read_entries()?; + let hashes: Vec<[u8; 32]> = entries.iter().map(|e| e.hash()).collect(); + + let selected_count = if let Some(n) = entry_count { + n + } else { + let cps = ledger.read_checkpoints()?; + cps.last() + .map(|c| c.entry_count) + .unwrap_or(hashes.len() as u64) + }; + let selected_count_usize: usize = selected_count + .try_into() + .map_err(|_| anyhow::anyhow!("entry_count out of range"))?; + if selected_count_usize > hashes.len() { + anyhow::bail!( + "entry_count {} exceeds current log length {}", + selected_count_usize, + hashes.len() + ); + } + + let want_hash = parse_hex_32(&entry_hash)?; + let prefix = &hashes[..selected_count_usize]; + let index = prefix + .iter() + .position(|h| h == &want_hash) + .context("entry_hash not found in selected prefix")?; + + let proof = ReadProofV0::from_tree(prefix, index)?; + let json = serde_json::to_string_pretty(&proof)?; + match out { + Some(path) => { + ensure_parent_dir(&path)?; + std::fs::write(path, json)?; + } + None => { + println!("{}", json); + } + } + } + Command::VerifyProof { proof } => { + let bytes = + std::fs::read(&proof).with_context(|| format!("read proof {}", proof.display()))?; + let rp: ReadProofV0 = serde_json::from_slice(&bytes).context("parse proof json")?; + verify_read_proof_v0(&rp)?; + println!("ok"); + } + Command::IngestExternalReceipt { dir, receipt } => { + let ledger = LedgerDir::new(dir); + ledger.init()?; + + let log_dir = ledger.log_dir(); + let external_dir = log_dir.join("external-receipts"); + fs::create_dir_all(&external_dir).with_context(|| { + format!("create external receipts dir {}", external_dir.display()) + })?; + + let source_bytes = fs::read(&receipt) + .with_context(|| format!("read receipt {}", receipt.display()))?; + let blake3_hash = blake3::hash(&source_bytes); + let sha256_hash = Sha256::digest(&source_bytes); + + let receipt_value: serde_json::Value = serde_json::from_slice(&source_bytes) + .context("parse receipt json for canonicalization")?; + + // Compute canonical body hashes (vm-cloud style) for integrity triangulation. + let canonical_bytes = canonicalize_receipt_body_value(&receipt_value)?; + let canonical_blake3 = blake3::hash(&canonical_bytes); + let canonical_sha256 = Sha256::digest(&canonical_bytes); + let canonical_blake3_hex = hex(canonical_blake3.as_bytes()); + let canonical_sha256_hex = hex(canonical_sha256.as_slice()); + + let ts_ms = now_ms(); + let stored_filename = format!("{:013}-{}.json", ts_ms, hex(blake3_hash.as_bytes())); + let stored_path = external_dir.join(stored_filename); + if stored_path.exists() { + anyhow::bail!( + "stored receipt path already exists: {}", + stored_path.display() + ); + } + + fs::write(&stored_path, &source_bytes) + .with_context(|| format!("write stored receipt {}", stored_path.display()))?; + + let source_path = canonical_relpath(&receipt, None)?; + let stored_path_rel = canonical_relpath(&stored_path, Some(&ledger.root))?; + let (source_blake3, source_sha256, source_hash_alg) = + extract_source_hashes(&receipt_value); + let receipt_is_v1 = receipt_declares_v1(&receipt_value); + let source_hash_matches_canonical = if receipt_is_v1 { + match (source_blake3.as_ref(), source_sha256.as_ref()) { + (Some(b3), Some(s2)) => Some( + b3.eq_ignore_ascii_case(&canonical_blake3_hex) + && s2.eq_ignore_ascii_case(&canonical_sha256_hex), + ), + (Some(b3), None) => Some(b3.eq_ignore_ascii_case(&canonical_blake3_hex)), + (None, Some(s2)) => Some(s2.eq_ignore_ascii_case(&canonical_sha256_hex)), + (None, None) => None, + } + } else { + None + }; + + let record = serde_json::json!({ + "ingested_at_ms": ts_ms, + "source_path": source_path, + "stored_path": stored_path_rel, + "blake3_hex": hex(blake3_hash.as_bytes()), + "sha256_hex": hex(sha256_hash.as_slice()), + "bytes": source_bytes.len() as u64, + "source_blake3": source_blake3, + "source_sha256": source_sha256, + "source_hash_alg": source_hash_alg, + "canonical_blake3_hex": canonical_blake3_hex, + "canonical_sha256_hex": canonical_sha256_hex, + "source_hash_matches_canonical": source_hash_matches_canonical, + }); + + let index_path = log_dir.join("external-receipts.jsonl"); + ensure_parent_dir(&index_path)?; + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&index_path) + .with_context(|| format!("open {}", index_path.display()))?; + serde_json::to_writer(&mut file, &record)?; + file.write_all(b"\n")?; + + println!("stored {}", stored_path_rel); + } + Command::Verify { + dir, + format, + no_checkpoints, + } => { + let ledger = LedgerDir::new(dir); + let report = verify_ledger_dir(&ledger, !no_checkpoints)?; + match format { + OutputFormat::Text => { + println!("ok: {}", report.ok); + println!("entries: {}", report.entry_count); + println!("head: {}", report.head_hash_hex); + if !report.failures.is_empty() { + for f in report.failures { + println!("failure: {}", f); + } + } + } + OutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(&report)?); + } + } + if !report.ok { + std::process::exit(2); + } + } + } + Ok(()) +} + +fn read_payload( + fmt: PayloadFormat, + payload: Option<&str>, + payload_file: Option<&std::path::Path>, +) -> Result> { + let bytes = match (payload, payload_file) { + (Some(_), Some(_)) => anyhow::bail!("use only one of --payload or --payload-file"), + (Some(s), None) => s.as_bytes().to_vec(), + (None, Some(path)) => { + std::fs::read(path).with_context(|| format!("read payload file {}", path.display()))? + } + (None, None) => anyhow::bail!("provide --payload or --payload-file"), + }; + + Ok(match fmt { + PayloadFormat::Text => { + serde_cbor::to_vec(&String::from_utf8(bytes)?).context("encode text as cbor")? + } + PayloadFormat::Json => { + let v: serde_json::Value = serde_json::from_slice(&bytes).context("parse json")?; + serde_cbor::to_vec(&v).context("encode json as cbor")? + } + PayloadFormat::Cbor => bytes, + }) +} + +fn now_ms() -> u64 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + now.as_millis() as u64 +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} + +fn parse_hex_32(s: &str) -> Result<[u8; 32]> { + let s = s.trim().strip_prefix("0x").unwrap_or(s.trim()); + if s.len() != 64 { + anyhow::bail!("expected 64 hex chars for 32-byte hash, got {}", s.len()); + } + let mut out = [0u8; 32]; + for i in 0..32 { + let idx = i * 2; + out[i] = u8::from_str_radix(&s[idx..idx + 2], 16) + .map_err(|_| anyhow::anyhow!("invalid hex at byte {}", i))?; + } + Ok(out) +} + +fn select_checkpoint(checkpoints: &[Checkpoint], entry_count: Option) -> Result { + let cp = if let Some(n) = entry_count { + checkpoints + .iter() + .rev() + .find(|c| c.entry_count == n) + .cloned() + .context("no checkpoint found for requested entry_count")? + } else { + checkpoints + .last() + .cloned() + .context("no checkpoints found")? + }; + Ok(cp) +} + +fn select_checkpoint_covering_index( + checkpoints: &[Checkpoint], + entry_count: Option, + index: usize, +) -> Result { + let cp = if let Some(n) = entry_count { + let cp = checkpoints + .iter() + .rev() + .find(|c| c.entry_count == n) + .cloned() + .context("no checkpoint found for requested entry_count")?; + let count_usize: usize = cp + .entry_count + .try_into() + .map_err(|_| anyhow::anyhow!("checkpoint entry_count out of range"))?; + if index >= count_usize { + anyhow::bail!("selected checkpoint does not cover entry index {}", index); + } + cp + } else { + checkpoints + .iter() + .rev() + .find(|c| c.entry_count as usize > index) + .cloned() + .context("no checkpoint covers requested entry")? + }; + Ok(cp) +} + +fn canonical_relpath(path: &std::path::Path, root: Option<&std::path::Path>) -> Result { + let path_abs = + std::fs::canonicalize(path).with_context(|| format!("canonicalize {}", path.display()))?; + let rel = if let Some(root) = root { + let root_abs = std::fs::canonicalize(root) + .with_context(|| format!("canonicalize {}", root.display()))?; + path_abs + .strip_prefix(&root_abs) + .unwrap_or(&path_abs) + .to_path_buf() + } else { + path_abs + }; + Ok(rel + .to_string_lossy() + .replace(std::path::MAIN_SEPARATOR, "/")) +} + +fn canonicalize_receipt_body_value(value: &serde_json::Value) -> Result> { + let mut v = value.clone(); + let obj = v.as_object_mut().context("receipt must be a JSON object")?; + + // Remove signature and hash metadata before hashing the canonical body. + for key in [ + "sig_alg", + "signer_pub", + "signature", + "signed_at", + "hash_alg", + "blake3", + "sha256", + ] { + obj.remove(key); + } + + let canonical_value = canonicalize_value(serde_json::Value::Object(obj.clone())); + let out = serde_json::to_vec(&canonical_value).context("serialize canonical receipt json")?; + Ok(out) +} + +fn canonicalize_value(v: serde_json::Value) -> serde_json::Value { + match v { + serde_json::Value::Object(map) => { + let mut entries: Vec<(String, serde_json::Value)> = map.into_iter().collect(); + entries.sort_by(|(a, _), (b, _)| a.cmp(b)); + let ordered = entries + .into_iter() + .map(|(k, v)| (k, canonicalize_value(v))) + .collect(); + serde_json::Value::Object(ordered) + } + serde_json::Value::Array(arr) => { + serde_json::Value::Array(arr.into_iter().map(canonicalize_value).collect()) + } + other => other, + } +} + +fn receipt_declares_v1(v: &serde_json::Value) -> bool { + let version = match v.get("receipt_version") { + Some(serde_json::Value::String(s)) => s == "1", + Some(serde_json::Value::Number(n)) => n.as_u64() == Some(1), + _ => false, + }; + let (_, _, hash_alg) = extract_source_hashes(v); + + version && hash_alg.as_deref() == Some("blake3+sha256") +} + +fn extract_source_hashes( + v: &serde_json::Value, +) -> (Option, Option, Option) { + let blake3 = extract_first_str(v, &["blake3", "hash_blake3", "hash_blake3_hex"]) + .or_else(|| v.get("body").and_then(|body| extract_first_str(body, &[ + "blake3", + "hash_blake3", + "hash_blake3_hex", + ]))); + let sha256 = extract_first_str(v, &["sha256", "hash_sha256", "hash_sha256_hex"]) + .or_else(|| v.get("body").and_then(|body| extract_first_str(body, &[ + "sha256", + "hash_sha256", + "hash_sha256_hex", + ]))); + let hash_alg = extract_first_str(v, &["hash_alg", "hash_algorithm"]) + .or_else(|| v.get("body").and_then(|body| extract_first_str(body, &[ + "hash_alg", + "hash_algorithm", + ]))); + + (blake3, sha256, hash_alg) +} + +fn extract_first_str(v: &serde_json::Value, keys: &[&str]) -> Option { + for key in keys { + if let Some(s) = v.get(key).and_then(|v| v.as_str()) { + return Some(s.to_string()); + } + } + None +} + +fn git_head(root: &std::path::Path) -> Result { + let output = std::process::Command::new("git") + .arg("-C") + .arg(root) + .args(["rev-parse", "HEAD"]) + .output() + .context("run git rev-parse HEAD")?; + if !output.status.success() { + anyhow::bail!("git rev-parse HEAD failed"); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn git_dirty(root: &std::path::Path) -> Result { + let output = std::process::Command::new("git") + .arg("-C") + .arg(root) + .args(["status", "--porcelain"]) + .output() + .context("run git status --porcelain")?; + if !output.status.success() { + anyhow::bail!("git status failed"); + } + Ok(!output.stdout.is_empty()) +} diff --git a/crates/ledger-core/Cargo.toml b/crates/ledger-core/Cargo.toml new file mode 100644 index 0000000..d6ded75 --- /dev/null +++ b/crates/ledger-core/Cargo.toml @@ -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" diff --git a/crates/ledger-core/src/attestation.rs b/crates/ledger-core/src/attestation.rs new file mode 100644 index 0000000..6a3ff04 --- /dev/null +++ b/crates/ledger-core/src/attestation.rs @@ -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 { + 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 { + 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(bytes: &[u8; 32], serializer: S) -> Result + 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(bytes: &[u8; 64], serializer: S) -> Result + 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(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) +} diff --git a/crates/ledger-core/src/entry.rs b/crates/ledger-core/src/entry.rs new file mode 100644 index 0000000..913a12e --- /dev/null +++ b/crates/ledger-core/src/entry.rs @@ -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, + 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, + 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 { + 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(sig: &[u8; 64], serializer: S) -> Result + 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(self, v: &[u8]) -> Result + 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(self, v: Vec) -> Result + where + E: de::Error, + { + self.visit_bytes(&v) + } + } + + deserializer.deserialize_bytes(SigVisitor) + } +} diff --git a/crates/ledger-core/src/identity.rs b/crates/ledger-core/src/identity.rs new file mode 100644 index 0000000..0403cbe --- /dev/null +++ b/crates/ledger-core/src/identity.rs @@ -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 { + 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 { + Ok(self.signing_key()?.verifying_key()) + } +} diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs new file mode 100644 index 0000000..9a8a941 --- /dev/null +++ b/crates/ledger-core/src/lib.rs @@ -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}; diff --git a/crates/ledger-core/src/merkle.rs b/crates/ledger-core/src/merkle.rs new file mode 100644 index 0000000..8df3290 --- /dev/null +++ b/crates/ledger-core/src/merkle.rs @@ -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 = 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), 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 = 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)) +} diff --git a/crates/ledger-core/src/proof.rs b/crates/ledger-core/src/proof.rs new file mode 100644 index 0000000..a774ac7 --- /dev/null +++ b/crates/ledger-core/src/proof.rs @@ -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, +} + +#[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 { + 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, + ) -> 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(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) +} diff --git a/crates/ledger-core/src/receipt.rs b/crates/ledger-core/src/receipt.rs new file mode 100644 index 0000000..a364e61 --- /dev/null +++ b/crates/ledger-core/src/receipt.rs @@ -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, +} + +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(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) +} diff --git a/crates/ledger-core/src/storage.rs b/crates/ledger-core/src/storage.rs new file mode 100644 index 0000000..cd8b784 --- /dev/null +++ b/crates/ledger-core/src/storage.rs @@ -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) -> 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, 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::(); + 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, 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, 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub witness_sig_hex: Option, +} + +pub fn ensure_parent_dir(path: &Path) -> Result<(), LedgerError> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + Ok(()) +} diff --git a/crates/ledger-core/src/verify.rs b/crates/ledger-core/src/verify.rs new file mode 100644 index 0000000..ab4a034 --- /dev/null +++ b/crates/ledger-core/src/verify.rs @@ -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, + pub verified_checkpoints: u64, +} + +pub fn verify_ledger_dir( + dir: &LedgerDir, + verify_checkpoints: bool, +) -> Result { + let entries = dir.read_entries()?; + let mut failures = Vec::new(); + + let mut expected_prev: EntryHash = [0u8; 32]; + let mut hashes: Vec = 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() +} diff --git a/crates/ledger-core/tests/attestation_vectors.rs b/crates/ledger-core/tests/attestation_vectors.rs new file mode 100644 index 0000000..67ab587 --- /dev/null +++ b/crates/ledger-core/tests/attestation_vectors.rs @@ -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()); +} diff --git a/crates/ledger-core/tests/entry_cbor_roundtrip.rs b/crates/ledger-core/tests/entry_cbor_roundtrip.rs new file mode 100644 index 0000000..fbe16a6 --- /dev/null +++ b/crates/ledger-core/tests/entry_cbor_roundtrip.rs @@ -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); +} diff --git a/crates/ledger-core/tests/golden_vectors.rs b/crates/ledger-core/tests/golden_vectors.rs new file mode 100644 index 0000000..c45c8a9 --- /dev/null +++ b/crates/ledger-core/tests/golden_vectors.rs @@ -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" + ); +} diff --git a/crates/ledger-core/tests/read_proof.rs b/crates/ledger-core/tests/read_proof.rs new file mode 100644 index 0000000..87402d7 --- /dev/null +++ b/crates/ledger-core/tests/read_proof.rs @@ -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()); +} diff --git a/crates/ledger-core/tests/receipt.rs b/crates/ledger-core/tests/receipt.rs new file mode 100644 index 0000000..41f6389 --- /dev/null +++ b/crates/ledger-core/tests/receipt.rs @@ -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()); +}