//! A person's approval of one job of one run, signed (oxygenhub#235): the //! approval attestation, the signer's signature over it, and the signer's //! identity, so it verifies with nothing else. The hub decides whether the //! signer may approve; the signature makes the decision theirs, and binds //! it to exactly that job, state, plan and environment. use crate::attest::{Attest, AttestBody}; use crate::cbor::{self, Value}; use crate::hash::ObjectRef; use crate::identity::{verify, Domain, Identity, SecretKey, Signature}; use crate::objects::{array, bytes, Hlc, ObjectError}; /// What is approved. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Statement { pub repo: [u8; 16], /// The run, as its URL on the hub. pub run: String, pub job: String, /// The job's hash with its sandbox (OxygenFlow spec 6.2). pub job_hash: ObjectRef, pub state: ObjectRef, /// The blobs the job takes as inputs (a plan), any order. pub inputs: Vec, pub environment: String, pub approve: bool, } #[derive(Debug, thiserror::Error)] pub enum ApprovalError { #[error("the attestation is not an approval")] NotApproval, #[error("no signature by the signer {0}")] Unsigned(String), #[error("the signature does not verify: {0}")] BadSignature(String), #[error("{0}")] Schema(String), } impl From for ApprovalError { fn from(e: ObjectError) -> Self { ApprovalError::Schema(e.to_string()) } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct SignedApproval { pub attest: Attest, pub sigs: Vec, pub signer: Identity, } impl SignedApproval { pub fn sign(s: &Statement, signer: &Identity, sk: &SecretKey) -> SignedApproval { let mut inputs = s.inputs.clone(); inputs.sort(); inputs.dedup(); let attest = Attest { repo: s.repo, hlc: Hlc::now(), body: AttestBody::Approval { run: s.run.clone(), job: s.job.clone(), job_hash: s.job_hash, state: s.state, inputs, environment: s.environment.clone(), approve: s.approve, }, }; let sig = sk.sign(Domain::Attest, &signer.hash(), &attest.hash()); SignedApproval { attest, sigs: vec![sig], signer: signer.clone(), } } /// What was approved, read back from the attestation. pub fn statement(&self) -> Result { let AttestBody::Approval { run, job, job_hash, state, inputs, environment, approve, } = &self.attest.body else { return Err(ApprovalError::NotApproval); }; Ok(Statement { repo: self.attest.repo, run: run.clone(), job: job.clone(), job_hash: *job_hash, state: *state, inputs: inputs.clone(), environment: environment.clone(), approve: *approve, }) } /// It is an approval and the signer signed it. Whether the signer may /// approve is the reader's call. pub fn verify(&self) -> Result<(), ApprovalError> { self.statement()?; let key = self.signer.hash(); let sig = self .sigs .iter() .find(|s| s.key == key) .ok_or_else(|| ApprovalError::Unsigned(key.short(12)))?; verify(&self.signer, sig, Domain::Attest, &self.attest.hash()) .map_err(|e| ApprovalError::BadSignature(e.to_string())) } /// Whether `s` is what this approves (the time it was made aside). pub fn covers(&self, s: &Statement) -> bool { let mut want = s.inputs.clone(); want.sort(); want.dedup(); self.statement().is_ok_and(|mine| { mine == Statement { inputs: want, ..s.clone() } }) } /// `[attest, [Signature], identity]`, each object as its bytes. pub fn encode(&self) -> Vec { cbor::encode(&Value::Array(vec![ Value::Bytes(self.attest.encode()), Value::Array(self.sigs.iter().map(|s| s.to_value()).collect()), Value::Bytes(self.signer.encode()), ])) } pub fn decode(b: &[u8]) -> Result { let v = cbor::decode(b).map_err(ObjectError::from)?; let a = array(&v, "signed approval")?; if a.len() != 3 { return Err(ApprovalError::Schema( "signed approval: expected [attest, sigs, identity]".into(), )); } Ok(SignedApproval { attest: Attest::decode(bytes(&a[0], "attest")?)?, sigs: array(&a[1], "sigs")? .iter() .map(Signature::from_value) .collect::>()?, signer: Identity::decode(bytes(&a[2], "identity")?)?, }) } } #[cfg(test)] mod tests { use super::*; use crate::identity::SecretKey; fn statement() -> Statement { Statement { repo: [7; 16], run: "https://hub.example/andy/infra/flow/8".into(), job: "apply".into(), job_hash: ObjectRef([1; 32]), state: ObjectRef([2; 32]), inputs: vec![ObjectRef([4; 32]), ObjectRef([3; 32])], environment: "prod".into(), approve: true, } } #[test] fn an_approval_signs_exactly_one_job_state_plan_and_environment() { let sk = SecretKey::generate(); let id = sk.identity("Andy", "andy@example.org", 1); let sa = SignedApproval::sign(&statement(), &id, &sk); sa.verify().unwrap(); let back = SignedApproval::decode(&sa.encode()).unwrap(); assert_eq!(back, sa, "round trip"); assert!(back.covers(&statement()), "inputs in any order"); for other in [ Statement { inputs: vec![ObjectRef([9; 32])], ..statement() }, Statement { state: ObjectRef([8; 32]), ..statement() }, Statement { job_hash: ObjectRef([8; 32]), ..statement() }, Statement { run: "https://hub.example/andy/infra/flow/9".into(), ..statement() }, Statement { environment: "dev".into(), ..statement() }, Statement { approve: false, ..statement() }, ] { assert!(!back.covers(&other), "{other:?}"); } // Changed after signing: the signature no longer holds. let mut forged = sa.clone(); if let AttestBody::Approval { inputs, .. } = &mut forged.attest.body { inputs[0] = ObjectRef([9; 32]); } assert!(forged.verify().is_err()); // Signed by someone else than the identity it carries. let other_sk = SecretKey::generate(); let mut stolen = SignedApproval::sign(&statement(), &id, &other_sk); stolen.signer = id.clone(); assert!(stolen.verify().is_err()); } }