1use sha2::Digest as _;
38
39pub const PROVENANCE_NAMESPACE: &str = "gunnar-provenance";
45
46#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct Subject {
50 pub name: String,
52 pub sha256: String,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
62#[serde(deny_unknown_fields)]
63pub struct BuildRecord {
64 pub builder: String,
68 pub source_repo: String,
70 pub source_commit: String,
72 pub recipe: String,
74 pub toolchain: String,
80 pub network: bool,
86 pub materials: Vec<Subject>,
88 pub subjects: Vec<Subject>,
90 pub started_at_unix_ms: i64,
92 pub finished_at_unix_ms: i64,
94}
95
96impl BuildRecord {
97 pub fn signing_bytes(&self) -> Vec<u8> {
105 let mut out = String::new();
106 out.push_str("gunnar-provenance-v1\n");
107 out.push_str(&format!("builder {}\n", self.builder));
108 out.push_str(&format!("source-repo {}\n", self.source_repo));
109 out.push_str(&format!("source-commit {}\n", self.source_commit));
110 out.push_str(&format!("recipe {}\n", self.recipe));
111 out.push_str(&format!("toolchain {}\n", self.toolchain));
112 out.push_str(&format!("network {}\n", self.network));
113 out.push_str(&format!("started {}\n", self.started_at_unix_ms));
114 out.push_str(&format!("finished {}\n", self.finished_at_unix_ms));
115 for material in &self.materials {
116 out.push_str(&format!("material {} {}\n", material.sha256, material.name));
117 }
118 for subject in &self.subjects {
119 out.push_str(&format!("subject {} {}\n", subject.sha256, subject.name));
120 }
121 out.into_bytes()
122 }
123
124 pub fn digest(&self) -> String {
126 let digest: [u8; 32] = sha2::Sha256::digest(self.signing_bytes()).into();
127 crate::transparency::hex(&digest)
128 }
129
130 pub fn produced(&self, sha256: &str) -> bool {
132 self.subjects.iter().any(|s| s.sha256 == sha256)
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
138#[serde(deny_unknown_fields)]
139pub struct SignedBuild {
140 #[serde(flatten)]
142 pub record: BuildRecord,
143 pub key_fingerprint: String,
145 pub signature: String,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum BuildProblem {
152 Malformed(String),
154 WrongKey {
156 signed_by: String,
158 offered: String,
160 },
161 NotVerified,
163}
164
165impl std::fmt::Display for BuildProblem {
166 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167 match self {
168 BuildProblem::Malformed(why) => {
169 write!(
170 f,
171 "the build record's signature is not a well-formed sshsig: {why}"
172 )
173 }
174 BuildProblem::WrongKey { signed_by, offered } => write!(
175 f,
176 "the build record says it was signed by {signed_by} and was checked against \
177 {offered}"
178 ),
179 BuildProblem::NotVerified => {
180 write!(f, "the build record's signature does not verify")
181 }
182 }
183 }
184}
185
186pub fn sign_build(
188 key: &ssh_key::PrivateKey,
189 record: BuildRecord,
190) -> Result<SignedBuild, ssh_key::Error> {
191 let signature = key.sign(
192 PROVENANCE_NAMESPACE,
193 ssh_key::HashAlg::Sha512,
194 &record.signing_bytes(),
195 )?;
196 Ok(SignedBuild {
197 key_fingerprint: key
198 .public_key()
199 .fingerprint(ssh_key::HashAlg::Sha256)
200 .to_string(),
201 signature: signature.to_pem(ssh_key::LineEnding::LF)?,
202 record,
203 })
204}
205
206pub fn verify_build(signed: &SignedBuild, key: &ssh_key::PublicKey) -> Result<(), BuildProblem> {
213 let offered = key.fingerprint(ssh_key::HashAlg::Sha256).to_string();
214 if offered != signed.key_fingerprint {
215 return Err(BuildProblem::WrongKey {
216 signed_by: signed.key_fingerprint.clone(),
217 offered,
218 });
219 }
220 let signature = ssh_key::SshSig::from_pem(signed.signature.as_bytes())
221 .map_err(|err| BuildProblem::Malformed(err.to_string()))?;
222 if signature.namespace() != PROVENANCE_NAMESPACE {
223 return Err(BuildProblem::Malformed(format!(
224 "namespace is {:?}, not {PROVENANCE_NAMESPACE:?}",
225 signature.namespace()
226 )));
227 }
228 key.verify(
229 PROVENANCE_NAMESPACE,
230 &signed.record.signing_bytes(),
231 &signature,
232 )
233 .map_err(|_| BuildProblem::NotVerified)
234}
235
236pub fn digest_bytes(bytes: &[u8]) -> String {
242 let digest: [u8; 32] = sha2::Sha256::digest(bytes).into();
243 crate::transparency::hex(&digest)
244}
245
246pub fn digest_file(path: &std::path::Path) -> std::io::Result<String> {
248 use std::io::Read as _;
249 let mut file = std::fs::File::open(path)?;
250 let mut hasher = sha2::Sha256::new();
251 let mut buf = vec![0u8; 64 * 1024];
255 loop {
256 let n = file.read(&mut buf)?;
257 if n == 0 {
258 break;
259 }
260 hasher.update(&buf[..n]);
261 }
262 let digest: [u8; 32] = hasher.finalize().into();
263 Ok(crate::transparency::hex(&digest))
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 fn record() -> BuildRecord {
271 BuildRecord {
272 builder: "runner-1".into(),
273 source_repo: "nordisk/gunnar".into(),
274 source_commit: "a".repeat(40),
275 recipe: "gunnar-server".into(),
276 toolchain: "rustc 1.85.0".into(),
277 network: false,
278 materials: vec![Subject {
279 name: "vendor.tar".into(),
280 sha256: "b".repeat(64),
281 }],
282 subjects: vec![Subject {
283 name: "gunnar".into(),
284 sha256: "c".repeat(64),
285 }],
286 started_at_unix_ms: 1_700_000_000_000,
287 finished_at_unix_ms: 1_700_000_060_000,
288 }
289 }
290
291 #[test]
292 fn a_build_record_verifies_and_every_tampering_is_caught() {
293 let key =
294 ssh_key::PrivateKey::from_openssh(crate::mint_ed25519().expect("mint")).expect("parse");
295 let other =
296 ssh_key::PrivateKey::from_openssh(crate::mint_ed25519().expect("mint")).expect("parse");
297 let signed = sign_build(&key, record()).expect("sign");
298 assert_eq!(verify_build(&signed, key.public_key()), Ok(()));
299
300 assert!(matches!(
301 verify_build(&signed, other.public_key()),
302 Err(BuildProblem::WrongKey { .. })
303 ));
304
305 for mutate in [
308 |r: &mut BuildRecord| r.source_commit = "d".repeat(40),
309 |r: &mut BuildRecord| r.builder = "somebody-else".into(),
310 |r: &mut BuildRecord| r.network = true,
311 |r: &mut BuildRecord| r.recipe = "another".into(),
312 |r: &mut BuildRecord| r.toolchain = "rustc 1.0.0".into(),
313 |r: &mut BuildRecord| r.subjects[0].sha256 = "e".repeat(64),
314 |r: &mut BuildRecord| r.subjects[0].name = "other".into(),
315 |r: &mut BuildRecord| r.materials[0].sha256 = "f".repeat(64),
316 |r: &mut BuildRecord| r.finished_at_unix_ms += 1,
317 ] {
318 let mut tampered = signed.clone();
319 mutate(&mut tampered.record);
320 assert_eq!(
321 verify_build(&tampered, key.public_key()),
322 Err(BuildProblem::NotVerified),
323 "a field was changed and the signature still verified: {:?}",
324 tampered.record
325 );
326 }
327 }
328
329 #[test]
332 fn a_signature_from_another_namespace_is_refused() {
333 let key =
334 ssh_key::PrivateKey::from_openssh(crate::mint_ed25519().expect("mint")).expect("parse");
335 let record = record();
336 let elsewhere = key
337 .sign("git", ssh_key::HashAlg::Sha512, &record.signing_bytes())
338 .expect("sign");
339 let smuggled = SignedBuild {
340 key_fingerprint: key
341 .public_key()
342 .fingerprint(ssh_key::HashAlg::Sha256)
343 .to_string(),
344 signature: elsewhere.to_pem(ssh_key::LineEnding::LF).expect("armour"),
345 record,
346 };
347 assert!(matches!(
348 verify_build(&smuggled, key.public_key()),
349 Err(BuildProblem::Malformed(_))
350 ));
351 }
352
353 #[test]
354 fn the_digest_moves_with_the_record_and_produced_answers_on_the_digest() {
355 let a = record();
356 let mut b = record();
357 b.subjects[0].sha256 = "9".repeat(64);
358 assert_ne!(a.digest(), b.digest());
359 assert!(a.produced(&"c".repeat(64)));
360 assert!(!a.produced(&"9".repeat(64)));
361 }
362
363 #[test]
364 fn a_files_digest_is_its_sha256() {
365 let dir = tempfile::tempdir().expect("tempdir");
366 let path = dir.path().join("artifact");
367 std::fs::write(&path, b"hello\n").expect("write");
368 assert_eq!(
370 digest_file(&path).expect("digest"),
371 "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"
372 );
373 }
374}