//! Execution receipts (spec §14.6): what a runner ran, in what, on which //! inputs and with what result. A receipt names no state, so the same //! receipt serves every state whose scope is unchanged; an attestation of //! kind `receipt`, signed by the runner, binds it to the state it ran on. use crate::attest::{Attest, AttestBody}; use crate::cbor::{self, Value}; use crate::hash::{hash_object, ObjectRef, ObjectType}; use crate::identity::{verify, Domain, Identity, SecretKey, Signature}; use crate::objects::{array, bytes, check_version, field, map, oref, text, uint, Hlc, ObjectError}; #[derive(Clone, Debug, PartialEq, Eq)] pub struct Receipt { pub job_name: String, /// The pipeline definition the job came from. pub pipeline_hash: ObjectRef, /// BLAKE3 over the sorted (path, content) of the files the job's scope /// matches; the whole tree when it has no scope. pub scope_tree_hash: ObjectRef, /// Artifacts and other inputs consumed beyond the tree. pub inputs: Vec, /// What the job ran in: isolation tier, rootfs, argv and environment. pub environment: ObjectRef, /// Blobs the job produced. pub outputs: Vec, pub exit_code: u32, pub duration_ms: u64, } fn refs(v: &[ObjectRef]) -> Value { Value::Array(v.iter().map(|h| Value::Bytes(h.0.to_vec())).collect()) } fn refs_from(v: &Value, what: &str) -> Result, ObjectError> { array(v, what)?.iter().map(|h| oref(h, what)).collect() } impl Receipt { pub fn passed(&self) -> bool { self.exit_code == 0 } pub fn to_value(&self) -> Value { Value::map([ (0, Value::Uint(1)), (1, Value::Text(self.job_name.clone())), (2, Value::Bytes(self.pipeline_hash.0.to_vec())), (3, Value::Bytes(self.scope_tree_hash.0.to_vec())), (4, refs(&self.inputs)), (5, Value::Bytes(self.environment.0.to_vec())), (6, refs(&self.outputs)), (7, Value::Uint(self.exit_code as u64)), (8, Value::Uint(self.duration_ms)), ]) } pub fn encode(&self) -> Vec { cbor::encode(&self.to_value()) } pub fn hash(&self) -> ObjectRef { hash_object(ObjectType::Receipt, &self.encode()) } pub fn decode(b: &[u8]) -> Result { let v = cbor::decode(b)?; let m = map(&v, "receipt")?; check_version(m)?; let exit_code = uint(field(m, 7, "exit_code")?, "exit_code")?; Ok(Receipt { job_name: text(field(m, 1, "job_name")?, "job_name")?.to_owned(), pipeline_hash: oref(field(m, 2, "pipeline_hash")?, "pipeline_hash")?, scope_tree_hash: oref(field(m, 3, "scope_tree_hash")?, "scope_tree_hash")?, inputs: refs_from(field(m, 4, "inputs")?, "inputs")?, environment: oref(field(m, 5, "environment")?, "environment")?, outputs: refs_from(field(m, 6, "outputs")?, "outputs")?, exit_code: u32::try_from(exit_code) .map_err(|_| ObjectError::Schema("exit_code: expected u32".into()))?, duration_ms: uint(field(m, 8, "duration_ms")?, "duration_ms")?, }) } } #[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] pub enum ReceiptError { #[error("the attestation is not of kind receipt")] NotReceipt, #[error("the attestation names a different receipt")] WrongReceipt, #[error("no signature by the runner {0}")] Unsigned(String), #[error("the runner's signature does not verify: {0}")] BadSignature(String), #[error("its certificate does not hold: {0}")] Certificate(String), #[error("{0}")] Schema(String), } impl From for ReceiptError { fn from(e: ObjectError) -> Self { ReceiptError::Schema(e.to_string()) } } /// A receipt as a runner hands it over: the receipt, the attestation /// binding it to a state, the runner's signature over that attestation, and /// the runner's identity, so it verifies with nothing else. A runner that /// signs with a short-lived key (keyless, oxygenhub#241) adds that key's /// certificate and its issuer's identity. #[derive(Clone, Debug, PartialEq, Eq)] pub struct SignedReceipt { pub receipt: Receipt, pub attest: Attest, pub sigs: Vec, pub runner: Identity, pub certificate: Option, } /// A short-lived signing key's certificate, with the identity of the key /// that issued it (spec §14.7). #[derive(Clone, Debug, PartialEq, Eq)] pub struct CertifiedBy { pub cert: crate::cert::SignedCertificate, pub issuer: Identity, } impl SignedReceipt { /// Bind `receipt` to `state` of repository `repo` for run `run` (a URL /// or other name of the run), signed by `runner` with `sk`. pub fn sign( receipt: Receipt, repo: [u8; 16], state: ObjectRef, run: &str, runner: &Identity, sk: &SecretKey, ) -> SignedReceipt { let attest = Attest { repo, hlc: Hlc::now(), body: AttestBody::Receipt { receipt: receipt.hash(), state, run: run.into(), }, }; let sig = sk.sign(Domain::Attest, &runner.hash(), &attest.hash()); SignedReceipt { receipt, attest, sigs: vec![sig], runner: runner.clone(), certificate: None, } } /// The same, signed by a short-lived key whose certificate `cert` /// `issuer` issued: the certificate rides along. pub fn with_certificate( mut self, cert: crate::cert::SignedCertificate, issuer: Identity, ) -> SignedReceipt { self.certificate = Some(CertifiedBy { cert, issuer }); self } /// The state the receipt was made on. pub fn state(&self) -> ObjectRef { match &self.attest.body { AttestBody::Receipt { state, .. } => *state, _ => ObjectRef([0; 32]), } } pub fn run(&self) -> &str { match &self.attest.body { AttestBody::Receipt { run, .. } => run, _ => "", } } /// The attestation is a receipt attestation for this receipt, and the /// runner signed it. Whether to trust the runner is the reader's call. pub fn verify(&self) -> Result<(), ReceiptError> { let AttestBody::Receipt { receipt, .. } = &self.attest.body else { return Err(ReceiptError::NotReceipt); }; if *receipt != self.receipt.hash() { return Err(ReceiptError::WrongReceipt); } let key = self.runner.hash(); let target = self.attest.hash(); let sig = self .sigs .iter() .find(|s| s.key == key) .ok_or_else(|| ReceiptError::Unsigned(key.short(12)))?; verify(&self.runner, sig, Domain::Attest, &target) .map_err(|e| ReceiptError::BadSignature(e.to_string()))?; if let Some(c) = &self.certificate { c.cert .verify(&c.issuer) .map_err(|e| ReceiptError::Certificate(e.to_string()))?; if c.cert.cert.key_id() != key { return Err(ReceiptError::Certificate( "it certifies another key than the one that signed".into(), )); } // The attestation's own time is the signer's claim: a host // checks the time it accepted the receipt as well. if !c .cert .cert .covers(crate::identity::Scope::SEAL, self.attest.hlc.wall_ms) { return Err(ReceiptError::Certificate( "the receipt was signed outside the certificate's window, or it gives no seal scope".into(), )); } } Ok(()) } /// `[receipt, attest, [Signature], identity]`, each object as its bytes. /// `[receipt, attest, [Signature], identity]`, and /// `[certificate, issuer identity]` after them when a short-lived key /// signed. pub fn encode(&self) -> Vec { let mut parts = vec![ Value::Bytes(self.receipt.encode()), Value::Bytes(self.attest.encode()), Value::Array(self.sigs.iter().map(|s| s.to_value()).collect()), Value::Bytes(self.runner.encode()), ]; if let Some(c) = &self.certificate { parts.push(Value::Array(vec![ Value::Bytes(c.cert.encode()), Value::Bytes(c.issuer.encode()), ])); } cbor::encode(&Value::Array(parts)) } pub fn decode(b: &[u8]) -> Result { let v = cbor::decode(b).map_err(ObjectError::from)?; let a = array(&v, "signed receipt")?; if a.len() != 4 && a.len() != 5 { return Err(ReceiptError::Schema( "signed receipt: expected [receipt, attest, sigs, identity, (certificate, issuer)]" .into(), )); } let certificate = match a.get(4) { None => None, Some(c) => { let c = array(c, "certificate")?; if c.len() != 2 { return Err(ReceiptError::Schema( "signed receipt: expected [certificate, issuer identity]".into(), )); } Some(CertifiedBy { cert: crate::cert::SignedCertificate::decode(bytes(&c[0], "certificate")?) .map_err(|e| ReceiptError::Certificate(e.to_string()))?, issuer: Identity::decode(bytes(&c[1], "issuer")?)?, }) } }; Ok(SignedReceipt { receipt: Receipt::decode(bytes(&a[0], "receipt")?)?, attest: Attest::decode(bytes(&a[1], "attest")?)?, sigs: array(&a[2], "sigs")? .iter() .map(Signature::from_value) .collect::>()?, runner: Identity::decode(bytes(&a[3], "identity")?)?, certificate, }) } } #[cfg(test)] mod tests { use super::*; pub(crate) fn sample() -> Receipt { Receipt { job_name: "test".into(), pipeline_hash: ObjectRef([1; 32]), scope_tree_hash: ObjectRef([2; 32]), inputs: vec![], environment: ObjectRef([3; 32]), outputs: vec![ObjectRef([4; 32])], exit_code: 0, duration_ms: 1500, } } #[test] fn receipts_round_trip_and_hash_as_receipt_objects() { let r = sample(); assert_eq!(Receipt::decode(&r.encode()).unwrap(), r); let mut h = blake3::Hasher::new(); h.update(format!("oxy1 receipt {}\0", r.encode().len()).as_bytes()); h.update(&r.encode()); assert_eq!( r.hash().0, *h.finalize().as_bytes(), "OxygenFlow's receipt hash" ); assert!(r.passed()); } #[test] fn a_signed_receipt_verifies_and_tampering_does_not() { let sk = SecretKey::generate(); let runner = sk.identity("earth-1", "runner@earth", 1); let sr = SignedReceipt::sign( sample(), [7; 16], ObjectRef([9; 32]), "andy/demo#3", &runner, &sk, ); sr.verify().unwrap(); let back = SignedReceipt::decode(&sr.encode()).unwrap(); assert_eq!(back, sr); assert_eq!(back.state(), ObjectRef([9; 32])); assert_eq!(back.run(), "andy/demo#3"); let mut forged = sr.clone(); forged.receipt.exit_code = 0; forged.receipt.duration_ms = 1; assert_eq!(forged.verify(), Err(ReceiptError::WrongReceipt)); let mut moved = sr.clone(); if let AttestBody::Receipt { state, .. } = &mut moved.attest.body { *state = ObjectRef([8; 32]); } assert!(matches!(moved.verify(), Err(ReceiptError::BadSignature(_)))); let other = SecretKey::generate().identity("mallory", "m@x", 1); let mut swapped = sr.clone(); swapped.runner = other; assert!(matches!(swapped.verify(), Err(ReceiptError::Unsigned(_)))); } /// A receipt signed with a short-lived key carries its certificate /// (oxygenhub#241): it verifies, round-trips, and is refused when the /// certificate is another key's, gives no seal scope, or does not cover /// when it was signed. #[test] fn a_receipt_by_a_certified_key_carries_and_checks_its_certificate() { use crate::cert::{Certificate, SignedCertificate}; use crate::identity::Scope; let hub_sk = SecretKey::generate(); let hub = hub_sk.identity("oxygenhub.io", "hub@oxygenhub.io", 1); let job_sk = SecretKey::generate(); let job = job_sk.identity("job 7 test", "flow@oxygenhub.io", 1); let certify = |key: &Identity, scope: Scope, from: u64, to: u64| { SignedCertificate::sign( Certificate { key: key.clone(), issuer: hub.hash(), subject: "repo:andy/demo:bookmark:main".into(), claims: vec![("job".into(), "test".into())], scope, not_before: from, expires: to, }, &hub, &hub_sk, ) }; let sr = SignedReceipt::sign( sample(), [7; 16], ObjectRef([9; 32]), "andy/demo#7", &job, &job_sk, ); let now = sr.attest.hlc.wall_ms; let good = sr.clone().with_certificate( certify(&job, Scope::SEAL, now - 1000, now + 600_000), hub.clone(), ); good.verify().unwrap(); let back = SignedReceipt::decode(&good.encode()).unwrap(); assert_eq!(back, good); assert_eq!( SignedReceipt::decode(&sr.encode()).unwrap().certificate, None, "a plain receipt reads as before" ); let other = SecretKey::generate().identity("other", "o@x", 1); let wrong_key = sr.clone().with_certificate( certify(&other, Scope::SEAL, now - 1000, now + 600_000), hub.clone(), ); assert!( matches!(wrong_key.verify(), Err(ReceiptError::Certificate(m)) if m.contains("another key")) ); let no_seal = sr.clone().with_certificate( certify(&job, Scope::CHANGES, now - 1000, now + 600_000), hub.clone(), ); assert!(matches!( no_seal.verify(), Err(ReceiptError::Certificate(_)) )); let too_late = sr.clone().with_certificate( certify(&job, Scope::SEAL, now - 700_000, now - 100_000), hub.clone(), ); assert!( matches!(too_late.verify(), Err(ReceiptError::Certificate(m)) if m.contains("window")) ); let wrong_issuer = sr .clone() .with_certificate(certify(&job, Scope::SEAL, now - 1000, now + 600_000), other); assert!(matches!( wrong_issuer.verify(), Err(ReceiptError::Certificate(_)) )); } }