use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};
use thiserror::Error;
pub const MANIFEST_FILE: &str = "record-manifest.json";
pub const SIGNATURE_FILE: &str = "record-manifest.json.asc";
pub const HONESTY_LINE: &str =
"A valid signature proves integrity and origin since signing — not the run's conditions.";
const INSTRUMENT_NAME: &str = "veredictum";
#[derive(Debug, Error)]
pub enum RecordError {
#[error("record manifest: duplicate file name {name:?}")]
DuplicateFile {
name: String,
},
#[error("record manifest: {name:?} is not a plain file name (no path separators, no `..`)")]
UnsafeFileName {
name: String,
},
#[error("record manifest: cannot serialize: {source}")]
Serialize {
#[source]
source: serde_json::Error,
},
#[error("record manifest: cannot parse {path}: {message}")]
ManifestParse {
path: PathBuf,
message: String,
},
#[error("cannot read {path}: {source}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("signing key: {message}")]
SigningKey {
message: String,
},
#[error("public key: {message}")]
PublicKey {
message: String,
},
#[error("detached signature: {message}")]
Signature {
message: String,
},
#[error("detached signature: no creation time subpacket")]
NoSigningTime,
#[error("detached signature: creation time is not representable: {message}")]
SigningTime {
message: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DigestAlgorithm {
Sha256,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Instrument {
pub name: String,
pub version: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RecordManifest {
pub instrument: Instrument,
pub digest_algorithm: DigestAlgorithm,
pub files: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Copy)]
pub struct RecordedFile<'a> {
pub name: &'a str,
pub body: &'a [u8],
}
#[derive(Debug, Clone)]
pub struct SealedRecord {
pub manifest: String,
pub signature: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedRecord {
pub signer_fingerprint: String,
pub signed_at: jiff::Timestamp,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SignatureOutcome {
Accepted(VerifiedRecord),
Rejected,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DigestOutcome {
Matches,
Mismatch {
recomputed: String,
},
Missing,
Unreadable {
message: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileVerdict {
pub name: String,
pub digest: String,
pub outcome: DigestOutcome,
}
#[derive(Debug, Clone)]
pub struct BundleVerification {
pub signature: SignatureOutcome,
pub instrument: Instrument,
pub files: Vec<FileVerdict>,
}
impl BundleVerification {
#[must_use]
pub fn findings(&self) -> Vec<String> {
let mut findings = Vec::new();
if self.signature == SignatureOutcome::Rejected {
findings.push(format!(
"{MANIFEST_FILE}: the detached signature does not verify against the supplied public key"
));
}
for file in &self.files {
match &file.outcome {
DigestOutcome::Matches => {}
DigestOutcome::Mismatch { recomputed } => findings.push(format!(
"{}: digest mismatch (manifest {}, recomputed {recomputed})",
file.name, file.digest
)),
DigestOutcome::Missing => {
findings.push(format!("{}: named by the manifest, absent", file.name));
}
DigestOutcome::Unreadable { message } => {
findings.push(format!("{}: unreadable: {message}", file.name));
}
}
}
findings
}
#[must_use]
pub fn is_clean(&self) -> bool {
self.findings().is_empty()
}
}
pub fn manifest(files: &[RecordedFile<'_>]) -> Result<String, RecordError> {
let mut digests: BTreeMap<String, String> = BTreeMap::new();
for file in files {
if !is_plain_file_name(file.name) {
return Err(RecordError::UnsafeFileName {
name: file.name.to_owned(),
});
}
if digests
.insert(file.name.to_owned(), hex(&Sha256::digest(file.body)))
.is_some()
{
return Err(RecordError::DuplicateFile {
name: file.name.to_owned(),
});
}
}
render(&RecordManifest {
instrument: Instrument {
name: INSTRUMENT_NAME.to_owned(),
version: env!("CARGO_PKG_VERSION").to_owned(),
},
digest_algorithm: DigestAlgorithm::Sha256,
files: digests,
})
}
pub fn sign(
manifest_bytes: &[u8],
secret_key_armored: &str,
passphrase: Option<&str>,
) -> Result<String, RecordError> {
use pgp::composed::{ArmorOptions, Deserializable as _, DetachedSignature, SignedSecretKey};
use pgp::crypto::hash::HashAlgorithm;
use pgp::types::Password;
use rand::rngs::OsRng;
let (secret, _) =
SignedSecretKey::from_string(secret_key_armored).map_err(|e| RecordError::SigningKey {
message: e.to_string(),
})?;
let password = passphrase.map_or_else(Password::empty, Password::from);
let signing_subkey = secret
.secret_subkeys
.iter()
.find(|subkey| subkey.signatures.iter().any(|sig| sig.key_flags().sign()));
let signature = match signing_subkey {
Some(subkey) => DetachedSignature::sign_binary_data(
OsRng,
&subkey.key,
&password,
HashAlgorithm::Sha256,
manifest_bytes,
),
None => DetachedSignature::sign_binary_data(
OsRng,
&secret.primary_key,
&password,
HashAlgorithm::Sha256,
manifest_bytes,
),
}
.map_err(|e| RecordError::SigningKey {
message: e.to_string(),
})?;
signature
.to_armored_string(ArmorOptions::default())
.map_err(|e| RecordError::Signature {
message: e.to_string(),
})
}
pub fn verify(
manifest_bytes: &[u8],
signature_armored: &str,
public_key_armored: &str,
) -> Result<SignatureOutcome, RecordError> {
use pgp::composed::{Deserializable as _, DetachedSignature, SignedPublicKey};
use pgp::types::KeyDetails as _;
let (key, _) =
SignedPublicKey::from_string(public_key_armored).map_err(|e| RecordError::PublicKey {
message: e.to_string(),
})?;
let (signature, _) =
DetachedSignature::from_string(signature_armored).map_err(|e| RecordError::Signature {
message: e.to_string(),
})?;
let created = signature
.signature
.created()
.ok_or(RecordError::NoSigningTime)?;
let signed_at = jiff::Timestamp::from_second(i64::from(created.as_secs())).map_err(|e| {
RecordError::SigningTime {
message: e.to_string(),
}
})?;
if signature.verify(&key, manifest_bytes).is_ok() {
return Ok(SignatureOutcome::Accepted(VerifiedRecord {
signer_fingerprint: key.fingerprint().to_string(),
signed_at,
}));
}
for subkey in &key.public_subkeys {
if signature.verify(subkey, manifest_bytes).is_ok() {
return Ok(SignatureOutcome::Accepted(VerifiedRecord {
signer_fingerprint: subkey.fingerprint().to_string(),
signed_at,
}));
}
}
Ok(SignatureOutcome::Rejected)
}
pub fn seal(
files: &[RecordedFile<'_>],
secret_key_path: &Path,
passphrase: Option<&str>,
) -> Result<SealedRecord, RecordError> {
let secret_key_armored = read_text(secret_key_path)?;
let manifest = manifest(files)?;
let signature = sign(manifest.as_bytes(), &secret_key_armored, passphrase)?;
Ok(SealedRecord {
manifest,
signature,
})
}
pub fn verify_bundle(
bundle_dir: &Path,
public_key_path: &Path,
) -> Result<BundleVerification, RecordError> {
let manifest_path = bundle_dir.join(MANIFEST_FILE);
let manifest_bytes = std::fs::read(&manifest_path).map_err(|source| RecordError::Read {
path: manifest_path.clone(),
source,
})?;
let signature_armored = read_text(&bundle_dir.join(SIGNATURE_FILE))?;
let public_key_armored = read_text(public_key_path)?;
let signature = verify(&manifest_bytes, &signature_armored, &public_key_armored)?;
let parsed: RecordManifest =
serde_json::from_slice(&manifest_bytes).map_err(|e| RecordError::ManifestParse {
path: manifest_path,
message: e.to_string(),
})?;
let mut files = Vec::with_capacity(parsed.files.len());
for (name, expected) in &parsed.files {
if !is_plain_file_name(name) {
return Err(RecordError::UnsafeFileName { name: name.clone() });
}
files.push(FileVerdict {
name: name.clone(),
digest: expected.clone(),
outcome: digest_outcome(&bundle_dir.join(name), expected, parsed.digest_algorithm),
});
}
Ok(BundleVerification {
signature,
instrument: parsed.instrument,
files,
})
}
fn digest_outcome(path: &Path, expected: &str, algorithm: DigestAlgorithm) -> DigestOutcome {
let bytes = match std::fs::read(path) {
Ok(bytes) => bytes,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
return DigestOutcome::Missing;
}
Err(source) => {
return DigestOutcome::Unreadable {
message: source.to_string(),
};
}
};
let recomputed = match algorithm {
DigestAlgorithm::Sha256 => hex(&Sha256::digest(&bytes)),
};
if recomputed == expected {
DigestOutcome::Matches
} else {
DigestOutcome::Mismatch { recomputed }
}
}
fn render(value: &RecordManifest) -> Result<String, RecordError> {
let mut text =
serde_json::to_string_pretty(value).map_err(|source| RecordError::Serialize { source })?;
text.push('\n');
Ok(text)
}
fn read_text(path: &Path) -> Result<String, RecordError> {
std::fs::read_to_string(path).map_err(|source| RecordError::Read {
path: path.to_owned(),
source,
})
}
fn is_plain_file_name(name: &str) -> bool {
!name.is_empty() && name != "." && name != ".." && !name.contains('/') && !name.contains('\\')
}
pub(crate) fn hex(bytes: &[u8]) -> String {
bytes.iter().fold(
String::with_capacity(bytes.len().saturating_mul(2)),
|mut out, byte| {
let _ = write!(out, "{byte:02x}");
out
},
)
}
#[cfg(test)]
#[expect(
clippy::disallowed_types,
reason = "the forged manifest is authored as raw JSON, which is the only way a tampered \
bundle's bytes reach the reader the way a real one would"
)]
#[expect(
clippy::panic_in_result_fn,
reason = "nine Result-returning tests in the Book ch11 shape, each asserting; \
clippy offers no allow-in-tests knob for this lint"
)]
mod tests {
use super::*;
fn key_dir() -> PathBuf {
Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../..")).join("artifacts/corpus/keys")
}
fn secret_key() -> Result<String, RecordError> {
read_text(&key_dir().join("cnf-signing.sec.asc"))
}
fn public_key() -> Result<String, RecordError> {
read_text(&key_dir().join("cnf-signing.pub.asc"))
}
fn other_public_key() -> Result<String, Box<dyn std::error::Error>> {
use pgp::composed::{KeyType, SecretKeyParamsBuilder};
let params = SecretKeyParamsBuilder::default()
.key_type(KeyType::Ed25519)
.can_sign(true)
.can_certify(true)
.primary_user_id("Veredictum Record Test <record@test.invalid>".to_owned())
.build()?;
let generated = params.generate(rand::rngs::OsRng)?;
Ok(generated.to_public_key().to_armored_string(None.into())?)
}
fn documents<'a>() -> [RecordedFile<'a>; 3] {
[
RecordedFile {
name: "verdicts.json",
body: b"{\"verdicts\":[]}\n",
},
RecordedFile {
name: "report.md",
body: b"# report\n",
},
RecordedFile {
name: "certificate.md",
body: b"# certificate\n",
},
]
}
fn seal_into(dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
let files = documents();
for file in &files {
std::fs::write(dir.join(file.name), file.body)?;
}
let sealed = seal(&files, &key_dir().join("cnf-signing.sec.asc"), None)?;
std::fs::write(dir.join(MANIFEST_FILE), &sealed.manifest)?;
std::fs::write(dir.join(SIGNATURE_FILE), &sealed.signature)?;
Ok(())
}
#[test]
fn manifest_is_byte_deterministic_across_renders() -> Result<(), RecordError> {
let first = manifest(&documents())?;
let second = manifest(&documents())?;
assert_eq!(first, second);
let mut reordered = documents();
reordered.reverse();
assert_eq!(first, manifest(&reordered)?);
Ok(())
}
#[test]
fn manifest_names_every_document_with_its_digest() -> Result<(), Box<dyn std::error::Error>> {
let rendered = manifest(&documents())?;
let parsed: RecordManifest = serde_json::from_str(&rendered)?;
assert_eq!(parsed.instrument.name, INSTRUMENT_NAME);
assert_eq!(parsed.instrument.version, env!("CARGO_PKG_VERSION"));
assert_eq!(parsed.digest_algorithm, DigestAlgorithm::Sha256);
assert_eq!(parsed.files.len(), 3);
assert_eq!(
parsed.files.get("report.md"),
Some(&hex(&Sha256::digest(b"# report\n")))
);
assert_eq!(
parsed.files.keys().cloned().collect::<Vec<_>>(),
vec![
"certificate.md".to_owned(),
"report.md".to_owned(),
"verdicts.json".to_owned()
]
);
Ok(())
}
#[test]
fn a_manifest_entry_that_escapes_the_bundle_is_refused() {
let escaping = [RecordedFile {
name: "../outside.json",
body: b"{}",
}];
assert!(matches!(
manifest(&escaping),
Err(RecordError::UnsafeFileName { .. })
));
}
#[test]
fn a_duplicate_document_name_is_refused() {
let duplicated = [
RecordedFile {
name: "results.json",
body: b"{}",
},
RecordedFile {
name: "results.json",
body: b"{\"other\":true}",
},
];
assert!(matches!(
manifest(&duplicated),
Err(RecordError::DuplicateFile { .. })
));
}
#[test]
fn a_good_bundle_verifies_clean() -> Result<(), Box<dyn std::error::Error>> {
let dir = assert_fs::TempDir::new()?;
seal_into(dir.path())?;
let verification = verify_bundle(dir.path(), &key_dir().join("cnf-signing.pub.asc"))?;
assert_eq!(verification.findings(), Vec::<String>::new());
assert!(verification.is_clean());
let SignatureOutcome::Accepted(record) = &verification.signature else {
panic!("the committed keypair must verify its own signature");
};
assert!(!record.signer_fingerprint.is_empty());
assert_eq!(verification.files.len(), 3);
Ok(())
}
#[test]
fn a_tampered_file_fails_naming_that_file() -> Result<(), Box<dyn std::error::Error>> {
let dir = assert_fs::TempDir::new()?;
seal_into(dir.path())?;
std::fs::write(dir.path().join("report.md"), b"# report (edited)\n")?;
let verification = verify_bundle(dir.path(), &key_dir().join("cnf-signing.pub.asc"))?;
assert!(!verification.is_clean());
let findings = verification.findings();
assert_eq!(findings.len(), 1, "{findings:?}");
assert!(
findings
.first()
.is_some_and(|f| f.starts_with("report.md: digest mismatch")),
"{findings:?}"
);
Ok(())
}
#[test]
fn a_missing_file_fails_naming_that_file() -> Result<(), Box<dyn std::error::Error>> {
let dir = assert_fs::TempDir::new()?;
seal_into(dir.path())?;
std::fs::remove_file(dir.path().join("verdicts.json"))?;
let verification = verify_bundle(dir.path(), &key_dir().join("cnf-signing.pub.asc"))?;
assert!(!verification.is_clean());
assert_eq!(
verification.findings(),
vec!["verdicts.json: named by the manifest, absent".to_owned()]
);
Ok(())
}
#[test]
fn a_tampered_manifest_fails_the_signature() -> Result<(), Box<dyn std::error::Error>> {
let dir = assert_fs::TempDir::new()?;
seal_into(dir.path())?;
let mut manifest_text = std::fs::read_to_string(dir.path().join(MANIFEST_FILE))?;
manifest_text = manifest_text.replace("veredictum", "veredictum-forged");
std::fs::write(dir.path().join(MANIFEST_FILE), &manifest_text)?;
let verification = verify_bundle(dir.path(), &key_dir().join("cnf-signing.pub.asc"))?;
assert_eq!(verification.signature, SignatureOutcome::Rejected);
assert!(!verification.is_clean());
Ok(())
}
#[test]
fn a_wrong_public_key_fails() -> Result<(), Box<dyn std::error::Error>> {
let dir = assert_fs::TempDir::new()?;
seal_into(dir.path())?;
let other = dir.path().join("other.pub.asc");
std::fs::write(&other, other_public_key()?)?;
let verification = verify_bundle(dir.path(), &other)?;
assert_eq!(verification.signature, SignatureOutcome::Rejected);
assert!(!verification.is_clean());
assert!(
verification
.files
.iter()
.all(|file| file.outcome == DigestOutcome::Matches)
);
Ok(())
}
#[test]
fn a_signature_over_the_manifest_verifies_directly() -> Result<(), Box<dyn std::error::Error>> {
let rendered = manifest(&documents())?;
let armored = sign(rendered.as_bytes(), &secret_key()?, None)?;
assert!(armored.starts_with("-----BEGIN PGP SIGNATURE-----"));
let outcome = verify(rendered.as_bytes(), &armored, &public_key()?)?;
let SignatureOutcome::Accepted(record) = outcome else {
panic!("the committed keypair must verify its own signature");
};
assert!(record.signed_at.as_second() > 0);
assert_eq!(
verify(b"tampered", &armored, &public_key()?)?,
SignatureOutcome::Rejected
);
Ok(())
}
#[test]
fn a_malformed_public_key_is_an_error_not_a_finding() -> Result<(), Box<dyn std::error::Error>>
{
let rendered = manifest(&documents())?;
let armored = sign(rendered.as_bytes(), &secret_key()?, None)?;
assert!(matches!(
verify(rendered.as_bytes(), &armored, "not a key"),
Err(RecordError::PublicKey { .. })
));
Ok(())
}
#[test]
fn malformed_signing_inputs_are_typed_errors() -> Result<(), Box<dyn std::error::Error>> {
let rendered = manifest(&documents())?;
assert!(matches!(
sign(rendered.as_bytes(), "not a secret key", None),
Err(RecordError::SigningKey { .. })
));
assert!(matches!(
verify(rendered.as_bytes(), "not a signature", &public_key()?),
Err(RecordError::Signature { .. })
));
Ok(())
}
#[test]
fn a_certificate_without_a_signing_subkey_signs_with_its_primary_key()
-> Result<(), Box<dyn std::error::Error>> {
use pgp::composed::{KeyType, SecretKeyParamsBuilder};
let params = SecretKeyParamsBuilder::default()
.key_type(KeyType::Ed25519)
.can_sign(true)
.can_certify(true)
.primary_user_id("Veredictum Primary Only <primary@test.invalid>".to_owned())
.build()?;
let generated = params.generate(rand::rngs::OsRng)?;
let secret = generated.to_armored_string(None.into())?;
let public = generated.to_public_key().to_armored_string(None.into())?;
let rendered = manifest(&documents())?;
let armored = sign(rendered.as_bytes(), &secret, None)?;
let SignatureOutcome::Accepted(record) = verify(rendered.as_bytes(), &armored, &public)?
else {
panic!("a primary-key signature must verify against its own certificate")
};
assert!(!record.signer_fingerprint.is_empty());
Ok(())
}
#[test]
fn an_unreadable_bundle_file_is_its_own_finding() -> Result<(), Box<dyn std::error::Error>> {
let dir = assert_fs::TempDir::new()?;
seal_into(dir.path())?;
std::fs::remove_file(dir.path().join("verdicts.json"))?;
std::fs::create_dir_all(dir.path().join("verdicts.json"))?;
let verification = verify_bundle(dir.path(), &key_dir().join("cnf-signing.pub.asc"))?;
let findings = verification.findings();
assert_eq!(findings.len(), 1, "{findings:?}");
assert!(
findings
.first()
.is_some_and(|f| f.starts_with("verdicts.json: unreadable: ")),
"{findings:?}"
);
Ok(())
}
#[test]
fn a_bundle_without_a_manifest_names_the_path() -> Result<(), Box<dyn std::error::Error>> {
let dir = assert_fs::TempDir::new()?;
let error = verify_bundle(dir.path(), &key_dir().join("cnf-signing.pub.asc"))
.expect_err("an empty directory carries no manifest");
assert!(
matches!(&error, RecordError::Read { path, .. } if path.ends_with(MANIFEST_FILE)),
"{error}"
);
Ok(())
}
fn seal_bytes(dir: &Path, manifest_bytes: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
std::fs::write(dir.join(MANIFEST_FILE), manifest_bytes)?;
let armored = sign(manifest_bytes, &secret_key()?, None)?;
std::fs::write(dir.join(SIGNATURE_FILE), armored)?;
Ok(())
}
#[test]
fn a_manifest_that_is_not_the_manifest_shape_is_a_parse_error()
-> Result<(), Box<dyn std::error::Error>> {
let dir = assert_fs::TempDir::new()?;
seal_bytes(dir.path(), b"not a manifest\n")?;
let error = verify_bundle(dir.path(), &key_dir().join("cnf-signing.pub.asc"))
.expect_err("the manifest does not parse");
assert!(
matches!(&error, RecordError::ManifestParse { path, .. } if path.ends_with(MANIFEST_FILE)),
"{error}"
);
Ok(())
}
#[test]
fn a_forged_manifest_entry_escaping_the_bundle_is_refused_at_verify()
-> Result<(), Box<dyn std::error::Error>> {
let dir = assert_fs::TempDir::new()?;
let forged = serde_json::json!({
"instrument": { "name": INSTRUMENT_NAME, "version": env!("CARGO_PKG_VERSION") },
"digest_algorithm": "sha256",
"files": { "../outside.json": hex(&Sha256::digest(b"{}")) }
});
seal_bytes(dir.path(), format!("{forged}\n").as_bytes())?;
let error = verify_bundle(dir.path(), &key_dir().join("cnf-signing.pub.asc"))
.expect_err("an escaping name is refused");
assert!(
matches!(&error, RecordError::UnsafeFileName { name } if name == "../outside.json"),
"{error}"
);
Ok(())
}
}