//! A container's file, checked from outside (`[decision]` 02.10, CH-5a; //! CH-5b): without the layer key, anyone holding the file tells whether it is //! the one the container wrote. //! //! | Check | Against what | //! |---|---| //! | the template identifier | recomputed from the release and the serial: `section_root(release, serial)` | //! | the container's post-quantum key | the issuer's signature on it, given at printing, under the issuer keys the verifier holds | //! | the file | the outer ML-DSA-65 signature by that key over the template identifier, the nonce and the ciphertext | //! //! The layout is `ksg-container-v2::store`'s, version 4, read here //! independently: a verifier does not run the container's code. use ksg_core_v2::crypto::hash::Hash; use ksg_core_v2::crypto::sign::{Alg, KeySet, Profile, PublicKey, Signature, SignatureSet}; use ksg_core_v2::doc::Serial; /// The file's first bytes. pub const MAGIC: &[u8] = b"ksg:store:v4"; /// Domain of the outer signature. const OUTER: &[u8] = b"ksg:store:v4:outer"; /// Domain of the issuer's word on the container's post-quantum key. const PQ_CERT: &[u8] = b"ksg:container-pq-key:v2"; const NONCE: usize = 12; /// What the verifier knows about the container's file without opening it — /// from the holder's `container.json`, every field checked here. #[derive(Debug, Clone)] pub struct FileClaim { /// The release the container was printed in. pub release: String, /// Its serial. pub serial: Serial, /// The container's post-quantum key. pub pq: PublicKey, /// The issuer's signature on it, given at printing. pub pq_cert: Signature, } impl FileClaim { /// The claim from a holder's `container.json`. /// /// # Errors /// /// A field is missing or is not what it should be. pub fn from_meta(json: &[u8]) -> Result { let v: serde_json::Value = serde_json::from_slice(json).map_err(|e| format!("container.json: {e}"))?; let field = |k: &str| v.get(k).cloned().ok_or(format!("container.json: no {k}")); Ok(Self { release: serde_json::from_value(field("emission")?).map_err(|e| e.to_string())?, serial: serde_json::from_value(field("serial")?).map_err(|e| e.to_string())?, pq: serde_json::from_value(field("pq")?).map_err(|e| e.to_string())?, pq_cert: serde_json::from_value(field("pq_cert")?).map_err(|e| e.to_string())?, }) } } fn ml_dsa_only() -> Profile { Profile { required_algs: [Alg::MlDsa65].into_iter().collect(), ..Profile::default() } } /// Checks a container's file against the claim and the issuer's keys. /// Returns the template identifier the file is bound to. /// /// # Errors /// /// What does not hold, named. pub fn verify_file(bytes: &[u8], claim: &FileClaim, issuer_keys: &KeySet) -> Result { let template = ksg_core_v2::section::section_root(&claim.release, claim.serial); if claim.pq.alg != Alg::MlDsa65 { return Err("the container's key is not an ML-DSA-65 key".into()); } SignatureSet::new(vec![claim.pq_cert.clone()]) .verify( &[PQ_CERT, template.as_bytes(), claim.pq.key.as_bytes()].concat(), issuer_keys, &Profile::default().with_ed25519(), ) .map_err(|e| format!("the issuer did not vouch for this key for this container: {e}"))?; let rest = bytes .strip_prefix(MAGIC) .ok_or("not a container file of version 4")?; let len_at = rest.len().checked_sub(4).ok_or("truncated")?; let (body, len) = rest.split_at(len_at); let len = u32::from_be_bytes(len.try_into().map_err(|_| "truncated")?) as usize; let sig_at = body.len().checked_sub(len).ok_or("truncated")?; let (body, sig) = body.split_at(sig_at); if body.len() < NONCE { return Err("truncated".into()); } let (nonce, sealed) = body.split_at(NONCE); if sig.is_empty() { return Err("the file carries no outer signature".into()); } let sig: Signature = serde_json::from_slice(sig).map_err(|_| "the outer signature is not one")?; let keys: KeySet = [claim.pq.clone()].into_iter().collect(); SignatureSet::new(vec![sig]) .verify( &[OUTER, template.as_bytes(), nonce, sealed].concat(), &keys, &ml_dsa_only(), ) .map_err(|e| format!("the outer signature does not hold: {e}"))?; Ok(template) }