use super::bundle::{
chain_link, chain_start, fingerprint, sha256_hex, Files, Manifest, SliceKind, ARTIFACTS, FORMAT,
};
use super::tsr;
use ed25519_dalek::{Signature, VerifyingKey};
use serde::Serialize;
#[derive(Default)]
pub struct VerifyOptions<'a> {
pub expected_public_key: Option<[u8; 32]>,
pub full_log: Option<&'a [u8]>,
pub require_timestamp: bool,
pub tsa_anchors: Option<&'a [Vec<u8>]>,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(tag = "code", rename_all = "kebab-case")]
pub enum Failure {
ManifestMissing,
ManifestMalformed {
detail: String,
},
UnsupportedFormat {
found: String,
},
SignatureMissing,
SignatureMalformed {
detail: String,
},
PublicKeyMalformed {
detail: String,
},
SignatureInvalid,
PublicKeyMismatch {
expected_fingerprint: String,
found_fingerprint: String,
},
ArtifactListMalformed {
detail: String,
},
FileMissing {
name: String,
},
FileSizeMismatch {
name: String,
expected: u64,
actual: u64,
},
FileHashMismatch {
name: String,
expected: String,
actual: String,
},
UnlistedFile {
name: String,
},
ChainMismatch {
name: String,
expected: String,
actual: String,
},
ChainHeadMismatch {
expected: String,
actual: String,
},
SliceRecordInconsistent {
detail: String,
},
EpochCountMismatch {
expected: usize,
actual: usize,
},
FullLogHashMismatch {
expected: String,
actual: String,
},
SliceNotFromFullLog,
TimestampMissing,
TimestampMalformed {
detail: String,
},
TimestampAuthorityInvalid {
detail: String,
},
TimestampImprintMismatch {
expected: String,
actual: String,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
Pass,
Fail,
Skipped,
}
#[derive(Clone, Debug, Serialize)]
pub struct Check {
pub name: &'static str,
pub status: Status,
pub detail: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct TimestampReport {
pub imprint_matches: bool,
pub gen_time: Option<String>,
pub authority_signature_verified: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct VerifyReport {
pub ok: bool,
pub failures: Vec<Failure>,
pub checks: Vec<Check>,
pub signature_valid: Option<bool>,
pub signer_fingerprint: Option<String>,
pub signer_pinned: bool,
pub engine_version: Option<String>,
pub timestamp: Option<TimestampReport>,
pub notes: Vec<String>,
}
pub fn verify_detached(public_key: &[u8; 32], msg: &[u8], sig: &[u8; 64]) -> Result<bool, String> {
let vk = VerifyingKey::from_bytes(public_key).map_err(|e| e.to_string())?;
Ok(vk.verify_strict(msg, &Signature::from_bytes(sig)).is_ok())
}
fn hex_fixed<const N: usize>(s: &str) -> Result<[u8; N], String> {
if !s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
return Err("not lower-case hex".into());
}
let v = hex::decode(s).map_err(|e| e.to_string())?;
<[u8; N]>::try_from(v.as_slice()).map_err(|_| format!("expected {N} bytes, found {}", v.len()))
}
pub fn attach_timestamp(
files: &mut Files,
token: &[u8],
replace: bool,
) -> Result<Vec<String>, String> {
if files.contains_key("timestamp.tsr") && !replace {
return Err("the pack already has a timestamp token; pass --replace to replace it".into());
}
let mut with = files.clone();
with.insert("timestamp.tsr".into(), token.to_vec());
let rep = verify_bundle(&with, &VerifyOptions::default());
if !rep.ok {
return Err(format!(
"not attached: the pack with this token does not verify ({})",
serde_json::to_string(&rep.failures).unwrap_or_default()
));
}
*files = with;
Ok(rep.notes)
}
pub fn verify_bundle(files: &Files, opts: &VerifyOptions<'_>) -> VerifyReport {
let mut r = VerifyReport {
ok: false,
failures: Vec::new(),
checks: Vec::new(),
signature_valid: None,
signer_fingerprint: None,
signer_pinned: opts.expected_public_key.is_some(),
engine_version: None,
timestamp: None,
notes: Vec::new(),
};
fn close(r: &mut VerifyReport, name: &'static str, from: usize, ok_detail: &str) {
let n = r.failures.len() - from;
r.checks.push(Check {
name,
status: if n == 0 { Status::Pass } else { Status::Fail },
detail: if n == 0 {
ok_detail.to_string()
} else {
format!("{n} failure(s)")
},
});
}
let Some(mbytes) = files.get("manifest.json") else {
r.failures.push(Failure::ManifestMissing);
r.checks.push(Check {
name: "manifest",
status: Status::Fail,
detail: "manifest.json is absent".into(),
});
return r;
};
let from = r.failures.len();
let manifest: Option<Manifest> = match serde_json::from_slice(mbytes) {
Ok(m) => Some(m),
Err(e) => {
r.failures.push(Failure::ManifestMalformed {
detail: e.to_string(),
});
None
}
};
if let Some(m) = &manifest {
r.engine_version = Some(m.engine_version.clone());
if m.format != FORMAT {
r.failures.push(Failure::UnsupportedFormat {
found: m.format.clone(),
});
}
}
close(&mut r, "manifest", from, "parses, known format");
let from = r.failures.len();
let named_key: Option<[u8; 32]> = match &manifest {
Some(m) => match hex_fixed::<32>(&m.signer.public_key) {
Ok(k) => {
r.signer_fingerprint = Some(fingerprint(&k));
if m.signer.fingerprint != fingerprint(&k) {
r.failures.push(Failure::PublicKeyMalformed {
detail: "fingerprint does not match the public key".into(),
});
}
Some(k)
}
Err(e) => {
r.failures.push(Failure::PublicKeyMalformed { detail: e });
None
}
},
None => None,
};
if let (Some(pin), Some(named)) = (opts.expected_public_key, named_key) {
if pin != named {
r.failures.push(Failure::PublicKeyMismatch {
expected_fingerprint: fingerprint(&pin),
found_fingerprint: fingerprint(&named),
});
}
}
let key_bytes = opts.expected_public_key.or(named_key);
match files.get("manifest.sig") {
None => r.failures.push(Failure::SignatureMissing),
Some(sb) => match (
match String::from_utf8_lossy(sb).strip_suffix('\n') {
Some(h) => hex_fixed::<64>(h),
None => Err("the signature file does not end with a newline".into()),
},
key_bytes,
) {
(Err(e), _) => r.failures.push(Failure::SignatureMalformed { detail: e }),
(Ok(sig), Some(k)) => match verify_detached(&k, mbytes, &sig) {
Err(detail) => r.failures.push(Failure::PublicKeyMalformed { detail }),
Ok(false) => {
r.signature_valid = Some(false);
r.failures.push(Failure::SignatureInvalid);
}
Ok(true) => r.signature_valid = Some(true),
},
(Ok(_), None) => r.notes.push(
"the signature could not be checked because the manifest names no usable key; supply the signer's key".into(),
),
},
}
let sig_detail = if r.signer_pinned {
"valid for the key you supplied"
} else {
"valid for the key the manifest names"
};
close(&mut r, "signature", from, sig_detail);
if !r.signer_pinned {
r.notes.push(format!(
"signer not pinned: the signature proves the pack is intact against key {}, which the pack names itself. Compare that fingerprint with one you got from the signer, or pass the key.",
r.signer_fingerprint.as_deref().unwrap_or("(none)")
));
}
let Some(m) = manifest else {
r.ok = false;
return r;
};
let from = r.failures.len();
let listed_ok = m.artifacts.len() == ARTIFACTS.len()
&& m.artifacts
.iter()
.zip(ARTIFACTS)
.all(|(a, (n, _))| a.name == n);
if !listed_ok {
r.failures.push(Failure::ArtifactListMalformed {
detail: format!("expected {:?}", ARTIFACTS.map(|(n, _)| n)),
});
}
for a in &m.artifacts {
match files.get(&a.name) {
None => r.failures.push(Failure::FileMissing {
name: a.name.clone(),
}),
Some(b) => {
if b.len() as u64 != a.bytes {
r.failures.push(Failure::FileSizeMismatch {
name: a.name.clone(),
expected: a.bytes,
actual: b.len() as u64,
});
}
let h = sha256_hex(b);
if h != a.sha256 {
r.failures.push(Failure::FileHashMismatch {
name: a.name.clone(),
expected: a.sha256.clone(),
actual: h,
});
}
}
}
}
close(
&mut r,
"files",
from,
"every listed file present with the recorded size and SHA-256",
);
let from = r.failures.len();
for name in files.keys() {
let known = matches!(
name.as_str(),
"manifest.json" | "manifest.sig" | "timestamp.tsr"
) || m.artifacts.iter().any(|a| &a.name == name);
if !known {
r.failures
.push(Failure::UnlistedFile { name: name.clone() });
}
}
close(
&mut r,
"no-extra-files",
from,
"nothing in the pack is outside the manifest",
);
let from = r.failures.len();
let mut prev = chain_start();
let mut chain_ok = true;
for a in &m.artifacts {
match hex_fixed::<32>(&a.sha256) {
Ok(d) => {
prev = chain_link(&prev, &a.name, &d);
if hex::encode(prev) != a.link {
r.failures.push(Failure::ChainMismatch {
name: a.name.clone(),
expected: hex::encode(prev),
actual: a.link.clone(),
});
}
}
Err(e) => {
chain_ok = false;
r.failures.push(Failure::ManifestMalformed {
detail: format!("{}: {e}", a.name),
});
}
}
}
if chain_ok && hex::encode(prev) != m.chain_head {
r.failures.push(Failure::ChainHeadMismatch {
expected: hex::encode(prev),
actual: m.chain_head.clone(),
});
}
close(
&mut r,
"hash-chain",
from,
"every link and the head recompute",
);
let from = r.failures.len();
let s = &m.log.slice;
let mut bad = |d: String| {
r.failures
.push(Failure::SliceRecordInconsistent { detail: d })
};
if s.start > s.end || s.end > m.log.full_bytes {
bad(format!(
"range {}..{} is not inside a log of {} bytes",
s.start, s.end, m.log.full_bytes
));
}
if let Some(a) = m.artifacts.iter().find(|a| a.name == "log-slice.bin") {
if a.sha256 != s.sha256 {
bad("slice SHA-256 differs from the artifact entry".into());
}
if a.bytes != s.end.saturating_sub(s.start) {
bad("slice length differs from its range".into());
}
}
if s.kind == SliceKind::WholeLog
&& (s.start != 0 || s.end != m.log.full_bytes || s.sha256 != m.log.full_sha256)
{
bad("a whole-log slice must be the full log".into());
}
if let Some(eb) = files.get("epochs.json") {
if let Ok(v) = serde_json::from_slice::<Vec<serde_json::Value>>(eb) {
if v.len() != m.epochs_in_window {
r.failures.push(Failure::EpochCountMismatch {
expected: m.epochs_in_window,
actual: v.len(),
});
}
}
}
close(
&mut r,
"slice-and-epochs",
from,
"slice record and epoch count agree",
);
match opts.full_log {
None => r.checks.push(Check {
name: "full-log",
status: Status::Skipped,
detail: "no full log supplied".into(),
}),
Some(full) => {
let from = r.failures.len();
let h = sha256_hex(full);
if h != m.log.full_sha256 {
r.failures.push(Failure::FullLogHashMismatch {
expected: m.log.full_sha256.clone(),
actual: h,
});
} else if let Some(slice) = files.get("log-slice.bin") {
let in_range = s.start <= s.end && s.end <= full.len() as u64;
if !in_range || full[s.start as usize..s.end as usize] != slice[..] {
r.failures.push(Failure::SliceNotFromFullLog);
}
}
close(
&mut r,
"full-log",
from,
"hash matches and the slice is that range of it",
);
}
}
match files.get("timestamp.tsr") {
None if opts.require_timestamp => {
r.failures.push(Failure::TimestampMissing);
r.checks.push(Check {
name: "timestamp",
status: Status::Fail,
detail: "a timestamp token was required and the pack has none".into(),
});
}
None => {
r.notes.push(
"no timestamp token: a token sits beside the signed manifest, so its absence cannot show whether one was ever attached; pass --require-timestamp to insist on one".into(),
);
r.checks.push(Check {
name: "timestamp",
status: Status::Skipped,
detail: "no timestamp token in the pack".into(),
})
}
Some(tok) => {
let from = r.failures.len();
match tsr::parse_token(tok) {
Err(e) => {
r.failures.push(Failure::TimestampMalformed { detail: e });
r.timestamp = Some(TimestampReport {
imprint_matches: false,
gen_time: None,
authority_signature_verified: false,
});
}
Ok(info) => {
let want = tsr::imprint_of(&info.hash_alg, mbytes).unwrap_or_default();
let matches = want == info.imprint;
if !matches {
r.failures.push(Failure::TimestampImprintMismatch {
expected: want,
actual: info.imprint.clone(),
});
}
let authority = opts
.tsa_anchors
.map(|a| crate::evidence::tsa::verify_token(tok, mbytes, a, &[]));
match &authority {
None => r.notes.push(format!(
"timestamp token states {}; its imprint {} manifest.json. Timestamp authority signature not verified by Kshana: check it with `openssl ts -verify`.",
info.gen_time,
if matches { "matches" } else { "does NOT match" }
)),
Some(v) => {
if !v.pass {
r.failures.push(Failure::TimestampAuthorityInvalid {
detail: v.reason.clone(),
});
}
r.notes.push(format!(
"timestamp token states {}; its imprint {} manifest.json; the timestamp authority's signature and chain {} against the anchors given.",
info.gen_time,
if matches { "matches" } else { "does NOT match" },
if v.pass { "verified" } else { "did NOT verify" }
));
}
}
r.timestamp = Some(TimestampReport {
imprint_matches: matches,
gen_time: Some(info.gen_time),
authority_signature_verified: authority.is_some_and(|v| v.pass),
});
}
}
close(
&mut r,
"timestamp",
from,
"token parses and its imprint is the hash of manifest.json",
);
}
}
r.ok = r.failures.is_empty();
r
}
#[cfg(test)]
mod tests {
use super::super::bundle::{create_bundle, EvidenceInput, Window};
use super::super::tsr::test_token::token;
use super::*;
fn pack() -> Files {
let log = b"synthetic log bytes\n".repeat(20);
let input = EvidenceInput {
title: "t",
engine_version: "0.0.0-test",
log_format: "nmea",
log_file_name: "dir/x.nmea",
log_bytes: &log,
start_label: None,
slice: None,
window: Window {
from_s: 0.0,
to_s: 2.0,
},
config: serde_json::json!({"cn0_drop_db": 6.0}),
epochs: vec![serde_json::json!({"t_s": 1.0, "state": "nominal", "alarms": []})],
created_utc: None,
};
create_bundle(&input, &[9u8; 32], None).unwrap()
}
fn stamped(files: &mut Files, digest_of: &[u8]) {
let d = <sha2::Sha256 as sha2::Digest>::digest(digest_of);
files.insert("timestamp.tsr".into(), token(&d, "20260102030405Z", true));
}
#[test]
fn anchors_make_the_authority_checked() {
let mut f = pack();
let m = f["manifest.json"].clone();
stamped(&mut f, &m);
let anchors = crate::evidence::tsa::pem_certificates(include_str!(
"../../tests/fixtures/evidence_tsa/certs/root-rsa.pem"
))
.unwrap();
let r = verify_bundle(
&f,
&VerifyOptions {
tsa_anchors: Some(&anchors),
..Default::default()
},
);
let t = r.timestamp.as_ref().unwrap();
assert!(t.imprint_matches && !t.authority_signature_verified);
assert!(r
.failures
.iter()
.any(|x| matches!(x, Failure::TimestampAuthorityInvalid { .. })));
let plain = verify_bundle(&f, &VerifyOptions::default());
assert!(!plain
.failures
.iter()
.any(|x| matches!(x, Failure::TimestampAuthorityInvalid { .. })));
}
#[test]
fn matching_token_binds_and_says_authority_is_unchecked() {
let mut f = pack();
let m = f["manifest.json"].clone();
stamped(&mut f, &m);
let r = verify_bundle(&f, &VerifyOptions::default());
assert!(r.ok, "{:?}", r.failures);
let t = r.timestamp.unwrap();
assert!(t.imprint_matches && !t.authority_signature_verified);
assert_eq!(t.gen_time.as_deref(), Some("2026-01-02T03:04:05Z"));
assert!(r
.notes
.iter()
.any(|n| n.contains("Timestamp authority signature not verified by Kshana")));
}
#[test]
fn token_over_something_else_fails_with_imprint_mismatch() {
let mut f = pack();
stamped(&mut f, b"not the manifest");
let r = verify_bundle(&f, &VerifyOptions::default());
assert!(matches!(
r.failures.as_slice(),
[Failure::TimestampImprintMismatch { .. }]
));
}
#[test]
fn garbage_token_fails_as_malformed() {
let mut f = pack();
f.insert("timestamp.tsr".into(), b"junk".to_vec());
let r = verify_bundle(&f, &VerifyOptions::default());
assert!(matches!(
r.failures.as_slice(),
[Failure::TimestampMalformed { .. }]
));
}
#[test]
fn editing_the_manifest_after_stamping_breaks_signature_and_imprint() {
let mut f = pack();
let m = f["manifest.json"].clone();
stamped(&mut f, &m);
let at = String::from_utf8_lossy(&m).find("\"t\"").unwrap() + 1;
f.get_mut("manifest.json").unwrap()[at] = b'u';
let r = verify_bundle(&f, &VerifyOptions::default());
assert!(r.failures.contains(&Failure::SignatureInvalid));
assert!(r
.failures
.iter()
.any(|x| matches!(x, Failure::TimestampImprintMismatch { .. })));
}
#[test]
fn a_stripped_token_is_noticed_only_when_one_is_required() {
let mut f = pack();
let m = f["manifest.json"].clone();
stamped(&mut f, &m);
assert!(verify_bundle(&f, &VerifyOptions::default()).ok);
f.remove("timestamp.tsr");
let r = verify_bundle(&f, &VerifyOptions::default());
assert!(r.ok);
assert!(r.notes.iter().any(|n| n.contains("--require-timestamp")));
let r = verify_bundle(
&f,
&VerifyOptions {
require_timestamp: true,
..Default::default()
},
);
assert_eq!(r.failures, vec![Failure::TimestampMissing]);
}
#[test]
fn a_replaced_token_over_other_bytes_is_caught() {
let mut f = pack();
let m = f["manifest.json"].clone();
stamped(&mut f, &m);
stamped(&mut f, b"a different document");
let r = verify_bundle(&f, &VerifyOptions::default());
assert!(matches!(
r.failures.as_slice(),
[Failure::TimestampImprintMismatch { .. }]
));
}
#[test]
fn creation_accepts_only_a_token_over_the_same_manifest() {
let input = || EvidenceInput {
title: "t",
engine_version: "0.0.0-test",
log_format: "nmea",
log_file_name: "x.nmea",
log_bytes: b"abc",
start_label: None,
slice: None,
window: Window {
from_s: 0.0,
to_s: 1.0,
},
config: serde_json::json!({}),
epochs: vec![serde_json::json!({"t_s": 0.0})],
created_utc: None,
};
let plain = create_bundle(&input(), &[9u8; 32], None).unwrap();
let d = <sha2::Sha256 as sha2::Digest>::digest(&plain["manifest.json"]);
let good = token(&d, "20260102030405Z", true);
let stamped = create_bundle(&input(), &[9u8; 32], Some(&good)).unwrap();
assert!(verify_bundle(&stamped, &VerifyOptions::default()).ok);
let other = token(&[0u8; 32], "20260102030405Z", true);
assert!(create_bundle(&input(), &[9u8; 32], Some(&other)).is_err());
}
}