use std::fs;
use std::path::{Path, PathBuf};
use spec_spine_core::{
VerifyOutcome, check_attestation_major, check_snapshot_major, check_spec_attestation_major,
payload_schema_version, stored_bytes_hash, verify_recompute, verify_snapshot_recompute,
verify_spec_recompute, with_stored_bytes, with_stored_bytes_snapshot, with_stored_bytes_spec,
};
use spec_spine_types::{
AuthoritySnapshot, Config, CorpusAttestation, Error, LedgerSeal, SpecAttestation, Verdict,
verdict::verb,
};
use crate::load_repo_config;
use crate::out;
use crate::seal;
pub struct VerifyArgs {
pub spec: Option<String>,
pub snapshot: bool,
pub recompute: bool,
pub signature: bool,
pub attestation: Option<PathBuf>,
pub public_key: Option<PathBuf>,
pub seal: Option<PathBuf>,
pub json: bool,
}
pub fn run(repo: &Path, args: &VerifyArgs) -> Result<u8, Error> {
if !args.recompute && !args.signature {
return Err(Error::Usage(
"verify-attestation requires at least one mode: --recompute and/or --signature"
.to_string(),
));
}
if args.snapshot && args.spec.is_some() {
return Err(Error::Usage(
"verify-attestation --snapshot cannot combine with --spec: each names a different \
record (spec 070 3.5)"
.to_string(),
));
}
let cfg = load_repo_config(repo)?;
if let Some(id) = &args.spec {
validate_spec_id(id)?;
}
let spec = match &args.spec {
Some(id) => Some(resolve_attested_spec(repo, &cfg, id)?),
None => None,
};
let attestation_path = args.attestation.clone().unwrap_or_else(|| {
if args.snapshot {
repo.join(&cfg.layout.derived_dir)
.join("attestation")
.join("snapshot.json")
} else {
default_attestation_path(repo, &cfg, spec.as_deref())
}
});
let hint = match &spec {
Some(id) => format!("spec-spine attest --spec {id}"),
None if args.snapshot => "spec-spine attest --snapshot".to_string(),
None => "spec-spine attest".to_string(),
};
let bytes = read_artifact(&attestation_path, "attestation", &hint)?;
let version = payload_schema_version(&bytes, "attestation")?;
let subject = match &spec {
None if args.snapshot => {
check_snapshot_major(&version)?;
Subject::Snapshot(Box::new(parse_artifact(
&bytes,
&attestation_path,
"snapshot",
)?))
}
Some(_) => {
check_spec_attestation_major(&version)?;
Subject::Spec(parse_artifact(&bytes, &attestation_path, "attestation")?)
}
None => {
check_attestation_major(&version)?;
Subject::Corpus(parse_artifact(&bytes, &attestation_path, "attestation")?)
}
};
let mut failed = false;
let mut report = serde_json::Map::new();
if args.recompute {
let outcome = match &subject {
Subject::Corpus(a) => with_stored_bytes(verify_recompute(&cfg, repo, a)?, a, &bytes)?,
Subject::Spec(a) => {
with_stored_bytes_spec(verify_spec_recompute(&cfg, repo, a)?, a, &bytes)?
}
Subject::Snapshot(a) => {
with_stored_bytes_snapshot(verify_snapshot_recompute(&cfg, repo, a)?, a, &bytes)?
}
};
match outcome {
VerifyOutcome::Match => {
if args.json {
report.insert("outcome".to_string(), serde_json::json!("match"));
} else {
outln!("recompute: MATCH (the corpus reproduces this attestation)");
}
}
VerifyOutcome::VersionMismatch { expected, actual } => {
if args.json {
report.insert("outcome".to_string(), serde_json::json!("versionMismatch"));
report.insert("expected".to_string(), serde_json::json!(expected));
report.insert("actual".to_string(), serde_json::json!(actual));
} else {
eprintln!(
"recompute: VERSION MISMATCH (attested under {expected}, this tool is {actual}); \
recompute under {expected} to verify"
);
}
failed = true;
}
VerifyOutcome::ContentMismatch { differences } => {
if args.json {
report.insert("outcome".to_string(), serde_json::json!("contentMismatch"));
report.insert("differences".to_string(), serde_json::json!(differences));
} else {
eprintln!(
"recompute: CONTENT MISMATCH ({} field(s) diverged):",
differences.len()
);
for d in &differences {
eprintln!(" - {d}");
}
}
failed = true;
}
}
}
if args.signature {
let pk_path = args.public_key.as_ref().ok_or_else(|| {
Error::Usage(
"verify-attestation --signature requires --public-key <path> (a 32-byte ed25519 public key)"
.to_string(),
)
})?;
let verifying_key = seal::load_verifying_key(pk_path)?;
let seal_path = args
.seal
.clone()
.unwrap_or_else(|| attestation_path.with_extension("sig"));
let seal_hint = match &spec {
Some(id) => format!("spec-spine attest --spec {id} --sign"),
None if args.snapshot => "spec-spine attest --snapshot --sign".to_string(),
None => "spec-spine attest --sign".to_string(),
};
let ledger_seal: LedgerSeal = load_json(&seal_path, "seal", &seal_hint)?;
let hash = stored_bytes_hash(&bytes);
let valid = seal::verify(&hash, &ledger_seal, &verifying_key)?;
if args.json {
report.insert(
"signature".to_string(),
serde_json::json!({ "valid": valid, "keyId": ledger_seal.key_id }),
);
} else if valid {
outln!("signature: VALID (sealed by keyId {})", ledger_seal.key_id);
} else {
eprintln!(
"signature: INVALID (the seal does not verify against the supplied public key)"
);
}
if !valid {
failed = true;
}
}
let code = if failed { 1 } else { 0 };
if args.json {
out::verdict(&Verdict::report(
verb::VERIFY_ATTESTATION,
code,
serde_json::Value::Object(report),
))?;
}
Ok(code)
}
enum Subject {
Corpus(CorpusAttestation),
Spec(SpecAttestation),
Snapshot(Box<AuthoritySnapshot>),
}
fn default_attestation_path(repo: &Path, cfg: &Config, spec: Option<&str>) -> PathBuf {
let dir = repo.join(&cfg.layout.derived_dir).join("attestation");
match spec {
Some(id) => dir.join("by-spec").join(format!("{id}.json")),
None => dir.join("attestation.json"),
}
}
fn resolve_attested_spec(repo: &Path, cfg: &Config, id: &str) -> Result<String, Error> {
let dir = repo
.join(&cfg.layout.derived_dir)
.join("attestation")
.join("by-spec");
let mut stems: Vec<String> = Vec::new();
if let Ok(entries) = fs::read_dir(&dir) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if let Some(stem) = name.strip_suffix(".json") {
stems.push(stem.to_string());
}
}
}
match spec_spine_core::match_spec_id(id, &stems) {
spec_spine_core::SpecIdMatch::Resolved(r) => Ok(r),
spec_spine_core::SpecIdMatch::Ambiguous(c) => {
Err(spec_spine_core::spec_id::ambiguous(id, &c))
}
spec_spine_core::SpecIdMatch::NoMatch => Ok(id.to_string()),
}
}
fn validate_spec_id(id: &str) -> Result<(), Error> {
let bad = id.is_empty()
|| id.contains('/')
|| id.contains('\\')
|| id == "."
|| id == ".."
|| id.contains('\0');
if bad {
return Err(Error::Usage(format!(
"verify-attestation --spec '{id}' is not a spec id: an id is one path segment, \
and pointing at another file is what --attestation is for"
)));
}
Ok(())
}
fn load_json<T: serde::de::DeserializeOwned>(
path: &Path,
what: &str,
hint: &str,
) -> Result<T, Error> {
let bytes = read_artifact(path, what, hint)?;
parse_artifact(&bytes, path, what)
}
fn read_artifact(path: &Path, what: &str, hint: &str) -> Result<Vec<u8>, Error> {
fs::read(path).map_err(|e| {
Error::Io(format!(
"read {what} {} (run `{hint}` first?): {e}",
path.display()
))
})
}
fn parse_artifact<T: serde::de::DeserializeOwned>(
bytes: &[u8],
path: &Path,
what: &str,
) -> Result<T, Error> {
serde_json::from_slice(bytes)
.map_err(|e| Error::Schema(format!("invalid {what} {}: {e}", path.display())))
}