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
15 changes: 13 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `spine-cli verify --strict`: verify a WAL under the strict profile (the same
contract the browser playground runs). Pins the signing key from
`--trusted-pubkey`, requires `--expected-root`, and recomputes each
`payload_hash` from the canonical JSON of the inline payload. This is the
profile the published demo WAL is signed under, so it now verifies on the CLI
as well as in the playground.
- `spine-cli verify`: when every record fails signature verification under the
default lenient profile, the report now hints that the WAL may be
strict-profile and points at `--strict`, rather than leaving a bare wall of
identical signature errors.
- `spine-cli` integration tests covering argument parsing, exit codes, and
JSONL/CSV/text output.
JSONL/CSV/text output, plus the strict profile and the profile hint.

### Changed

Expand All @@ -26,7 +36,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- `spine-core`: canonical JSON now rejects integer-valued floats outside `i64`
range instead of saturating the cast, tightening payload encoding so distinct payloads always serialise to distinct canonical bytes.
range instead of saturating the cast, tightening payload encoding so distinct
payloads always serialise to distinct canonical bytes.
- `spine-cli`: write the export sidecar manifest before publishing the data
file, so a failed manifest write can no longer leave a named export without a
manifest.
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions spine-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,7 @@ tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }

[dev-dependencies]
tempfile = "3.14"
# Smoke tests build a strict-profile signed WAL fixture in-process to
# exercise `verify --strict`. A fixed 32-byte seed key (no randomness)
# keeps the fixture deterministic, so no `rand` feature is needed.
ed25519-dalek = { version = "2.1", default-features = false, features = ["std"] }
15 changes: 13 additions & 2 deletions spine-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ playground, so a WAL that verifies here verifies there byte-for-byte.

- `verify`: run BLAKE3 hash-chain and Ed25519 signature verification over a
WAL directory. Exit code is `0` on `valid:true`, `1` on `valid:false`, `2`
on I/O or input errors.
on I/O or input errors. Defaults to the **lenient** profile (tolerates
unsigned records, `--expected-root` optional, trusts record-declared keys).
Pass `--strict` for the **strict** profile: the exact contract the browser
playground runs, with the signing key pinned from `--trusted-pubkey`,
`--expected-root` mandatory, and every `payload_hash` recomputed from the
inline payload's canonical JSON.
- `export`: emit the audit trail as JSON-Lines, CSV, or RFC 5424 syslog.
JSONL exports written to file are accompanied by a `.manifest.json` that
pins the chain root (cross-verifiable with `verify`).
Expand All @@ -21,12 +26,18 @@ playground, so a WAL that verifies here verifies there byte-for-byte.
## Quick example

