1pub const RELEASE_REPO: &str = "laithalsaadoon/microvms-agentd";
32
33pub const ASSET: &str = "agentd";
36
37pub const CHECKSUMS: &str = "SHA256SUMS";
39
40pub const RELEASE_WORKFLOW: &str = ".github/workflows/release.yml";
42
43pub const ACTIONS_ISSUER: &str = "https://token.actions.githubusercontent.com";
45
46pub const SLSA_PROVENANCE_V1: &str = "https://slsa.dev/provenance/v1";
49
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum Verification {
53 Attestation,
55 Checksum,
58}
59
60impl Verification {
61 pub fn as_str(self) -> &'static str {
62 match self {
63 Verification::Attestation => "attestation",
64 Verification::Checksum => "checksum",
65 }
66 }
67}
68
69#[derive(Clone, Debug, PartialEq, Eq)]
71pub struct Signer {
72 pub identity: String,
74 pub issuer: String,
76 pub predicate_type: String,
78}
79
80impl Signer {
81 pub fn release(tag: &str) -> Self {
92 Self {
93 identity: format!(
94 "https://github.com/{RELEASE_REPO}/{RELEASE_WORKFLOW}@refs/tags/{tag}"
95 ),
96 issuer: ACTIONS_ISSUER.to_string(),
97 predicate_type: SLSA_PROVENANCE_V1.to_string(),
98 }
99 }
100}
101
102#[derive(Clone, Debug, PartialEq, Eq)]
105pub enum Bundles {
106 Published(Vec<String>),
109 Absent(String),
113 Unreachable(String),
116}
117
118pub trait ReleaseSource {
120 fn asset(&self, tag: &str, name: &str) -> Result<Vec<u8>, String>;
122
123 fn checksums(&self, tag: &str) -> Result<String, String>;
125
126 fn attestations(&self, tag: &str, sha256: &str) -> Bundles;
129}
130
131pub trait AttestationVerifier {
133 fn verify(&self, bundle: &str, artifact: &[u8], signer: &Signer) -> Result<(), String>;
137}
138
139#[derive(Clone, PartialEq, Eq)]
141pub struct Fetched {
142 pub bytes: Vec<u8>,
143 pub verification: Verification,
144}
145
146impl std::fmt::Debug for Fetched {
148 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149 f.debug_struct("Fetched")
150 .field("bytes", &format_args!("<{} bytes>", self.bytes.len()))
151 .field("verification", &self.verification)
152 .finish()
153 }
154}
155
156pub fn fetch_release(
159 source: &dyn ReleaseSource,
160 verifier: &dyn AttestationVerifier,
161 tag: &str,
162 progress: &mut dyn FnMut(&str),
163) -> Result<Fetched, String> {
164 let bytes = source.asset(tag, ASSET).map_err(|reason| {
165 format!("could not download {ASSET} {tag} from {RELEASE_REPO}: {reason}")
166 })?;
167 let digest = sha256_hex(&bytes);
168
169 let unavailable = match source.attestations(tag, &digest) {
170 Bundles::Published(bundles) if !bundles.is_empty() => {
171 progress(&format!(
172 "downloaded {ASSET} {tag}; verifying its attestation"
173 ));
174 let signer = Signer::release(tag);
175 let mut refusals = Vec::new();
178 for bundle in &bundles {
179 match verifier.verify(bundle, &bytes, &signer) {
180 Ok(()) => {
181 return Ok(Fetched {
182 bytes,
183 verification: Verification::Attestation,
184 });
185 }
186 Err(reason) => refusals.push(reason),
187 }
188 }
189 drop(bytes);
190 return Err(format!(
191 "the attestation for {ASSET} {tag} did not verify: {}. The bytes were \
192 discarded; do not retry with verification off.",
193 refusals.join("; ")
194 ));
195 }
196 Bundles::Published(_) => {
197 return Err(absent(tag, bytes, "the lookup listed no bundles"));
198 }
199 Bundles::Absent(reason) => return Err(absent(tag, bytes, &reason)),
200 Bundles::Unreachable(reason) => reason,
201 };
202
203 progress(&format!(
204 "downloaded {ASSET} {tag}, but GitHub couldn't be reached for its attestation bundle \
205 ({unavailable}); checking {CHECKSUMS}"
206 ));
207 let sums = source.checksums(tag).map_err(|reason| {
208 format!(
209 "downloaded {ASSET} {tag}, but could neither fetch an attestation for it \
210 ({unavailable}) nor the release's {CHECKSUMS} ({reason}). An unverified download \
211 proves nothing about the bytes, so this fails closed. Releases before v0.5.0 ship \
212 no {CHECKSUMS}; for those, download and verify manually."
213 )
214 })?;
215 verify_sha256(&sums, ASSET, &bytes)?;
216 progress(&format!("{CHECKSUMS} entry for {ASSET} {tag} matched"));
217 Ok(Fetched {
218 bytes,
219 verification: Verification::Checksum,
220 })
221}
222
223fn absent(tag: &str, bytes: Vec<u8>, reason: &str) -> String {
226 drop(bytes);
227 format!(
228 "release {tag} publishes no attestation for the {ASSET} it served ({reason}). Every \
229 release that ships {CHECKSUMS} also publishes its attestation, so {CHECKSUMS} was not \
230 consulted and the bytes were discarded: treat this as a replaced asset until shown \
231 otherwise. Releases before v0.5.0 carry neither; for those, download and verify \
232 manually."
233 )
234}
235
236pub fn verify_sha256(sums: &str, asset: &str, bytes: &[u8]) -> Result<(), String> {
240 let expected = sums
241 .lines()
242 .filter_map(|line| {
243 let mut parts = line.split_whitespace();
244 let digest = parts.next()?;
245 let name = parts.next()?;
246 (name.trim_start_matches('*') == asset).then(|| digest.to_ascii_lowercase())
248 })
249 .next()
250 .ok_or_else(|| {
251 format!(
252 "the release's SHA256SUMS has no entry for {asset}, so nothing to verify against"
253 )
254 })?;
255 let actual = sha256_hex(bytes);
256 if actual == expected {
257 return Ok(());
258 }
259 Err(format!(
260 "SHA256 mismatch for {asset}: the release says {expected}, the download hashed to \
261 {actual}. The bytes were not installed; retry, and if it repeats, treat the \
262 mismatch as the finding rather than the obstacle."
263 ))
264}
265
266pub fn sha256_hex(bytes: &[u8]) -> String {
268 use sha2::{Digest as _, Sha256};
269 const_hex::encode(Sha256::digest(bytes))
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use std::cell::RefCell;
276
277 #[derive(Clone, Copy, Debug)]
279 enum Bundle {
280 Unavailable,
282 Attested,
284 Unattested,
286 Absent,
288 Empty,
290 }
291
292 struct Scripted {
294 downloads: bool,
295 bundle: Bundle,
296 sums: Option<bool>,
298 asset: Vec<u8>,
299 ran: RefCell<Vec<&'static str>>,
300 }
301
302 impl Scripted {
303 fn new(bundle: Bundle, sums: Option<bool>) -> Self {
304 Self {
305 downloads: true,
306 bundle,
307 sums,
308 asset: b"\x7fELF release bytes".to_vec(),
309 ran: RefCell::new(Vec::new()),
310 }
311 }
312
313 fn ran(&self) -> Vec<&'static str> {
314 self.ran.borrow().clone()
315 }
316 }
317
318 impl ReleaseSource for Scripted {
319 fn asset(&self, _: &str, name: &str) -> Result<Vec<u8>, String> {
320 self.ran.borrow_mut().push("asset");
321 assert_eq!(name, ASSET);
322 if self.downloads {
323 Ok(self.asset.clone())
324 } else {
325 Err("connection refused".into())
326 }
327 }
328
329 fn checksums(&self, _: &str) -> Result<String, String> {
330 self.ran.borrow_mut().push("checksums");
331 let digest = match self.sums {
332 None => return Err("HTTP 404".into()),
333 Some(true) => sha256_hex(&self.asset),
334 Some(false) => sha256_hex(b"other bytes"),
335 };
336 Ok(format!("{digest} agentd\n"))
337 }
338
339 fn attestations(&self, _: &str, sha256: &str) -> Bundles {
340 self.ran.borrow_mut().push("attestations");
341 assert_eq!(
342 sha256,
343 sha256_hex(&self.asset),
344 "looked up by the asset's digest"
345 );
346 match self.bundle {
347 Bundle::Unavailable => Bundles::Unreachable("HTTP 403: rate limited".into()),
348 Bundle::Attested => Bundles::Published(vec!["good".into()]),
349 Bundle::Unattested => Bundles::Published(vec!["bad".into()]),
350 Bundle::Absent => Bundles::Absent("HTTP 404".into()),
351 Bundle::Empty => Bundles::Published(Vec::new()),
352 }
353 }
354 }
355
356 struct Verifier;
358
359 impl AttestationVerifier for Verifier {
360 fn verify(&self, bundle: &str, _: &[u8], signer: &Signer) -> Result<(), String> {
361 assert_eq!(signer, &Signer::release("v9.9.9"));
362 match bundle {
363 "good" => Ok(()),
364 _ => Err("identity mismatch".into()),
365 }
366 }
367 }
368
369 fn fetch(source: &Scripted) -> Result<Fetched, String> {
370 fetch_release(source, &Verifier, "v9.9.9", &mut |_| {})
371 }
372
373 #[test]
376 fn an_attestation_failure_discards_the_bytes_and_stops() {
377 let source = Scripted::new(Bundle::Unattested, Some(true));
378 let refusal = fetch(&source).expect_err("bytes that failed provenance must not be served");
379 assert!(refusal.contains("discarded"), "{refusal}");
380 assert!(refusal.contains("identity mismatch"), "{refusal}");
381 assert_eq!(
382 source.ran(),
383 ["asset", "attestations"],
384 "a refused attestation must not fall through to the checksum"
385 );
386 }
387
388 #[test]
392 fn a_release_that_publishes_no_attestation_refuses_without_the_checksum() {
393 for bundle in [Bundle::Absent, Bundle::Empty] {
394 let source = Scripted::new(bundle, Some(true));
395 let refusal = fetch(&source).expect_err("an unattested asset must not be served");
396 assert!(refusal.contains("publishes no attestation"), "{refusal}");
397 assert!(refusal.contains("discarded"), "{refusal}");
398 assert_eq!(
399 source.ran(),
400 ["asset", "attestations"],
401 "{bundle:?}: a missing attestation must not fall through to the checksum"
402 );
403 }
404 }
405
406 #[test]
408 fn a_missing_sha256sums_fails_closed() {
409 let source = Scripted::new(Bundle::Unavailable, None);
410 let refusal = fetch(&source).expect_err("an unverifiable download must refuse");
411 assert!(refusal.contains("fails closed"), "{refusal}");
412 assert!(refusal.contains("rate limited"), "{refusal}");
413 assert!(refusal.contains("HTTP 404"), "{refusal}");
414 }
415
416 #[test]
418 fn a_checksum_mismatch_discards_the_bytes() {
419 let source = Scripted::new(Bundle::Unavailable, Some(false));
420 let refusal = fetch(&source).expect_err("a mismatch must refuse");
421 assert!(refusal.contains("SHA256 mismatch"), "{refusal}");
422 assert!(refusal.contains("not installed"), "{refusal}");
423 }
424
425 #[test]
428 fn one_verifying_bundle_among_several_is_enough() {
429 struct Several(Scripted);
430 impl ReleaseSource for Several {
431 fn asset(&self, tag: &str, name: &str) -> Result<Vec<u8>, String> {
432 self.0.asset(tag, name)
433 }
434 fn checksums(&self, tag: &str) -> Result<String, String> {
435 self.0.checksums(tag)
436 }
437 fn attestations(&self, _: &str, _: &str) -> Bundles {
438 Bundles::Published(vec!["bad".into(), "good".into()])
439 }
440 }
441 let source = Several(Scripted::new(Bundle::Attested, None));
442 let fetched = fetch_release(&source, &Verifier, "v9.9.9", &mut |_| {}).expect("verifies");
443 assert_eq!(fetched.verification, Verification::Attestation);
444 assert_eq!(fetched.bytes, source.0.asset);
445 }
446
447 #[test]
450 fn the_signer_is_the_release_workflow_at_the_requested_tag() {
451 assert_eq!(
452 Signer::release("v0.10.0"),
453 Signer {
454 identity: "https://github.com/laithalsaadoon/microvms-agentd/.github/workflows/\
455 release.yml@refs/tags/v0.10.0"
456 .into(),
457 issuer: "https://token.actions.githubusercontent.com".into(),
458 predicate_type: "https://slsa.dev/provenance/v1".into(),
459 }
460 );
461 }
462
463 #[test]
470 fn the_verification_table_matches_the_model() {
471 type Row = (bool, Bundle, Option<bool>, Option<Verification>);
473 let rows: [Row; 16] = [
474 (true, Bundle::Absent, Some(true), None),
475 (true, Bundle::Absent, None, None),
476 (true, Bundle::Empty, Some(true), None),
477 (false, Bundle::Absent, Some(true), None),
478 (
479 true,
480 Bundle::Attested,
481 Some(true),
482 Some(Verification::Attestation),
483 ),
484 (
485 true,
486 Bundle::Attested,
487 Some(false),
488 Some(Verification::Attestation),
489 ),
490 (
491 true,
492 Bundle::Attested,
493 None,
494 Some(Verification::Attestation),
495 ),
496 (true, Bundle::Unattested, Some(true), None),
497 (true, Bundle::Unattested, None, None),
498 (
499 true,
500 Bundle::Unavailable,
501 Some(true),
502 Some(Verification::Checksum),
503 ),
504 (true, Bundle::Unavailable, Some(false), None),
505 (true, Bundle::Unavailable, None, None),
506 (false, Bundle::Attested, Some(true), None),
507 (false, Bundle::Unattested, Some(true), None),
508 (false, Bundle::Unavailable, Some(true), None),
509 (false, Bundle::Unavailable, None, None),
510 ];
511 for (downloads, bundle, sums, expected) in rows {
512 let source = Scripted {
513 downloads,
514 ..Scripted::new(bundle, sums)
515 };
516 let fetched = fetch(&source);
517 let row = (downloads, bundle, sums);
518 assert_eq!(
519 fetched.as_ref().ok().map(|fetched| fetched.verification),
520 expected,
521 "{row:?}: {fetched:?}"
522 );
523 if let Ok(fetched) = fetched {
524 assert_eq!(fetched.bytes, source.asset, "{row:?}");
525 }
526 if !downloads {
527 assert_eq!(source.ran(), ["asset"], "{row:?}: nothing to verify");
528 }
529 if matches!(bundle, Bundle::Absent | Bundle::Empty | Bundle::Unattested) {
530 assert!(!source.ran().contains(&"checksums"), "{row:?}: laundered");
531 }
532 }
533 }
534
535 #[test]
538 fn the_checksum_verification_matches_mismatches_and_refuses_a_missing_entry() {
539 let digest = sha256_hex(b"agentd-bytes");
540 let sums = format!("{digest} agentd\nother microvm-x86_64.tar.gz\n");
541 assert!(verify_sha256(&sums, "agentd", b"agentd-bytes").is_ok());
542 let starred = format!("{digest} *agentd\n");
543 assert!(verify_sha256(&starred, "agentd", b"agentd-bytes").is_ok());
544
545 let mismatch = verify_sha256(&sums, "agentd", b"tampered").expect_err("must refuse");
546 assert!(mismatch.contains(&digest), "{mismatch}");
547 assert!(mismatch.contains("not installed"), "{mismatch}");
548
549 let missing =
550 verify_sha256("abc something-else\n", "agentd", b"x").expect_err("must refuse");
551 assert!(missing.contains("no entry"), "{missing}");
552 }
553
554 #[test]
556 fn each_verification_has_its_reported_name() {
557 assert_eq!(Verification::Attestation.as_str(), "attestation");
558 assert_eq!(Verification::Checksum.as_str(), "checksum");
559 }
560
561 #[test]
563 fn a_debug_print_of_fetched_bytes_gives_their_length() {
564 let fetched = Fetched {
565 bytes: vec![0x7f; 3],
566 verification: Verification::Checksum,
567 };
568 assert_eq!(
569 format!("{fetched:?}"),
570 "Fetched { bytes: <3 bytes>, verification: Checksum }"
571 );
572 }
573}