//! Attestations (spec §14.4): signed statements about references and batches //! of changes, and runs (receipts, §14.6). use crate::cbor::{self, Value}; use crate::hash::{hash_object, ChangeHash, KeyId, ObjectRef, ObjectType}; use crate::objects::{ array, bytes, check_version, field, map, oref, text, uint, Hlc, ObjectError, FORMAT_VERSION, }; pub const MAX_SEAL_HASHES: usize = 4096; #[derive(Clone, Debug, PartialEq, Eq)] pub enum AttestBody { /// A bookmark moved from `old_state` to `new_state` (spec §10.2). Ref { name: String, old_state: ObjectRef, new_state: ObjectRef, serial: u64, }, /// One signature covering a batch of changes (spec §14.4). Seal { hashes: Vec }, /// An immutable named state (spec §10.1). Tag { name: String, state: ObjectRef, message: String, }, /// The signing key's identity was replaced by `new` (spec §14.2). Rotate { new: KeyId }, /// `key` must no longer be trusted (spec §14.2). Revoke { key: KeyId, reason: String }, /// A runner ran the job of `receipt` on `state` (spec §14.6); `run` /// names the run, usually by URL. Receipt { receipt: ObjectRef, state: ObjectRef, run: String, }, /// A person approved (or rejected) one job of one run (OxygenFlow /// approval gates; oxygenhub#235): the job as hashed with its sandbox, /// on that state, taking exactly those input blobs (a reviewed plan), /// for that environment. It approves nothing else. Approval { run: String, job: String, job_hash: ObjectRef, state: ObjectRef, inputs: Vec, environment: String, approve: bool, }, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct Attest { /// Repository ID chosen at init. pub repo: [u8; 16], pub hlc: Hlc, pub body: AttestBody, } impl Attest { pub fn kind(&self) -> &'static str { match self.body { AttestBody::Ref { .. } => "ref", AttestBody::Seal { .. } => "seal", AttestBody::Tag { .. } => "tag", AttestBody::Rotate { .. } => "rotate", AttestBody::Revoke { .. } => "revoke", AttestBody::Receipt { .. } => "receipt", AttestBody::Approval { .. } => "approval", } } pub fn seal(repo: [u8; 16], mut hashes: Vec) -> Attest { hashes.sort(); hashes.dedup(); Attest { repo, hlc: Hlc::now(), body: AttestBody::Seal { hashes }, } } pub fn to_value(&self) -> Value { let body = match &self.body { AttestBody::Ref { name, old_state, new_state, serial, } => Value::map([ (0, Value::Text(name.clone())), (1, Value::Bytes(old_state.0.to_vec())), (2, Value::Bytes(new_state.0.to_vec())), (3, Value::Uint(*serial)), ]), AttestBody::Seal { hashes } => Value::map([( 0, Value::Array(hashes.iter().map(|h| Value::Bytes(h.0.to_vec())).collect()), )]), AttestBody::Tag { name, state, message, } => Value::map([ (0, Value::Text(name.clone())), (1, Value::Bytes(state.0.to_vec())), (2, Value::Text(message.clone())), ]), AttestBody::Rotate { new } => Value::map([(0, Value::Bytes(new.0.to_vec()))]), AttestBody::Revoke { key, reason } => Value::map([ (0, Value::Bytes(key.0.to_vec())), (1, Value::Text(reason.clone())), ]), AttestBody::Receipt { receipt, state, run, } => Value::map([ (0, Value::Bytes(receipt.0.to_vec())), (1, Value::Bytes(state.0.to_vec())), (2, Value::Text(run.clone())), ]), AttestBody::Approval { run, job, job_hash, state, inputs, environment, approve, } => Value::map([ (0, Value::Text(run.clone())), (1, Value::Text(job.clone())), (2, Value::Bytes(job_hash.0.to_vec())), (3, Value::Bytes(state.0.to_vec())), ( 4, Value::Array(inputs.iter().map(|i| Value::Bytes(i.0.to_vec())).collect()), ), (5, Value::Text(environment.clone())), (6, Value::Uint(u64::from(*approve))), ]), }; Value::map([ (0, Value::Uint(FORMAT_VERSION)), (1, Value::Text(self.kind().into())), (2, Value::Bytes(self.repo.to_vec())), (3, self.hlc.to_value()), (4, body), ]) } pub fn encode(&self) -> Vec { cbor::encode(&self.to_value()) } pub fn hash(&self) -> ObjectRef { hash_object(ObjectType::Attest, &self.encode()) } pub fn decode(b: &[u8]) -> Result { let v = cbor::decode(b)?; let m = map(&v, "attest")?; check_version(m)?; let kind = text(field(m, 1, "kind")?, "kind")?; let repo: [u8; 16] = bytes(field(m, 2, "repo")?, "repo")? .try_into() .map_err(|_| ObjectError::Schema("repo: expected 16 bytes".into()))?; let hlc = Hlc::from_value(field(m, 3, "hlc")?)?; let b = map(field(m, 4, "body")?, "body")?; let body = match kind { "ref" => AttestBody::Ref { name: text(field(b, 0, "name")?, "name")?.to_owned(), old_state: oref(field(b, 1, "old_state")?, "old_state")?, new_state: oref(field(b, 2, "new_state")?, "new_state")?, serial: uint(field(b, 3, "serial")?, "serial")?, }, "seal" => { let hashes = array(field(b, 0, "hashes")?, "hashes")? .iter() .map(|h| oref(h, "hash")) .collect::, _>>()?; if hashes.is_empty() || hashes.len() > MAX_SEAL_HASHES || !hashes.windows(2).all(|w| w[0] < w[1]) { return Err(ObjectError::Schema( "seal: hashes must be 1..4096, sorted and unique".into(), )); } AttestBody::Seal { hashes } } "tag" => AttestBody::Tag { name: text(field(b, 0, "name")?, "name")?.to_owned(), state: oref(field(b, 1, "state")?, "state")?, message: text(field(b, 2, "message")?, "message")?.to_owned(), }, "rotate" => AttestBody::Rotate { new: oref(field(b, 0, "new")?, "new")?, }, "revoke" => AttestBody::Revoke { key: oref(field(b, 0, "key")?, "key")?, reason: text(field(b, 1, "reason")?, "reason")?.to_owned(), }, "receipt" => AttestBody::Receipt { receipt: oref(field(b, 0, "receipt")?, "receipt")?, state: oref(field(b, 1, "state")?, "state")?, run: text(field(b, 2, "run")?, "run")?.to_owned(), }, "approval" => { let inputs = array(field(b, 4, "inputs")?, "inputs")? .iter() .map(|h| oref(h, "input")) .collect::, _>>()?; if !inputs.windows(2).all(|w| w[0] < w[1]) { return Err(ObjectError::Schema( "approval: inputs must be sorted and unique".into(), )); } AttestBody::Approval { run: text(field(b, 0, "run")?, "run")?.to_owned(), job: text(field(b, 1, "job")?, "job")?.to_owned(), job_hash: oref(field(b, 2, "job_hash")?, "job_hash")?, state: oref(field(b, 3, "state")?, "state")?, inputs, environment: text(field(b, 5, "environment")?, "environment")?.to_owned(), approve: match uint(field(b, 6, "approve")?, "approve")? { 0 => false, 1 => true, _ => return Err(ObjectError::Schema("approval: approve is 0 or 1".into())), }, } } other => { return Err(ObjectError::Schema(format!( "unknown attestation kind {other:?}" ))) } }; Ok(Attest { repo, hlc, body }) } } #[cfg(test)] mod tests { use super::*; #[test] fn round_trips() { let r = Attest { repo: [1; 16], hlc: Hlc { wall_ms: 3, logical: 0, }, body: AttestBody::Ref { name: "main".into(), old_state: ObjectRef([1; 32]), new_state: ObjectRef([2; 32]), serial: 7, }, }; assert_eq!(Attest::decode(&r.encode()).unwrap(), r); assert_eq!(r.kind(), "ref"); let s = Attest::seal( [1; 16], vec![ObjectRef([9; 32]), ObjectRef([3; 32]), ObjectRef([9; 32])], ); let d = Attest::decode(&s.encode()).unwrap(); assert!( matches!(&d.body, AttestBody::Seal { hashes } if hashes == &vec![ObjectRef([3; 32]), ObjectRef([9; 32])]) ); assert_ne!(s.hash(), r.hash()); let t = Attest { repo: [1; 16], hlc: Hlc::default(), body: AttestBody::Tag { name: "v1".into(), state: ObjectRef([5; 32]), message: "release".into(), }, }; assert_eq!(Attest::decode(&t.encode()).unwrap(), t); assert_eq!(t.kind(), "tag"); } #[test] fn rejects_bad_seals() { let empty = Attest { repo: [0; 16], hlc: Hlc::default(), body: AttestBody::Seal { hashes: vec![] }, }; assert!(Attest::decode(&empty.encode()).is_err()); let unsorted = Attest { repo: [0; 16], hlc: Hlc::default(), body: AttestBody::Seal { hashes: vec![ObjectRef([2; 32]), ObjectRef([1; 32])], }, }; assert!(Attest::decode(&unsorted.encode()).is_err()); } }