```bash
# Verify a directory of .wal / .jsonl segments
# Verify a directory of .wal / .jsonl segments (lenient profile)
spine-cli verify --wal /path/to/wal

# Same, but emit a JSON report to stdout
spine-cli --format json verify --wal /path/to/wal

# Strict profile: pin the signing key and chain root from outside the
# WAL. This is how the published demo WAL verifies (the lenient default
# rejects its domain-separated signatures by design).
spine-cli verify --strict --wal /path/to/wal \
--trusted-pubkey <64-hex-pubkey> --expected-root <64-hex-root>

# Show 20 most recent events as a table
spine-cli inspect --wal /path/to/wal -n 20

Expand Down
30 changes: 28 additions & 2 deletions spine-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
//!
//! * `verify`: lenient verification of a WAL directory against
//! `spine-core`. Optional `--keystore` enables receipt-signature
//! verification; `--trusted-pubkey` pins the signing key.
//! verification; `--trusted-pubkey` pins the signing key. Pass
//! `--strict` to switch to the strict profile (the playground
//! contract): the pinned pubkey and `--expected-root` become
//! mandatory and each `payload_hash` is recomputed from the inline
//! payload's canonical JSON.
//! * `inspect`: human-readable view of WAL contents. No verification.
//! * `export`: filter and export entries to JSONL, CSV or syslog. A
//! `.manifest.json` sidecar is always written next to the output
Expand Down Expand Up @@ -114,9 +118,27 @@ enum Commands {
/// instead of being silently trusted. Without this flag the
/// lenient verifier trusts the record-declared pubkey (and
/// warns about it), which is sufficient for offline audit
/// but not for an enterprise CI gate.
/// but not for an enterprise CI gate. Under `--strict` this
/// is the externally pinned key and is mandatory.
#[arg(long)]
trusted_pubkey: Option<String>,

/// Verify under the strict profile: the same contract the
/// browser playground runs. Every record must be signed, the
/// signing key is pinned from `--trusted-pubkey` (mandatory),
/// `--expected-root` is mandatory, and each `payload_hash` is
/// recomputed from the canonical JSON of the inline payload.
/// Strict signatures are domain-separated, so a strict-profile
/// WAL (for example the published Spine demo WAL) fails the
/// default lenient path; pass `--strict` to verify it.
#[arg(long)]
strict: bool,

/// Manifest version echoed into the strict report so a
/// consumer can pin the exact contract it expects. Strict
/// profile only; ignored in lenient mode.
#[arg(long, default_value_t = 1)]
manifest_version: u32,
},

/// Export WAL records (JSONL, CSV or syslog) with optional time
Expand Down Expand Up @@ -208,13 +230,17 @@ fn main() -> ExitCode {
fail_fast,
keystore,
trusted_pubkey,
strict,
manifest_version,
} => verify::run(
&wal,
expected_root.as_deref(),
output.as_deref(),
fail_fast,
keystore.as_deref(),
trusted_pubkey.as_deref(),
strict,
manifest_version,
cli.format,
)
.map_err(|e| e.to_string()),
Expand Down
201 changes: 199 additions & 2 deletions spine-cli/src/verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
use std::fs;
use std::path::Path;

use spine_core::{verify_wal_bytes_with_options, Keystore, LenientOptions, VerificationResult};
use spine_core::{
verify_demo_wal, verify_wal_bytes_with_options, DemoRecordOutcome, DemoReport, DemoStatus,
Keystore, LenientOptions, VerificationResult,
};

use crate::wal_io::{read_wal_bytes, WalIoError};
use crate::OutputFormat;
Expand All @@ -19,24 +22,45 @@ pub enum VerifyCmdError {
#[error("Keystore could not be loaded: {0}")]
Keystore(String),

/// Bad flag combination or a strict configuration the verifier
/// could not run with (for example a malformed pinned pubkey).
/// Maps to exit code 2.
#[error("{0}")]
Usage(String),

#[error("Output write failed: {0}")]
OutputWrite(std::io::Error),

#[error("Report serialisation failed: {0}")]
Serialize(#[from] serde_json::Error),
}

#[allow(clippy::too_many_arguments)]
pub fn run(
wal_path: &Path,
expected_root: Option<&str>,
output_path: Option<&Path>,
fail_fast: bool,
keystore_path: Option<&Path>,
trusted_pubkey: Option<&str>,
strict: bool,
manifest_version: u32,
format: OutputFormat,
) -> Result<bool, VerifyCmdError> {
let bytes = read_wal_bytes(wal_path)?;

if strict {
return run_strict(
&bytes,
expected_root,
trusted_pubkey,
keystore_path,
manifest_version,
output_path,
format,
);
}

let keystore = match keystore_path {
Some(p) => {
Some(Keystore::load_from_file(p).map_err(|e| VerifyCmdError::Keystore(e.to_string()))?)
Expand All @@ -55,12 +79,104 @@ pub fn run(
// partial report (records up to a fail-fast halt, plus the
// failing error in result.errors) is always emitted. We just
// surface it as-is.
let result = verify_wal_bytes_with_options(&bytes, &opts);
let mut result = verify_wal_bytes_with_options(&bytes, &opts);
maybe_add_profile_hint(&mut result);

emit_report(&result, output_path, format)?;
Ok(result.valid)
}

/// When every record fails signature verification under the lenient
/// profile, the likeliest cause is not tampering but a profile
/// mismatch: a strict-signed WAL (domain-separated signatures, e.g.
/// the published Spine demo WAL) fed to the lenient verifier. A wall
/// of identical `invalid_signature` errors reads as catastrophe; this
/// hint points the user at `--strict` instead. We only fire it when
/// the failure count covers EVERY verified record, which is the
/// fingerprint of a wrong-profile run rather than partial tampering.
fn maybe_add_profile_hint(result: &mut VerificationResult) {
if result.valid || result.events_verified == 0 {
return;
}
let sig_failures = result
.errors
.iter()
.filter(|e| e.error_type == "invalid_signature")
.count() as u64;
if sig_failures > 0 && sig_failures == result.events_verified {
result.warnings.push(
"All records failed signature verification under the lenient profile. \
If this is a strict-profile WAL (for example the Spine demo WAL), re-run \
with --strict --trusted-pubkey <hex> --expected-root <hex>."
.to_string(),
);
}
}

/// Strict-profile verification: a thin shim over
/// [`spine_core::verify_demo_wal`], the exact contract the browser
/// playground runs. The pinned key and expected root are mandatory;
/// `--keystore` is rejected because strict does not check receipts.
#[allow(clippy::too_many_arguments)]
fn run_strict(
bytes: &[u8],
expected_root: Option<&str>,
trusted_pubkey: Option<&str>,
keystore_path: Option<&Path>,
manifest_version: u32,
output_path: Option<&Path>,
format: OutputFormat,
) -> Result<bool, VerifyCmdError> {
if keystore_path.is_some() {
return Err(VerifyCmdError::Usage(
"--keystore is not used by the strict profile (receipts are not checked in strict mode)"
.to_string(),
));
}
let pubkey = trusted_pubkey.ok_or_else(|| {
VerifyCmdError::Usage(
"strict profile requires --trusted-pubkey (the externally pinned signing key)"
.to_string(),
)
})?;
let root = expected_root.ok_or_else(|| {
VerifyCmdError::Usage("strict profile requires --expected-root".to_string())
})?;

// Match the lenient path's tolerance for an optional `0x` prefix
// and surrounding whitespace so the same root string works under
// either profile.
let pubkey = normalize_hex(pubkey);
let root = normalize_hex(root);

let report = verify_demo_wal(bytes, &pubkey, &root, manifest_version);
emit_strict_report(&report, output_path, format)?;

match report.status {
DemoStatus::Valid => Ok(true),
DemoStatus::Invalid => Ok(false),
// A configuration error (malformed pinned pubkey or root) is
// a usage problem, not a verdict on the WAL: surface it as
// exit 2 rather than letting it masquerade as "invalid".
DemoStatus::Error => {
Err(VerifyCmdError::Usage(report.error.unwrap_or_else(|| {
"strict verification could not run".to_string()
})))
}
}
}

/// Trim, strip an optional `0x`/`0X` prefix, and lowercase a hex
/// string, mirroring the lenient verifier's `expected_root`
/// normalisation.
fn normalize_hex(s: &str) -> String {
let t = s.trim();
t.strip_prefix("0x")
.or_else(|| t.strip_prefix("0X"))
.unwrap_or(t)
.to_lowercase()
}

fn emit_report(
result: &VerificationResult,
output_path: Option<&Path>,
Expand Down Expand Up @@ -97,6 +213,87 @@ fn emit_report(
Ok(())
}

fn emit_strict_report(
report: &DemoReport,
output_path: Option<&Path>,
format: OutputFormat,
) -> Result<(), VerifyCmdError> {
// File output is always JSON, regardless of --format, matching the
// lenient path's contract (see main.rs's --format docstring).
match format {
OutputFormat::Json => {
let json = serde_json::to_string_pretty(report)?;
if let Some(out) = output_path {
fs::write(out, &json).map_err(VerifyCmdError::OutputWrite)?;
} else {
println!("{json}");
}
}
OutputFormat::Text => {
if let Some(out) = output_path {
let json = serde_json::to_string_pretty(report)?;
fs::write(out, &json).map_err(VerifyCmdError::OutputWrite)?;
} else {
print_strict_text_report(report);
}
}
OutputFormat::Quiet => {
if let Some(out) = output_path {
let json = serde_json::to_string_pretty(report)?;
fs::write(out, &json).map_err(VerifyCmdError::OutputWrite)?;
}
}
}
Ok(())
}

fn print_strict_text_report(report: &DemoReport) {
let status = match report.status {
DemoStatus::Valid => "VALID",
DemoStatus::Invalid => "INVALID",
DemoStatus::Error => "ERROR",
};
println!("Status: {status}");
println!("Profile: strict");
println!("Verifier version: {}", report.verifier_version);
println!("Events verified: {}", report.events_verified);
println!("Signatures verified: {}", report.signatures_verified);
println!("Expected pubkey fp: {}", report.expected_pubkey_fp);
if !report.chain_root.is_empty() {
let short = short_hex(&report.chain_root, 16);
println!("Chain root: {short}...");
}
if let Some(err) = &report.error {
println!("\nError: {err}");
}
let problems: Vec<&spine_core::DemoRecordEntry> = report
.records
.iter()
.filter(|r| !matches!(r.outcome, DemoRecordOutcome::Valid))
.collect();
if !problems.is_empty() {
println!("\nRecords with issues:");
for r in problems {
println!(" [seq {}] {}", r.sequence, describe_outcome(&r.outcome));
}
}
}

/// Render a non-valid strict outcome as `<outcome>: <reason.kind>`
/// (for example `invalid: payload_hash_mismatch`). Driven off the
/// serde tags so it stays correct as reason variants evolve, rather
/// than hand-maintaining a match over every variant.
fn describe_outcome(outcome: &DemoRecordOutcome) -> String {
let Ok(v) = serde_json::to_value(outcome) else {
return "unprintable outcome".to_string();
};
let tag = v.get("outcome").and_then(|t| t.as_str()).unwrap_or("?");
v.get("reason")
.and_then(|r| r.get("kind"))
.and_then(|k| k.as_str())
.map_or_else(|| tag.to_string(), |kind| format!("{tag}: {kind}"))
}

fn print_text_report(result: &VerificationResult) {
let status = if result.valid { "VALID" } else { "INVALID" };
println!("Status: {status}");
Expand Down
Loading
Loading