Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
239 changes: 212 additions & 27 deletions Cargo.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
56 changes: 55 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pubkey>` |
| **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/<bundle> --verify-key cert/signing.pub
```

### Captured Output Normalization

Every file written by `cargo evidence generate` under the capture directory
Expand Down Expand Up @@ -632,7 +682,11 @@ Previously tracked items now resolved:
(captures panic/assertion text from `---- <test> 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
Expand Down
44 changes: 44 additions & 0 deletions cert/QUALIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cert/floors.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions cert/trace/hlr.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
13 changes: 10 additions & 3 deletions cert/trace/llr.toml
Original file line number Diff line number Diff line change
Expand Up @@ -317,19 +317,26 @@ 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"]
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",
Expand Down
22 changes: 18 additions & 4 deletions cert/trace/sys.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 7 additions & 6 deletions crates/cargo-evidence/src/cli/generate/phases.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf>,
Expand All @@ -400,11 +401,11 @@ pub(super) fn finalize_and_sign(
) -> Result<PathBuf> {
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)
Expand Down
70 changes: 34 additions & 36 deletions crates/cargo-evidence/src/cli/verify.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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,
Expand All @@ -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(),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -382,31 +373,21 @@ 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);
}
},
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
Expand Down Expand Up @@ -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(),
}
}
14 changes: 7 additions & 7 deletions crates/cargo-evidence/tests/fixtures/golden_rules.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading