diff --git a/Cargo.lock b/Cargo.lock index 341dc84..49bc418 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -134,6 +134,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.8.0" @@ -155,6 +161,15 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[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 = "block-buffer" version = "0.12.0" @@ -287,18 +302,18 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" -[[package]] -name = "cmov" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" - [[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -311,6 +326,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -320,6 +344,16 @@ 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 = "crypto-common" version = "0.2.1" @@ -330,12 +364,30 @@ dependencies = [ ] [[package]] -name = "ctutils" -version = "0.4.2" +name = "curve25519-dalek" +version = "4.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ - "cmov", + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "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]] @@ -378,22 +430,41 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + [[package]] name = "difflib" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + [[package]] name = "digest" version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", - "ctutils", + "block-buffer 0.12.0", + "const-oid 0.10.2", + "crypto-common 0.2.1", ] [[package]] @@ -413,6 +484,31 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[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 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "email_address" version = "0.2.9" @@ -443,13 +539,14 @@ name = "evidence-core" version = "0.1.4" dependencies = [ "chrono", + "ed25519-dalek", "hex", - "hmac", "jsonschema", + "rand_core", "regex", "serde", "serde_json", - "sha2", + "sha2 0.11.0", "tempfile", "thiserror", "toml", @@ -494,6 +591,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[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.9" @@ -630,6 +733,27 @@ dependencies = [ "slab", ] +[[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.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -695,15 +819,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hmac" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" -dependencies = [ - "digest", -] - [[package]] name = "hybrid-array" version = "0.4.10" @@ -1134,6 +1249,16 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1213,6 +1338,15 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1323,6 +1457,15 @@ dependencies = [ "syn", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1452,6 +1595,17 @@ dependencies = [ "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 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.11.0" @@ -1459,8 +1613,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.0", + "digest 0.11.2", ] [[package]] @@ -1488,6 +1642,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + [[package]] name = "slab" version = "0.4.12" @@ -1500,6 +1663,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1512,6 +1685,12 @@ 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.117" @@ -2154,6 +2333,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/Cargo.toml b/Cargo.toml index d7a732a..24c1b5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,8 +23,9 @@ clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.11" -hmac = "0.13" hex = "0.4" +ed25519-dalek = { version = "2", default-features = false, features = ["std", "rand_core", "zeroize"] } +rand_core = { version = "0.6", features = ["std"] } jsonschema = { version = "0.46", default-features = false } tracing = "0.1" tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "env-filter"] } diff --git a/README.md b/README.md index 61a0143..b0684b9 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,56 @@ overrides. A committed historical-anchor baseline — pinning `{git_sha → deterministic_hash}` for a curated set of milestone commits — would close that gap and is tracked as a follow-up. +### Bundle integrity layers + +A bundle's integrity is verifiable along three layers, each requiring +strictly less trusted material than the next: + +| Layer | What it covers | Key required to verify | Tool needed | +|-------|----------------|------------------------|-------------| +| **1. Content** | Every hashed file in `SHA256SUMS` (env.json, deterministic-manifest.json, inputs/outputs hashes, commands.json, trace outputs, captured stdout/stderr, compliance reports) | None | `sha256sum -c SHA256SUMS` | +| **2. Metadata** | `index.json` fields excluded from `SHA256SUMS` (`git_sha`, `dal_map`, `test_summary`, `trace_outputs` paths, schema versions, timestamps) | The supplier's **public** ed25519 verifying key (32 bytes, hex) | `cargo evidence verify --verify-key ` | +| **3. Provenance** | Non-repudiation: only the holder of the corresponding **private** signing key could have produced the matching `BUNDLE.sig` | Same public key as Layer 2; the chain of trust is "did this public key sign this?" | Same `verify` invocation | + +**Why two cryptographic objects (`SHA256SUMS` and `BUNDLE.sig`) instead +of one?** `index.json` is deliberately excluded from `SHA256SUMS` +because `index.json` records `content_hash`, which is `SHA-256 +(SHA256SUMS)` — a self-referential cycle. The detached `BUNDLE.sig` +covers a length-prefixed `(SHA256SUMS, index.json)` envelope, closing +the metadata-layer tampering gap. + +**What `BUNDLE.sig` is not:** + +- *Not an HMAC.* The verifier needs only the public key, not a shared + secret. (Pre-0.1.5 versions used HMAC-SHA256 and called it a + "signature" — that was a misnomer; it's now a real signature.) +- *Not an X.509 / PKI signature.* The 32-byte public key is raw — + distribute it however you like (commit to repo, attach to release, + publish to a transparency log). +- *Not a SLSA L3 attestation.* SLSA build-provenance integration is a + separate roadmap item; the current signature covers integrity and + non-repudiation only. + +**Generating and verifying:** + +```bash +# One-time: generate an ed25519 keypair (any tool that produces 32-byte +# raw private + 32-byte raw public works; openssl shown here). +# A native `cargo evidence keygen` subcommand is planned. +openssl genpkey -algorithm ed25519 -outform raw -out signing.key.raw +xxd -p -c 64 signing.key.raw > cert/signing.key +openssl pkey -in signing.key.raw -inform raw -pubout -outform raw -out signing.pub.raw +xxd -p -c 64 signing.pub.raw > cert/signing.pub +chmod 600 cert/signing.key # private — never commit +# Commit cert/signing.pub; gitignore cert/signing.key. + +# Sign during generate +cargo evidence generate --signing-key cert/signing.key --out-dir /tmp/evidence + +# Verify with public key +cargo evidence verify /tmp/evidence/ --verify-key cert/signing.pub +``` + ### Captured Output Normalization Every file written by `cargo evidence generate` under the capture directory @@ -632,7 +682,11 @@ Previously tracked items now resolved: (captures panic/assertion text from `---- stdout ----` failure blocks). A-7 Obj-3/Obj-4 upgrade from Partial → Met when present + aggregate `tests_passed == true`. -- ~~No cryptographic signing~~ → HMAC-SHA256 via `sign_bundle()` + `BUNDLE.sig` +- ~~No cryptographic signing~~ → ed25519 detached signature over + the `(SHA256SUMS, index.json)` envelope, hex-encoded as `BUNDLE.sig`. + The verifying party needs only the supplier's 32-byte public key — + no shared secret. See "Bundle integrity layers" below for the + three-layer model (SHA-256 content / ed25519 metadata / non-repudiation). - ~~No extra-file detection~~ → `verify.rs` walks bundle and flags unexpected files - ~~Incomplete SCI/SECI~~ → `Cargo.lock` hash, `RUSTFLAGS`, `rust-toolchain.toml` captured - ~~`engine_git_sha` conflation / `"unknown"` fallback~~ → `build.rs` captures the engine's own diff --git a/cert/QUALIFICATION.md b/cert/QUALIFICATION.md index ace9a6e..8c06405 100644 --- a/cert/QUALIFICATION.md +++ b/cert/QUALIFICATION.md @@ -11,6 +11,50 @@ check looks for this file. Severity is Warning at DAL-D (advisory) and Error at DAL ≥ C. Cert / record profile generation refuses to proceed at DAL ≥ C without this file present. +## Integrity layers (SYS-001) + +A bundle's integrity is verifiable along three layers, each requiring +strictly less trusted material than the next. DO-178C §7 (Software +Configuration Management) requires "protection against unauthorized +changes" but does not mandate any specific cryptographic primitive; +this tool's choice is documented here so an auditor can map the +artifact set onto the SCM expectation. + +1. **Content layer.** `SHA256SUMS` lists every content-bearing file + with its SHA-256 digest. Any party with `sha256sum` confirms every + file. No key required. This is the layer most directly aligned + with traditional CC1 baseline integrity (DO-178C §7.2.5). +2. **Metadata layer.** `BUNDLE.sig` is a 64-byte ed25519 detached + signature, hex-encoded, over the length-prefixed + `(SHA256SUMS, index.json)` envelope. `index.json` is excluded from + `SHA256SUMS` because `index.json` records the SHA-256 of + `SHA256SUMS` (a self-referential cycle); the signature covers it + instead. The verifier needs the supplier's 32-byte public ed25519 + verifying key. +3. **Provenance layer.** A successful Layer-2 verification implies + non-repudiation: only the holder of the corresponding private + signing key could have produced a signature that the public key + accepts. This satisfies cross-organizational provenance requirements + that an HMAC-based integrity check cannot — HMAC is symmetric and + the verifier would need the same secret as the signer (FIPS 198-1). + +**Pre-0.1.5 history:** earlier releases used HMAC-SHA256 over the same +envelope and called it a "signature." That was a terminology error; +HMAC is a Message Authentication Code, not a signature. The mechanism +was correct for in-organization integrity but inadequate for cross- +organizational provenance. The 0.1.5 transition replaces the primitive +with ed25519 and keeps the same envelope shape and the same +`BUNDLE.sig` filename (now legitimate). + +**Out of scope of this layer:** + +- SLSA L2/L3 build-provenance attestations (in-toto / DSSE / sigstore) + — a planned follow-up; the current signature is metadata-layer only. +- X.509 / PKI / institutional cert chains — the public key is raw and + distributed by the supplier however they choose. +- Time-of-signing attestation — the signature is computed at bundle + finalize, on the same host, and bound to that bundle only. + ## A-7 Obj-7 (MC/DC) — capability gap The most visible automation gap is DO-178C Annex A Table A-7 Objective 7 diff --git a/cert/floors.toml b/cert/floors.toml index 538d658..4492a44 100644 --- a/cert/floors.toml +++ b/cert/floors.toml @@ -78,7 +78,7 @@ known_surfaces = 22 [per_crate.evidence-core] # `#[test]` attribute count inside crates/evidence-core/**/*.rs. -test_count = 360 +test_count = 367 [per_crate.cargo-evidence] test_count = 142 diff --git a/cert/trace/hlr.toml b/cert/trace/hlr.toml index 66b1f49..cae3caa 100644 --- a/cert/trace/hlr.toml +++ b/cert/trace/hlr.toml @@ -288,12 +288,13 @@ surfaces = ["verify"] [[requirements]] uid = "904ad11d-7b76-4707-8a08-cab8224b64c9" id = "HLR-015" -title = "Strict-mode verify requires BUNDLE.sig" +title = "Strict-mode verify requires an ed25519 signature" owner = "tool" scope = "component" description = """ -Under --strict verify, a bundle without BUNDLE.sig and no --verify-key -is a verification failure. The JSONL path emits +Under --strict verify, a bundle without `BUNDLE.sig` (the detached +ed25519 signature over the `(SHA256SUMS, index.json)` envelope) and +no `--verify-key` is a verification failure. The JSONL path emits VERIFY_STRICT_SIGNATURE_MISSING + VERIFY_FAIL terminal, exit 2. """ verification_methods = ["test"] diff --git a/cert/trace/llr.toml b/cert/trace/llr.toml index bf119c4..3e60d8a 100644 --- a/cert/trace/llr.toml +++ b/cert/trace/llr.toml @@ -317,7 +317,7 @@ emits = ["VERIFY_TRACE_OUTPUT_NOT_HASHED"] [[requirements]] uid = "bb176fd9-7859-4ec1-bbc0-088d2518dc54" id = "LLR-015" -title = "cmd_verify_jsonl strict-mode signature guard" +title = "cmd_verify_jsonl strict-mode ed25519 signature guard" owner = "tool" traces_to = ["904ad11d-7b76-4707-8a08-cab8224b64c9"] modules = ["cargo_evidence::cli::verify::cmd_verify_jsonl"] @@ -325,11 +325,18 @@ description = """ When strict is true and neither BUNDLE.sig nor --verify-key is present, cmd_verify_jsonl emits VERIFY_STRICT_SIGNATURE_MISSING as a finding followed by VERIFY_FAIL terminal, returning -EXIT_VERIFICATION_FAILURE (2). +EXIT_VERIFICATION_FAILURE (2). When --verify-key is supplied but the +key file is unreadable (I/O fault) or has invalid content (bad hex, +wrong length, invalid ed25519 point) the run terminates with +VERIFY_ERROR (exit 1) carrying the structured cause: +VERIFY_RUNTIME_READ_VERIFY_KEY for I/O, SIGN_INVALID_KEY / +SIGN_INVALID_SIGNATURE_HEX for parse. Successful key load with a +present BUNDLE.sig that fails ed25519 verification surfaces +VERIFY_SIGNATURE_INVALID + VERIFY_FAIL. """ verification_methods = ["test"] emits = [ - "VERIFY_HMAC_FAILURE", + "VERIFY_SIGNATURE_INVALID", "SIGN_INVALID_KEY", "SIGN_INVALID_SIGNATURE_HEX", "SIGN_READ_FAILED", diff --git a/cert/trace/sys.toml b/cert/trace/sys.toml index b3d62b9..ee42d8d 100644 --- a/cert/trace/sys.toml +++ b/cert/trace/sys.toml @@ -12,10 +12,24 @@ title = "Verifiable evidence bundle" owner = "soi" scope = "soi" description = """ -The tool shall produce an evidence bundle whose integrity can be -independently verified without re-invoking the tool: a sibling party -with SHA-256 + HMAC implementations and the public signing key must be -able to confirm every hashed file and the bundle envelope. +The tool shall produce an evidence bundle whose integrity is +independently verifiable without re-invoking the tool, in three +layers each requiring strictly less trusted material than the next: + +1. Content layer — any party with a SHA-256 implementation confirms + every hashed file via `sha256sum -c SHA256SUMS`. No key required. +2. Metadata layer — any party additionally holding the supplier's + ed25519 public verifying key (32 bytes) confirms `BUNDLE.sig` + (a 64-byte detached signature, hex-encoded) against the + length-prefixed `(SHA256SUMS, index.json)` envelope. This catches + tampering of metadata-layer fields excluded from `SHA256SUMS` + (`git_sha`, `dal_map`, `test_summary`, `trace_outputs` paths, + schema versions, timestamps, etc.). +3. Provenance layer — successful metadata-layer verification implies + non-repudiation: only the holder of the corresponding 32-byte + private signing key could have produced the matching signature. + The supplier publishes the public key alongside the bundle + release, in the source repository, or in a transparency log. """ verification_methods = [ "test", diff --git a/crates/cargo-evidence/src/cli/generate/phases.rs b/crates/cargo-evidence/src/cli/generate/phases.rs index abe1235..4df173b 100644 --- a/crates/cargo-evidence/src/cli/generate/phases.rs +++ b/crates/cargo-evidence/src/cli/generate/phases.rs @@ -386,11 +386,12 @@ pub(super) fn write_compliance_reports( Ok(()) } -// Phase 9 — finalize bundle + optional HMAC signing +// Phase 9 — finalize bundle + optional ed25519 signing /// Finalize the bundle (writes `SHA256SUMS`, `index.json`, closes the -/// builder) and, if `sign_key` is set, sign the envelope and drop -/// `BUNDLE.sig` next to it. Returns the bundle directory path. +/// builder) and, if `sign_key` is set, sign the envelope with the +/// supplier's ed25519 signing (private) key and drop `BUNDLE.sig` next +/// to it. Returns the bundle directory path. pub(super) fn finalize_and_sign( builder: EvidenceBuilder, trace_outputs: Vec, @@ -400,11 +401,11 @@ pub(super) fn finalize_and_sign( ) -> Result { let bundle_path = builder.finalize(trace_outputs)?; if let Some(key_path) = sign_key { - let key_bytes = fs::read(&key_path) + let signing_key = evidence_core::read_signing_key(&key_path) .with_context(|| format!("reading signing key from {:?}", key_path))?; - sign_bundle(&bundle_path, &key_bytes)?; + sign_bundle(&bundle_path, &signing_key)?; if !quiet && !json_output { - println!("evidence: HMAC signature written to BUNDLE.sig"); + println!("evidence: ed25519 signature written to BUNDLE.sig"); } } Ok(bundle_path) diff --git a/crates/cargo-evidence/src/cli/verify.rs b/crates/cargo-evidence/src/cli/verify.rs index d2eece3..bfa90a6 100644 --- a/crates/cargo-evidence/src/cli/verify.rs +++ b/crates/cargo-evidence/src/cli/verify.rs @@ -1,14 +1,14 @@ //! `cargo evidence verify`. -use std::fs; use std::path::PathBuf; use anyhow::Result; use serde::Serialize; +use evidence_core::SigningError; use evidence_core::diagnostic::{Diagnostic, DiagnosticCode, Severity}; use evidence_core::verify::VerifyRuntimeError; -use evidence_core::{VerifyResult, verify_bundle_with_key}; +use evidence_core::{VerifyResult, read_verifying_key, verify_bundle_with_key}; use super::args::{EXIT_ERROR, EXIT_SUCCESS, EXIT_VERIFICATION_FAILURE, OutputFormat}; use super::output::{emit_json, emit_jsonl}; @@ -146,20 +146,11 @@ pub fn cmd_verify( ); } - // Load verify key if provided. An I/O failure here is a runtime - // fault (key file missing / unreadable) — mirror the - // bundle-not-found shape above so all three formats stay - // HLR-016-consistent: human prints `error: ...`, json wraps the - // failure in `VerifyOutput { success: false, ... }`, both exit 1. - let key_bytes = match &verify_key { - Some(path) => match fs::read(path) { - Ok(bytes) => Some(bytes), - Err(source) => { - let err = VerifyRuntimeError::ReadVerifyKey { - path: path.clone(), - source, - }; - let msg = err.to_string(); + let verify_key_obj = match &verify_key { + Some(path) => match read_verifying_key(path) { + Ok(key) => Some(key), + Err(err) => { + let msg = classify_key_load_diagnostic(path, err).message; return fail_verify( json_output, &bundle_path, @@ -178,7 +169,7 @@ pub fn cmd_verify( }; // Run verification - match verify_bundle_with_key(&bundle_path, key_bytes.as_deref()) { + match verify_bundle_with_key(&bundle_path, verify_key_obj.as_ref()) { Ok(VerifyResult::Pass) => { checks.push(VerifyCheck { name: "bundle_integrity".to_string(), @@ -258,7 +249,7 @@ pub fn cmd_verify( #[rustfmt::skip] let name = match err { VE::UnexpectedFile(_) => "unexpected_file", - VE::HmacFailure => "hmac_signature", + VE::SignatureInvalid => "signature_invalid", VE::HashMismatch { .. } => "hash_mismatch", VE::MissingHashedFile(_) => "missing_file", VE::ContentHashMismatch { .. } => "content_hash", @@ -382,23 +373,13 @@ fn cmd_verify_jsonl( return Ok(EXIT_VERIFICATION_FAILURE); } - // Load verify key. An I/O failure (missing / unreadable key - // file) is a runtime fault but must still emit the JSONL - // terminal pair — Schema Rule 1 mandates exactly one terminal - // per --format=jsonl run, and the user-visible verify pipeline - // already started. Mirror the BundleNotFound shape above: - // emit the structured `VERIFY_RUNTIME_READ_VERIFY_KEY` finding, - // then the `VERIFY_ERROR` terminal, then exit 1. - let key_bytes = match &verify_key { - Some(path) => match fs::read(path) { - Ok(bytes) => Some(bytes), - Err(source) => { - let err = VerifyRuntimeError::ReadVerifyKey { - path: path.clone(), - source, - }; - let msg = err.to_string(); - emit_jsonl(&err.to_diagnostic())?; + let verify_key_obj = match &verify_key { + Some(path) => match read_verifying_key(path) { + Ok(key) => Some(key), + Err(err) => { + let diag = classify_key_load_diagnostic(path, err); + let msg = diag.message.clone(); + emit_jsonl(&diag)?; emit_jsonl(&terminal_error(&msg))?; return Ok(EXIT_ERROR); } @@ -406,7 +387,7 @@ fn cmd_verify_jsonl( None => None, }; - match verify_bundle_with_key(&bundle_path, key_bytes.as_deref()) { + match verify_bundle_with_key(&bundle_path, verify_key_obj.as_ref()) { Ok(VerifyResult::Pass) => { // A dev-profile bundle can legitimately land in // Pass with `bundle_complete: false` + recorded @@ -488,3 +469,20 @@ fn cmd_verify_jsonl( } } } + +/// Classify a verify-key load failure: I/O failures route through +/// `VerifyRuntimeError::ReadVerifyKey` (preserving the documented +/// `VERIFY_RUNTIME_READ_VERIFY_KEY` surface); parse failures keep the +/// underlying `SigningError` (`SIGN_INVALID_KEY` etc.). Returns the +/// structured `Diagnostic` for the JSONL stream; callers needing the +/// human-readable text read it off `diagnostic.message`. +fn classify_key_load_diagnostic(path: &std::path::Path, err: SigningError) -> Diagnostic { + match err { + SigningError::Read { source, .. } => VerifyRuntimeError::ReadVerifyKey { + path: path.to_path_buf(), + source, + } + .to_diagnostic(), + other => other.to_diagnostic(), + } +} diff --git a/crates/cargo-evidence/tests/fixtures/golden_rules.json b/crates/cargo-evidence/tests/fixtures/golden_rules.json index d81f36a..98cfaeb 100644 --- a/crates/cargo-evidence/tests/fixtures/golden_rules.json +++ b/crates/cargo-evidence/tests/fixtures/golden_rules.json @@ -902,13 +902,6 @@ "has_fix_hint": false, "terminal": false }, - { - "code": "VERIFY_HMAC_FAILURE", - "severity": "error", - "domain": "verify", - "has_fix_hint": false, - "terminal": false - }, { "code": "VERIFY_INVALID_FORMAT", "severity": "error", @@ -1007,6 +1000,13 @@ "has_fix_hint": false, "terminal": false }, + { + "code": "VERIFY_SIGNATURE_INVALID", + "severity": "error", + "domain": "verify", + "has_fix_hint": false, + "terminal": false + }, { "code": "VERIFY_TEST_SUMMARY_ABSENT_ON_FAILED_RUN", "severity": "error", diff --git a/crates/evidence-core/Cargo.toml b/crates/evidence-core/Cargo.toml index 72f7c48..2a0100c 100644 --- a/crates/evidence-core/Cargo.toml +++ b/crates/evidence-core/Cargo.toml @@ -17,8 +17,9 @@ thiserror.workspace = true serde.workspace = true serde_json.workspace = true sha2.workspace = true -hmac.workspace = true hex.workspace = true +ed25519-dalek.workspace = true +rand_core.workspace = true jsonschema.workspace = true tracing.workspace = true toml.workspace = true diff --git a/crates/evidence-core/src/bundle.rs b/crates/evidence-core/src/bundle.rs index 685a1c0..da8972c 100644 --- a/crates/evidence-core/src/bundle.rs +++ b/crates/evidence-core/src/bundle.rs @@ -8,7 +8,7 @@ //! | `test_summary` | `TestSummary` + `parse_cargo_test_output` | //! | `outcome_record` | `TestOutcomeRecord` — rows in `tests/test_outcomes.jsonl` | //! | `capture` | `normalize_captured_text` — LF-normalize stdout/stderr | -//! | `signing` | `sign_bundle` / `verify_bundle_signature` (HMAC envelope) | +//! | `signing` | `sign_bundle` / `verify_bundle_signature` (ed25519 over the SHA256SUMS+index.json envelope) | //! | `index` | `EvidenceIndex` — struct mirror of `index.json` | //! | `builder` | `EvidenceBuildConfig`, `EvidenceBuilder` (assembly state) | //! | `time` | `utc_now_rfc3339` + `utc_compact_stamp` | @@ -36,7 +36,10 @@ pub use command_failure::{STDERR_TAIL_LINES, ToolCommandFailure, tail_stderr}; pub use error::BuilderError; pub use index::EvidenceIndex; pub use outcome_record::{TestOutcomeRecord, TestsError}; -pub use signing::{SigningError, sign_bundle, verify_bundle_signature}; +pub use signing::{ + SigningError, generate_signing_key, read_signing_key, read_verifying_key, sign_bundle, + verify_bundle_signature, write_signing_key, write_verifying_key, +}; pub use test_summary::{ TestOutcome, TestSummary, parse_cargo_test_output, parse_cargo_test_output_detailed, parse_cargo_test_output_with_outcomes, diff --git a/crates/evidence-core/src/bundle/signing.rs b/crates/evidence-core/src/bundle/signing.rs index 0307225..0050265 100644 --- a/crates/evidence-core/src/bundle/signing.rs +++ b/crates/evidence-core/src/bundle/signing.rs @@ -1,7 +1,31 @@ -//! HMAC-SHA256 signing of a bundle's `SHA256SUMS` + `index.json` envelope. +//! Ed25519 detached signature over a bundle's `SHA256SUMS` + `index.json` +//! envelope. +//! +//! The verifying party needs only the public verifying key (32 bytes); the +//! signing party holds the private signing key (32 bytes of seed material). +//! Together they cover the metadata-layer integrity gap left by `SHA256SUMS` +//! — which deliberately excludes `index.json` to break the self-referential +//! `content_hash` cycle. Editing any field in `index.json` (`engine_git_sha`, +//! `dal_map`, `test_summary`, `trace_outputs` paths, schema versions, +//! timestamps...) without re-signing rotates the verification result to false. +//! +//! This is the second of three integrity layers documented under SYS-001: +//! +//! 1. **Content layer.** Anyone with a SHA-256 implementation confirms +//! `SHA256SUMS` against the bundle. No key required. +//! 2. **Metadata layer.** *(this module)* Anyone holding the supplier's +//! 32-byte public verifying key confirms `BUNDLE.sig` against the +//! length-prefixed `(SHA256SUMS, index.json)` envelope. The public key +//! is a 64-character hex string, distributable in the source repo, in +//! a release asset, or in a transparency log. +//! 3. **Provenance layer.** Verification implies non-repudiation: only the +//! holder of the corresponding 32-byte private signing key could have +//! produced the matching signature. +//! +//! See `cert/QUALIFICATION.md` "Integrity layers" for the auditor framing. -use hmac::{Hmac, KeyInit, Mac}; -use sha2::Sha256; +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use rand_core::OsRng; use std::fs; use std::path::{Path, PathBuf}; @@ -9,33 +33,36 @@ use thiserror::Error; use crate::diagnostic::{DiagnosticCode, Location, Severity}; -type HmacSha256 = Hmac; - -/// Errors returned by [`sign_bundle`] / [`verify_bundle_signature`]. +/// Errors returned by the signing API: +/// [`sign_bundle`] / [`verify_bundle_signature`] / the on-disk key helpers. #[derive(Debug, Error)] pub enum SigningError { - /// Failed to read one of the envelope inputs or the signature file. + /// Failed to read one of the envelope inputs, the signature file, or a + /// key file. #[error("reading {path}")] Read { - /// Bundle-relative filename (`SHA256SUMS`, `index.json`, `BUNDLE.sig`). + /// Filename being read (bundle-relative for envelope inputs and the + /// signature; absolute for key paths supplied by the caller). path: String, /// Underlying OS error. #[source] source: std::io::Error, }, - /// Failed to write `BUNDLE.sig`. + /// Failed to write `BUNDLE.sig` or a key file. #[error("writing {path}")] Write { - /// Bundle-relative filename being written (always `BUNDLE.sig`). + /// Filename being written. path: String, /// Underlying OS error. #[source] source: std::io::Error, }, - /// The provided HMAC key had an invalid length for SHA-256. - #[error("invalid HMAC key: {reason}")] + /// Key material had the wrong byte length, contained invalid hex, or + /// failed ed25519 point-decoding. + #[error("invalid key material: {reason}")] InvalidKey { - /// Human-readable reason from the `hmac` crate. + /// Human-readable reason (length mismatch, bad hex, point decoding + /// failure). reason: String, }, /// `BUNDLE.sig` contained non-hex bytes. @@ -71,49 +98,111 @@ impl DiagnosticCode for SigningError { } } -/// HMAC envelope layout: length-prefixed concatenation of -/// `SHA256SUMS` and `index.json` as they live on disk, no -/// canonicalization at verify time. The length prefixes frame the -/// two inputs unambiguously so no pair of (A, B) byte strings can -/// collide onto the same MAC input as another (A', B'). +/// Generate a fresh ed25519 signing keypair using OS randomness. /// -/// ```text -/// u64_be(|SHA256SUMS|) || SHA256SUMS || u64_be(|index.json|) || index.json -/// ``` +/// The returned [`SigningKey`] both signs and exposes its companion +/// [`VerifyingKey`] via `signing_key.verifying_key()`. +pub fn generate_signing_key() -> SigningKey { + SigningKey::generate(&mut OsRng) +} + +/// Read an ed25519 signing (private) key from a 64-character hex file. +/// +/// Whitespace at the start and end is trimmed before decoding so the file +/// can carry a trailing newline. Other whitespace (interior spaces, line +/// breaks) is rejected — this catches accidental concatenation of two +/// keys or pasted PEM bundles. +pub fn read_signing_key(path: &Path) -> Result { + let bytes = read_key_bytes::<32>(path)?; + Ok(SigningKey::from_bytes(&bytes)) +} + +/// Read an ed25519 verifying (public) key from a 64-character hex file. +pub fn read_verifying_key(path: &Path) -> Result { + let bytes = read_key_bytes::<32>(path)?; + VerifyingKey::from_bytes(&bytes).map_err(|e| SigningError::InvalidKey { + reason: format!("ed25519 public-key decoding failed: {e}"), + }) +} + +/// Write a signing (private) key as 64-character ASCII hex with a trailing +/// newline. /// -/// `SHA256SUMS` already covers the content layer (env.json, -/// inputs/outputs hashes, deterministic-manifest.json, trace outputs, -/// captured stdout/stderr). Extending the envelope to include -/// `index.json`'s on-disk bytes closes the metadata-layer tampering -/// gap: editing any index field (engine_git_sha, dal_map, -/// test_summary, trace_outputs, schema versions, timestamp…) without -/// the HMAC key rotates the MAC. +/// The caller is responsible for filesystem permissions on the resulting +/// file (private keys should not be world-readable). The CLI's `keygen` +/// subcommand attempts a best-effort `chmod 600` on Unix. +pub fn write_signing_key(path: &Path, key: &SigningKey) -> Result<(), SigningError> { + write_hex(path, key.to_bytes().as_ref()) +} + +/// Write a verifying (public) key as 64-character ASCII hex with a trailing +/// newline. +pub fn write_verifying_key(path: &Path, key: &VerifyingKey) -> Result<(), SigningError> { + write_hex(path, key.to_bytes().as_ref()) +} + +fn read_key_bytes(path: &Path) -> Result<[u8; N], SigningError> { + let raw = fs::read_to_string(path).map_err(|source| SigningError::Read { + path: path.display().to_string(), + source, + })?; + let trimmed = raw.trim(); + let decoded = hex::decode(trimmed).map_err(|e| SigningError::InvalidKey { + reason: format!("expected {} bytes of ASCII hex, hex decoder said: {e}", N), + })?; + decoded + .as_slice() + .try_into() + .map_err(|_| SigningError::InvalidKey { + reason: format!( + "expected {} bytes of ed25519 key material, got {}", + N, + decoded.len() + ), + }) +} + +fn write_hex(path: &Path, bytes: &[u8]) -> Result<(), SigningError> { + let mut hex_str = hex::encode(bytes); + hex_str.push('\n'); + fs::write(path, hex_str).map_err(|source| SigningError::Write { + path: path.display().to_string(), + source, + }) +} + +/// Length-prefixed two-input envelope: `u64_be(|sha256sums|) || sha256sums +/// || u64_be(|index_json|) || index_json`. The framing prevents `(A, B)` +/// from colliding with any `(A', B')` whose concatenation happens to share +/// the same byte stream. /// -/// We feed disk bytes verbatim rather than re-serializing the struct -/// because serde_json's output shape is stable-in-practice (struct -/// declaration order, `BTreeMap` for maps) but not a documented -/// guarantee; signing the bytes we actually wrote removes any -/// canonicalization tail-risk. -fn hmac_envelope_into(mac: &mut HmacSha256, sha256sums: &[u8], index_json: &[u8]) { - mac.update(&(sha256sums.len() as u64).to_be_bytes()); - mac.update(sha256sums); - mac.update(&(index_json.len() as u64).to_be_bytes()); - mac.update(index_json); +/// Disk bytes are signed verbatim rather than re-serialized — `serde_json`'s +/// output shape is stable in practice (struct field order, `BTreeMap` for +/// maps) but not a documented guarantee, and signing the bytes we actually +/// wrote eliminates any canonicalization tail-risk. +fn envelope_bytes(sha256sums: &[u8], index_json: &[u8]) -> Vec { + let mut buf = Vec::with_capacity(16 + sha256sums.len() + index_json.len()); + buf.extend_from_slice(&(sha256sums.len() as u64).to_be_bytes()); + buf.extend_from_slice(sha256sums); + buf.extend_from_slice(&(index_json.len() as u64).to_be_bytes()); + buf.extend_from_slice(index_json); + buf } -/// Sign `SHA256SUMS` + `index.json` with HMAC-SHA256 and write `BUNDLE.sig`. +/// Sign the bundle's `SHA256SUMS` + `index.json` envelope with the supplied +/// ed25519 signing key. Writes the 64-byte signature as a 128-character hex +/// string with trailing newline to `BUNDLE.sig` and returns its path. /// -/// Must be called after `EvidenceBuilder::finalize()` — both files -/// must be present on disk with their final contents. -pub fn sign_bundle(bundle_dir: &Path, key: &[u8]) -> Result { +/// Must be called after `EvidenceBuilder::finalize()` — both envelope +/// inputs must be present on disk in their final form. +pub fn sign_bundle(bundle_dir: &Path, key: &SigningKey) -> Result { let sha256sums = read_envelope_input(bundle_dir, "SHA256SUMS")?; let index_json = read_envelope_input(bundle_dir, "index.json")?; + let envelope = envelope_bytes(&sha256sums, &index_json); + let signature: Signature = key.sign(&envelope); - let mut mac = HmacSha256::new_from_slice(key).map_err(|e| SigningError::InvalidKey { - reason: e.to_string(), - })?; - hmac_envelope_into(&mut mac, &sha256sums, &index_json); - let sig_hex = hex::encode(mac.finalize().into_bytes()); + let mut sig_hex = hex::encode(signature.to_bytes()); + sig_hex.push('\n'); let sig_path = bundle_dir.join("BUNDLE.sig"); fs::write(&sig_path, &sig_hex).map_err(|source| SigningError::Write { @@ -123,27 +212,39 @@ pub fn sign_bundle(bundle_dir: &Path, key: &[u8]) -> Result Result { +/// Returns `Ok(true)` for a valid signature, `Ok(false)` for a syntactically +/// well-formed but cryptographically invalid one, or an error if any of the +/// inputs cannot be read or decoded. +pub fn verify_bundle_signature( + bundle_dir: &Path, + key: &VerifyingKey, +) -> Result { let sha256sums = read_envelope_input(bundle_dir, "SHA256SUMS")?; let index_json = read_envelope_input(bundle_dir, "index.json")?; - let sig_hex = + let sig_text = fs::read_to_string(bundle_dir.join("BUNDLE.sig")).map_err(|source| SigningError::Read { path: "BUNDLE.sig".to_string(), source, })?; - let expected = hex::decode(sig_hex.trim()).map_err(SigningError::InvalidSignatureHex)?; - - let mut mac = HmacSha256::new_from_slice(key).map_err(|e| SigningError::InvalidKey { - reason: e.to_string(), - })?; - hmac_envelope_into(&mut mac, &sha256sums, &index_json); + let sig_bytes_vec = hex::decode(sig_text.trim()).map_err(SigningError::InvalidSignatureHex)?; + let sig_bytes: [u8; 64] = + sig_bytes_vec + .as_slice() + .try_into() + .map_err(|_| SigningError::InvalidKey { + reason: format!( + "ed25519 signature must be 64 bytes (got {})", + sig_bytes_vec.len() + ), + })?; + let signature = Signature::from_bytes(&sig_bytes); - Ok(mac.verify_slice(&expected).is_ok()) + let envelope = envelope_bytes(&sha256sums, &index_json); + Ok(key.verify(&envelope, &signature).is_ok()) } fn read_envelope_input(bundle_dir: &Path, filename: &str) -> Result, SigningError> { @@ -163,28 +264,47 @@ fn read_envelope_input(bundle_dir: &Path, filename: &str) -> Result, Sig mod tests { use super::*; + fn deterministic_signing_key(seed_byte: u8) -> SigningKey { + SigningKey::from_bytes(&[seed_byte; 32]) + } + #[test] - fn test_hmac_sign_and_verify() { + fn sign_and_verify_round_trip() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("SHA256SUMS"), "abc123 file.txt\n").unwrap(); fs::write(dir.path().join("index.json"), b"{\"content_hash\":\"x\"}\n").unwrap(); - let key = b"test-secret-key-bytes"; - let sig_path = sign_bundle(dir.path(), key).unwrap(); + let signing = deterministic_signing_key(7); + let verifying = signing.verifying_key(); + + let sig_path = sign_bundle(dir.path(), &signing).unwrap(); assert!(sig_path.exists()); - // Verify with correct key - assert!(verify_bundle_signature(dir.path(), key).unwrap()); + assert!(verify_bundle_signature(dir.path(), &verifying).unwrap()); + } + + #[test] + fn verify_rejects_signature_made_with_different_key() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("SHA256SUMS"), "abc123 file.txt\n").unwrap(); + fs::write(dir.path().join("index.json"), b"{}\n").unwrap(); + + let supplier = deterministic_signing_key(7); + sign_bundle(dir.path(), &supplier).unwrap(); - // Verify with wrong key - assert!(!verify_bundle_signature(dir.path(), b"wrong-key").unwrap()); + let attacker_pubkey = deterministic_signing_key(8).verifying_key(); + assert!( + !verify_bundle_signature(dir.path(), &attacker_pubkey).unwrap(), + "verifying with the wrong public key must fail" + ); } #[test] - fn test_hmac_detects_tamper_on_index_json() { - // A holder without the key cannot edit index.json without - // rotating BUNDLE.sig — that's the whole point of folding - // index.json into the HMAC envelope. + fn verify_rejects_index_json_tamper() { + // index.json is excluded from SHA256SUMS by design (the + // self-referential content_hash cycle). The signature envelope + // closes that gap: editing any index field rotates the + // verification result. let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("SHA256SUMS"), "abc123 file.txt\n").unwrap(); fs::write( @@ -193,37 +313,117 @@ mod tests { ) .unwrap(); - let key = b"test-secret-key-bytes"; - sign_bundle(dir.path(), key).unwrap(); + let signing = deterministic_signing_key(7); + sign_bundle(dir.path(), &signing).unwrap(); - // Tamper index.json: flip engine_git_sha. SHA256SUMS still - // hashes the content layer fine (index.json isn't in it), - // but the envelope's second input changed. fs::write( dir.path().join("index.json"), b"{\"engine_git_sha\":\"bbb\"}\n", ) .unwrap(); assert!( - !verify_bundle_signature(dir.path(), key).unwrap(), - "tampered index.json must break HMAC verification" + !verify_bundle_signature(dir.path(), &signing.verifying_key()).unwrap(), + "tampered index.json must break signature verification" ); } #[test] - fn test_hmac_detects_tamper_on_sha256sums() { + fn verify_rejects_sha256sums_tamper() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("SHA256SUMS"), "abc123 file.txt\n").unwrap(); fs::write(dir.path().join("index.json"), b"{}\n").unwrap(); - let key = b"test-secret-key-bytes"; - sign_bundle(dir.path(), key).unwrap(); + let signing = deterministic_signing_key(7); + sign_bundle(dir.path(), &signing).unwrap(); - // Tamper SHA256SUMS. fs::write(dir.path().join("SHA256SUMS"), "deadbeef file.txt\n").unwrap(); assert!( - !verify_bundle_signature(dir.path(), key).unwrap(), - "tampered SHA256SUMS must break HMAC verification" + !verify_bundle_signature(dir.path(), &signing.verifying_key()).unwrap(), + "tampered SHA256SUMS must break signature verification" + ); + } + + #[test] + fn read_write_signing_key_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("signing.key"); + let original = deterministic_signing_key(42); + + write_signing_key(&path, &original).unwrap(); + let recovered = read_signing_key(&path).unwrap(); + + assert_eq!(original.to_bytes(), recovered.to_bytes()); + } + + #[test] + fn read_write_verifying_key_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("signing.pub"); + let original = deterministic_signing_key(42).verifying_key(); + + write_verifying_key(&path, &original).unwrap(); + let recovered = read_verifying_key(&path).unwrap(); + + assert_eq!(original.to_bytes(), recovered.to_bytes()); + } + + #[test] + fn read_signing_key_rejects_short_input() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("signing.key"); + // 31 bytes of zero, hex-encoded -> 62 chars, will decode but fail the length check. + fs::write(&path, "00".repeat(31)).unwrap(); + let err = read_signing_key(&path).unwrap_err(); + match err { + SigningError::InvalidKey { reason } => { + assert!( + reason.contains("32"), + "reason should name the expected length: {reason}" + ); + } + other => panic!("expected InvalidKey, got {other:?}"), + } + } + + #[test] + fn read_signing_key_rejects_non_hex_input() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("signing.key"); + fs::write(&path, "not-hex-content").unwrap(); + let err = read_signing_key(&path).unwrap_err(); + match err { + SigningError::InvalidKey { .. } => (), + other => panic!("expected InvalidKey, got {other:?}"), + } + } + + #[test] + fn generate_signing_key_returns_distinct_keys() { + let a = generate_signing_key(); + let b = generate_signing_key(); + assert_ne!( + a.to_bytes(), + b.to_bytes(), + "OsRng must not produce duplicate keys back-to-back" ); } + + #[test] + fn sign_bundle_overwrites_existing_signature() { + // Re-signing replaces the previous BUNDLE.sig in place — there is + // never a stale signature alongside a fresh one. + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("SHA256SUMS"), "first a\n").unwrap(); + fs::write(dir.path().join("index.json"), b"{\"v\":1}\n").unwrap(); + + let signing = deterministic_signing_key(7); + sign_bundle(dir.path(), &signing).unwrap(); + let first_sig = fs::read_to_string(dir.path().join("BUNDLE.sig")).unwrap(); + + fs::write(dir.path().join("SHA256SUMS"), "second a\n").unwrap(); + sign_bundle(dir.path(), &signing).unwrap(); + let second_sig = fs::read_to_string(dir.path().join("BUNDLE.sig")).unwrap(); + + assert_ne!(first_sig, second_sig); + } } diff --git a/crates/evidence-core/src/hash.rs b/crates/evidence-core/src/hash.rs index 494db3d..34c4f71 100644 --- a/crates/evidence-core/src/hash.rs +++ b/crates/evidence-core/src/hash.rs @@ -199,7 +199,7 @@ pub fn write_sha256sums(root: &Path, out_path: &Path) -> Result<(), HashError> { continue; // index.json (contains timestamps) } if entry.path() == sig_path { - continue; // BUNDLE.sig (HMAC signature, written after finalization) + continue; // BUNDLE.sig (ed25519 signature, written after finalization) } files.push(entry.path().to_path_buf()); } diff --git a/crates/evidence-core/src/lib.rs b/crates/evidence-core/src/lib.rs index e406550..42d9e8a 100644 --- a/crates/evidence-core/src/lib.rs +++ b/crates/evidence-core/src/lib.rs @@ -72,8 +72,10 @@ pub use boundary_check::{ check_no_proc_macros, }; pub use bundle::{ - EvidenceBuildConfig, EvidenceBuilder, EvidenceIndex, TestSummary, ToolCommandFailure, - parse_cargo_test_output_detailed, sign_bundle, verify_bundle_signature, + EvidenceBuildConfig, EvidenceBuilder, EvidenceIndex, SigningError, TestSummary, + ToolCommandFailure, generate_signing_key, parse_cargo_test_output_detailed, read_signing_key, + read_verifying_key, sign_bundle, verify_bundle_signature, write_signing_key, + write_verifying_key, }; pub use compliance::{ Applicability, ComplianceReport, ComplianceSummary, CrateEvidence, OBJECTIVES, ObjectiveStatus, @@ -84,6 +86,7 @@ pub use coverage::{ parse_llvm_cov_export, }; pub use diagnostic::{Diagnostic, DiagnosticCode, Location, Severity, TERMINAL_CODES}; +pub use ed25519_dalek::{Signature, SigningKey, VerifyingKey}; pub use env::{EnvFingerprint, Host}; pub use floors::{FloorsConfig, current_measurements}; pub use git::{GitSnapshot, RealGitProvider}; diff --git a/crates/evidence-core/src/rules.rs b/crates/evidence-core/src/rules.rs index d99a512..efcf51e 100644 --- a/crates/evidence-core/src/rules.rs +++ b/crates/evidence-core/src/rules.rs @@ -336,7 +336,6 @@ pub const RULES: &[RuleEntry] = &[ terminal("VERIFY_ERROR", Severity::Error), terminal("VERIFY_FAIL", Severity::Error), r("VERIFY_HASH_MISMATCH", Severity::Error, Domain::Verify), - r("VERIFY_HMAC_FAILURE", Severity::Error, Domain::Verify), r("VERIFY_INVALID_FORMAT", Severity::Error, Domain::Verify), r( "VERIFY_LLR_CHECK_SKIPPED_NO_OUTCOMES", @@ -379,6 +378,7 @@ pub const RULES: &[RuleEntry] = &[ ), r("VERIFY_RUNTIME_SIGNING", Severity::Error, Domain::Verify), r("VERIFY_RUNTIME_WALK", Severity::Error, Domain::Verify), + r("VERIFY_SIGNATURE_INVALID", Severity::Error, Domain::Verify), r( "VERIFY_TEST_SUMMARY_ABSENT_ON_FAILED_RUN", Severity::Error, diff --git a/crates/evidence-core/src/verify/bundle.rs b/crates/evidence-core/src/verify/bundle.rs index 97a4a71..1a79f13 100644 --- a/crates/evidence-core/src/verify/bundle.rs +++ b/crates/evidence-core/src/verify/bundle.rs @@ -30,15 +30,18 @@ use super::runtime_error::VerifyRuntimeError; /// 5. All SHA256SUMS entries match actual file hashes /// 6. content_hash matches actual SHA256SUMS hash /// 7. No unexpected files exist outside SHA256SUMS and known metadata -/// 8. If `verify_key` is provided, verify BUNDLE.sig HMAC +/// 8. If a verifying key is provided, verify the ed25519 detached +/// signature in `BUNDLE.sig` against the `(SHA256SUMS, index.json)` +/// envelope. pub fn verify_bundle(bundle: &Path) -> Result { verify_bundle_with_key(bundle, None) } -/// Verify an evidence bundle, optionally checking HMAC signature. +/// Verify an evidence bundle, optionally checking the ed25519 detached +/// signature in `BUNDLE.sig` against the supplied verifying (public) key. pub fn verify_bundle_with_key( bundle: &Path, - verify_key: Option<&[u8]>, + verify_key: Option<&ed25519_dalek::VerifyingKey>, ) -> Result { tracing::info!("verify: checking bundle at {:?}", bundle); @@ -168,22 +171,22 @@ pub fn verify_bundle_with_key( verify_errors.push(VerifyError::UnexpectedFile(rel)); } - // 8. HMAC signature verification (if key provided) + // 8. Ed25519 signature verification (if a verifying key was supplied). let sig_path = bundle.join("BUNDLE.sig"); if let Some(key) = verify_key { if !sig_path.exists() { - verify_errors.push(VerifyError::HmacFailure); + verify_errors.push(VerifyError::SignatureInvalid); } else { let valid = crate::bundle::verify_bundle_signature(bundle, key)?; if !valid { - verify_errors.push(VerifyError::HmacFailure); + verify_errors.push(VerifyError::SignatureInvalid); } else { - tracing::info!("verify: HMAC signature OK"); + tracing::info!("verify: ed25519 signature OK"); } } } else if sig_path.exists() { tracing::info!( - "verify: BUNDLE.sig present but no --verify-key provided, skipping HMAC check" + "verify: BUNDLE.sig present but no --verify-key provided, skipping signature check" ); } diff --git a/crates/evidence-core/src/verify/consistency.rs b/crates/evidence-core/src/verify/consistency.rs index 61c4d81..dc44aaf 100644 --- a/crates/evidence-core/src/verify/consistency.rs +++ b/crates/evidence-core/src/verify/consistency.rs @@ -1,9 +1,9 @@ //! Cross-layer consistency checks — step 6c/6d/6e of `verify_bundle`. //! //! Each check here catches tampering that can't be detected by the -//! HMAC envelope alone (e.g. an insider-with-key attack) by requiring -//! index.json fields to agree with independently-derivable data in -//! the signed content layer: +//! ed25519 signature envelope alone (e.g. an insider-with-private-key +//! attack) by requiring index.json fields to agree with independently- +//! derivable data in the signed content layer: //! //! - `check_trace_outputs_hashed` — every `trace_outputs[i]` appears in SHA256SUMS //! - `check_test_summary` — `index.test_summary` equals a re-parse of captured stdout @@ -100,8 +100,8 @@ pub(super) fn check_test_summary( /// in `index.json.dal_map` must have a matching compliance report /// whose `dal` field agrees; extra `compliance/*.json` files (or /// missing `dal_map` entries) are flagged as orphans. Without this, -/// a holder with the HMAC key could demote a crate's DAL in -/// index.json while leaving the qualifying compliance artifact +/// a holder of the ed25519 signing private key could demote a crate's +/// DAL in index.json while leaving the qualifying compliance artifact /// untouched. pub(super) fn check_dal_map(bundle: &Path, index: &EvidenceIndex, errors: &mut Vec) { let compliance_dir = bundle.join("compliance"); diff --git a/crates/evidence-core/src/verify/errors.rs b/crates/evidence-core/src/verify/errors.rs index b4b478e..aa1f59f 100644 --- a/crates/evidence-core/src/verify/errors.rs +++ b/crates/evidence-core/src/verify/errors.rs @@ -13,8 +13,9 @@ use crate::diagnostic::{DiagnosticCode, Location, Severity}; pub enum VerifyError { /// A file exists in the bundle but is not listed in SHA256SUMS UnexpectedFile(String), - /// HMAC signature verification failed - HmacFailure, + /// Ed25519 signature verification failed (or `BUNDLE.sig` was + /// missing when a verifying key was supplied). + SignatureInvalid, /// A hash in SHA256SUMS does not match the actual file hash HashMismatch { /// Bundle-relative path whose hash disagreed. @@ -218,7 +219,7 @@ impl DiagnosticCode for VerifyError { // a stable code here fails compilation — Schema Rule 3. match self { VerifyError::UnexpectedFile(_) => "VERIFY_UNEXPECTED_FILE", - VerifyError::HmacFailure => "VERIFY_HMAC_FAILURE", + VerifyError::SignatureInvalid => "VERIFY_SIGNATURE_INVALID", VerifyError::HashMismatch { .. } => "VERIFY_HASH_MISMATCH", VerifyError::MissingHashedFile(_) => "VERIFY_MISSING_HASHED_FILE", VerifyError::ContentHashMismatch { .. } => "VERIFY_CONTENT_HASH_MISMATCH", @@ -273,7 +274,7 @@ impl DiagnosticCode for VerifyError { } // The remaining variants are bundle-wide invariants; no // single file "owns" the failure. - VerifyError::HmacFailure + VerifyError::SignatureInvalid | VerifyError::ContentHashMismatch { .. } | VerifyError::FormatError { .. } | VerifyError::CrossFileInconsistency { .. } @@ -347,8 +348,8 @@ mod tests { assert!(VerifyResult::Pass.is_pass()); assert!(!VerifyResult::Pass.is_fail()); - assert!(VerifyResult::Fail(vec![VerifyError::HmacFailure]).is_fail()); - assert!(!VerifyResult::Fail(vec![VerifyError::HmacFailure]).is_pass()); + assert!(VerifyResult::Fail(vec![VerifyError::SignatureInvalid]).is_fail()); + assert!(!VerifyResult::Fail(vec![VerifyError::SignatureInvalid]).is_pass()); assert!(!VerifyResult::Skipped("reason".to_string()).is_pass()); assert!(!VerifyResult::Skipped("reason".to_string()).is_fail()); diff --git a/crates/evidence-core/src/verify/errors_display.rs b/crates/evidence-core/src/verify/errors_display.rs index 4906174..ae510c4 100644 --- a/crates/evidence-core/src/verify/errors_display.rs +++ b/crates/evidence-core/src/verify/errors_display.rs @@ -9,7 +9,7 @@ impl std::fmt::Display for VerifyError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { VerifyError::UnexpectedFile(file) => write!(f, "unexpected file: {}", file), - VerifyError::HmacFailure => write!(f, "HMAC signature verification failed"), + VerifyError::SignatureInvalid => write!(f, "ed25519 signature verification failed"), VerifyError::HashMismatch { file, expected, diff --git a/crates/evidence-core/src/verify/runtime_error.rs b/crates/evidence-core/src/verify/runtime_error.rs index 200128d..07ed5a4 100644 --- a/crates/evidence-core/src/verify/runtime_error.rs +++ b/crates/evidence-core/src/verify/runtime_error.rs @@ -59,9 +59,9 @@ pub enum VerifyRuntimeError { /// A file hash couldn't be computed. #[error(transparent)] Hash(#[from] HashError), - /// HMAC signature verification had an I/O or envelope error - /// (distinct from the signature being invalid, which is a - /// [`crate::verify::VerifyError::HmacFailure`]). + /// Ed25519 signature verification or key handling raised an I/O or + /// envelope error (distinct from the signature being mathematically + /// invalid, which is a [`crate::verify::VerifyError::SignatureInvalid`]). #[error(transparent)] Signing(#[from] SigningError), }