| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | |
|
| 7 | | use crate::attest::{Attest, AttestBody}; |
| 8 | | use crate::cbor::{self, Value}; |
| 9 | | use crate::hash::ObjectRef; |
| 10 | | use crate::identity::{verify, Domain, Identity, SecretKey, Signature}; |
| 11 | | use crate::objects::{array, bytes, Hlc, ObjectError}; |
| 12 | |
|
| 13 | |
|
| 14 | | #[derive(Clone, Debug, PartialEq, Eq)] |
| 15 | | pub struct Statement { |
| 16 | | pub repo: [u8; 16], |
| 17 | | |
| 18 | | pub run: String, |
| 19 | | pub job: String, |
| 20 | | |
| 21 | | pub job_hash: ObjectRef, |
| 22 | | pub state: ObjectRef, |
| 23 | | |
| 24 | | pub inputs: Vec<ObjectRef>, |
| 25 | | pub environment: String, |
| 26 | | pub approve: bool, |
| 27 | | } |
| 28 | |
|
| 29 | | #[derive(Debug, thiserror::Error)] |
| 30 | | pub enum ApprovalError { |
| 31 | | #[error("the attestation is not an approval")] |
| 32 | | NotApproval, |
| 33 | | #[error("no signature by the signer {0}")] |
| 34 | | Unsigned(String), |
| 35 | | #[error("the signature does not verify: {0}")] |
| 36 | | BadSignature(String), |
| 37 | | #[error("{0}")] |
| 38 | | Schema(String), |
| 39 | | } |
| 40 | |
|
| 41 | | impl From<ObjectError> for ApprovalError { |
| 42 | 0× | fn from(e: ObjectError) -> Self { |
| 43 | 0× | ApprovalError::Schema(e.to_string()) |
| 44 | 0× | } |
| 45 | | } |
| 46 | |
|
| 47 | | #[derive(Clone, Debug, PartialEq, Eq)] |
| 48 | | pub struct SignedApproval { |
| 49 | | pub attest: Attest, |
| 50 | | pub sigs: Vec<Signature>, |
| 51 | | pub signer: Identity, |
| 52 | | } |
| 53 | |
|
| 54 | | impl SignedApproval { |
| 55 | 4× | pub fn sign(s: &Statement, signer: &Identity, sk: &SecretKey) -> SignedApproval { |
| 56 | 4× | let mut inputs = s.inputs.clone(); |
| 57 | 4× | inputs.sort(); |
| 58 | 4× | inputs.dedup(); |
| 59 | 4× | let attest = Attest { |
| 60 | 4× | repo: s.repo, |
| 61 | 4× | hlc: Hlc::now(), |
| 62 | 4× | body: AttestBody::Approval { |
| 63 | 4× | run: s.run.clone(), |
| 64 | 4× | job: s.job.clone(), |
| 65 | 4× | job_hash: s.job_hash, |
| 66 | 4× | state: s.state, |
| 67 | 4× | inputs, |
| 68 | 4× | environment: s.environment.clone(), |
| 69 | 4× | approve: s.approve, |
| 70 | 4× | }, |
| 71 | 4× | }; |
| 72 | 4× | let sig = sk.sign(Domain::Attest, &signer.hash(), &attest.hash()); |
| 73 | 4× | SignedApproval { |
| 74 | 4× | attest, |
| 75 | 4× | sigs: vec![sig], |
| 76 | 4× | signer: signer.clone(), |
| 77 | 4× | } |
| 78 | 4× | } |
| 79 | |
|
| 80 | | |
| 81 | 14× | pub fn statement(&self) -> Result<Statement, ApprovalError> { |
| 82 | | let AttestBody::Approval { |
| 83 | 14× | run, |
| 84 | 14× | job, |
| 85 | 14× | job_hash, |
| 86 | 14× | state, |
| 87 | 14× | inputs, |
| 88 | 14× | environment, |
| 89 | 14× | approve, |
| 90 | 14× | } = &self.attest.body |
| 91 | | else { |
| 92 | 0× | return Err(ApprovalError::NotApproval); |
| 93 | | }; |
| 94 | 14× | Ok(Statement { |
| 95 | 14× | repo: self.attest.repo, |
| 96 | 14× | run: run.clone(), |
| 97 | 14× | job: job.clone(), |
| 98 | 14× | job_hash: *job_hash, |
| 99 | 14× | state: *state, |
| 100 | 14× | inputs: inputs.clone(), |
| 101 | 14× | environment: environment.clone(), |
| 102 | 14× | approve: *approve, |
| 103 | 14× | }) |
| 104 | 14× | } |
| 105 | |
|
| 106 | | |
| 107 | | |
| 108 | 6× | pub fn verify(&self) -> Result<(), ApprovalError> { |
| 109 | 6× | self.statement()?; |
| 110 | 6× | let key = self.signer.hash(); |
| 111 | 6× | let sig = self |
| 112 | 6× | .sigs |
| 113 | 6× | .iter() |
| 114 | 6× | .find(|s| s.key == key) |
| 115 | 6× | .ok_or_else(|| ApprovalError::Unsigned(key.short(12)))?; |
| 116 | 6× | verify(&self.signer, sig, Domain::Attest, &self.attest.hash()) |
| 117 | 6× | .map_err(|e| ApprovalError::BadSignature(e.to_string())) |
| 118 | 6× | } |
| 119 | |
|
| 120 | | |
| 121 | 7× | pub fn covers(&self, s: &Statement) -> bool { |
| 122 | 7× | let mut want = s.inputs.clone(); |
| 123 | 7× | want.sort(); |
| 124 | 7× | want.dedup(); |
| 125 | 7× | self.statement().is_ok_and(|mine| { |
| 126 | 7× | mine == Statement { |
| 127 | 7× | inputs: want, |
| 128 | 7× | ..s.clone() |
| 129 | 7× | } |
| 130 | 7× | }) |
| 131 | 7× | } |
| 132 | |
|
| 133 | | |
| 134 | 1× | pub fn encode(&self) -> Vec<u8> { |
| 135 | 1× | cbor::encode(&Value::Array(vec![ |
| 136 | 1× | Value::Bytes(self.attest.encode()), |
| 137 | 1× | Value::Array(self.sigs.iter().map(|s| s.to_value()).collect()), |
| 138 | 1× | Value::Bytes(self.signer.encode()), |
| 139 | | ])) |
| 140 | 1× | } |
| 141 | |
|
| 142 | 1× | pub fn decode(b: &[u8]) -> Result<SignedApproval, ApprovalError> { |
| 143 | 1× | let v = cbor::decode(b).map_err(ObjectError::from)?; |
| 144 | 1× | let a = array(&v, "signed approval")?; |
| 145 | 1× | if a.len() != 3 { |
| 146 | 0× | return Err(ApprovalError::Schema( |
| 147 | 0× | "signed approval: expected [attest, sigs, identity]".into(), |
| 148 | 0× | )); |
| 149 | 1× | } |
| 150 | | Ok(SignedApproval { |
| 151 | 1× | attest: Attest::decode(bytes(&a[0], "attest")?)?, |
| 152 | 1× | sigs: array(&a[1], "sigs")? |
| 153 | 1× | .iter() |
| 154 | 1× | .map(Signature::from_value) |
| 155 | 1× | .collect::<Result<_, _>>()?, |
| 156 | 1× | signer: Identity::decode(bytes(&a[2], "identity")?)?, |
| 157 | | }) |
| 158 | 1× | } |
| 159 | | } |
| 160 | |
|
| 161 | | #[cfg(test)] |
| 162 | | mod tests { |
| 163 | | use super::*; |
| 164 | | use crate::identity::SecretKey; |
| 165 | |
|
| 166 | 9× | fn statement() -> Statement { |
| 167 | 9× | Statement { |
| 168 | 9× | repo: [7; 16], |
| 169 | 9× | run: "https://hub.example/andy/infra/flow/8".into(), |
| 170 | 9× | job: "apply".into(), |
| 171 | 9× | job_hash: ObjectRef([1; 32]), |
| 172 | 9× | state: ObjectRef([2; 32]), |
| 173 | 9× | inputs: vec![ObjectRef([4; 32]), ObjectRef([3; 32])], |
| 174 | 9× | environment: "prod".into(), |
| 175 | 9× | approve: true, |
| 176 | 9× | } |
| 177 | 9× | } |
| 178 | |
|
| 179 | | #[test] |
| 180 | 1× | fn an_approval_signs_exactly_one_job_state_plan_and_environment() { |
| 181 | 1× | let sk = SecretKey::generate(); |
| 182 | 1× | let id = sk.identity("Andy", "[email protected]", 1); |
| 183 | 1× | let sa = SignedApproval::sign(&statement(), &id, &sk); |
| 184 | 1× | sa.verify().unwrap(); |
| 185 | 1× | let back = SignedApproval::decode(&sa.encode()).unwrap(); |
| 186 | 1× | assert_eq!(back, sa, "round trip"); |
| 187 | 1× | assert!(back.covers(&statement()), "inputs in any order"); |
| 188 | 6× | for other in [ |
| 189 | 1× | Statement { |
| 190 | 1× | inputs: vec![ObjectRef([9; 32])], |
| 191 | 1× | ..statement() |
| 192 | 1× | }, |
| 193 | 1× | Statement { |
| 194 | 1× | state: ObjectRef([8; 32]), |
| 195 | 1× | ..statement() |
| 196 | 1× | }, |
| 197 | 1× | Statement { |
| 198 | 1× | job_hash: ObjectRef([8; 32]), |
| 199 | 1× | ..statement() |
| 200 | 1× | }, |
| 201 | 1× | Statement { |
| 202 | 1× | run: "https://hub.example/andy/infra/flow/9".into(), |
| 203 | 1× | ..statement() |
| 204 | 1× | }, |
| 205 | 1× | Statement { |
| 206 | 1× | environment: "dev".into(), |
| 207 | 1× | ..statement() |
| 208 | 1× | }, |
| 209 | 1× | Statement { |
| 210 | 1× | approve: false, |
| 211 | 1× | ..statement() |
| 212 | 1× | }, |
| 213 | 1× | ] { |
| 214 | 6× | assert!(!back.covers(&other), "{other:?}"); |
| 215 | | } |
| 216 | | |
| 217 | 1× | let mut forged = sa.clone(); |
| 218 | 1× | if let AttestBody::Approval { inputs, .. } = &mut forged.attest.body { |
| 219 | 1× | inputs[0] = ObjectRef([9; 32]); |
| 220 | 1× | } |
| 221 | 1× | assert!(forged.verify().is_err()); |
| 222 | | |
| 223 | 1× | let other_sk = SecretKey::generate(); |
| 224 | 1× | let mut stolen = SignedApproval::sign(&statement(), &id, &other_sk); |
| 225 | 1× | stolen.signer = id.clone(); |
| 226 | 1× | assert!(stolen.verify().is_err()); |
| 227 | 1× | } |
| 228 | | } |