//! 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("{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. #[derive(Clone, Debug, PartialEq, Eq)] pub struct SignedReceipt { pub receipt: Receipt, pub attest: Attest, pub sigs: Vec, pub runner: 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(), } } /// 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())) } /// `[receipt, attest, [Signature], identity]`, each object as its bytes. pub fn encode(&self) -> Vec { cbor::encode(&Value::Array(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()), ])) } 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 { return Err(ReceiptError::Schema( "signed receipt: expected [receipt, attest, sigs, identity]".into(), )); } 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")?)?, }) } } #[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(_)))); } }