pub const RELEASE_REPO: &str = "laithalsaadoon/microvms-agentd";
pub const ASSET: &str = "agentd";
pub const CHECKSUMS: &str = "SHA256SUMS";
pub const RELEASE_WORKFLOW: &str = ".github/workflows/release.yml";
pub const ACTIONS_ISSUER: &str = "https://token.actions.githubusercontent.com";
pub const SLSA_PROVENANCE_V1: &str = "https://slsa.dev/provenance/v1";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Verification {
Attestation,
Checksum,
}
impl Verification {
pub fn as_str(self) -> &'static str {
match self {
Verification::Attestation => "attestation",
Verification::Checksum => "checksum",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Signer {
pub identity: String,
pub issuer: String,
pub predicate_type: String,
}
impl Signer {
pub fn release(tag: &str) -> Self {
Self {
identity: format!(
"https://github.com/{RELEASE_REPO}/{RELEASE_WORKFLOW}@refs/tags/{tag}"
),
issuer: ACTIONS_ISSUER.to_string(),
predicate_type: SLSA_PROVENANCE_V1.to_string(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Bundles {
Published(Vec<String>),
Absent(String),
Unreachable(String),
}
pub trait ReleaseSource {
fn asset(&self, tag: &str, name: &str) -> Result<Vec<u8>, String>;
fn checksums(&self, tag: &str) -> Result<String, String>;
fn attestations(&self, tag: &str, sha256: &str) -> Bundles;
}
pub trait AttestationVerifier {
fn verify(&self, bundle: &str, artifact: &[u8], signer: &Signer) -> Result<(), String>;
}
#[derive(Clone, PartialEq, Eq)]
pub struct Fetched {
pub bytes: Vec<u8>,
pub verification: Verification,
}
impl std::fmt::Debug for Fetched {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Fetched")
.field("bytes", &format_args!("<{} bytes>", self.bytes.len()))
.field("verification", &self.verification)
.finish()
}
}
pub fn fetch_release(
source: &dyn ReleaseSource,
verifier: &dyn AttestationVerifier,
tag: &str,
progress: &mut dyn FnMut(&str),
) -> Result<Fetched, String> {
let bytes = source.asset(tag, ASSET).map_err(|reason| {
format!("could not download {ASSET} {tag} from {RELEASE_REPO}: {reason}")
})?;
let digest = sha256_hex(&bytes);
let unavailable = match source.attestations(tag, &digest) {
Bundles::Published(bundles) if !bundles.is_empty() => {
progress(&format!(
"downloaded {ASSET} {tag}; verifying its attestation"
));
let signer = Signer::release(tag);
let mut refusals = Vec::new();
for bundle in &bundles {
match verifier.verify(bundle, &bytes, &signer) {
Ok(()) => {
return Ok(Fetched {
bytes,
verification: Verification::Attestation,
});
}
Err(reason) => refusals.push(reason),
}
}
drop(bytes);
return Err(format!(
"the attestation for {ASSET} {tag} did not verify: {}. The bytes were \
discarded; do not retry with verification off.",
refusals.join("; ")
));
}
Bundles::Published(_) => {
return Err(absent(tag, bytes, "the lookup listed no bundles"));
}
Bundles::Absent(reason) => return Err(absent(tag, bytes, &reason)),
Bundles::Unreachable(reason) => reason,
};
progress(&format!(
"downloaded {ASSET} {tag}, but GitHub couldn't be reached for its attestation bundle \
({unavailable}); checking {CHECKSUMS}"
));
let sums = source.checksums(tag).map_err(|reason| {
format!(
"downloaded {ASSET} {tag}, but could neither fetch an attestation for it \
({unavailable}) nor the release's {CHECKSUMS} ({reason}). An unverified download \
proves nothing about the bytes, so this fails closed. Releases before v0.5.0 ship \
no {CHECKSUMS}; for those, download and verify manually."
)
})?;
verify_sha256(&sums, ASSET, &bytes)?;
progress(&format!("{CHECKSUMS} entry for {ASSET} {tag} matched"));
Ok(Fetched {
bytes,
verification: Verification::Checksum,
})
}
fn absent(tag: &str, bytes: Vec<u8>, reason: &str) -> String {
drop(bytes);
format!(
"release {tag} publishes no attestation for the {ASSET} it served ({reason}). Every \
release that ships {CHECKSUMS} also publishes its attestation, so {CHECKSUMS} was not \
consulted and the bytes were discarded: treat this as a replaced asset until shown \
otherwise. Releases before v0.5.0 carry neither; for those, download and verify \
manually."
)
}
pub fn verify_sha256(sums: &str, asset: &str, bytes: &[u8]) -> Result<(), String> {
let expected = sums
.lines()
.filter_map(|line| {
let mut parts = line.split_whitespace();
let digest = parts.next()?;
let name = parts.next()?;
(name.trim_start_matches('*') == asset).then(|| digest.to_ascii_lowercase())
})
.next()
.ok_or_else(|| {
format!(
"the release's SHA256SUMS has no entry for {asset}, so nothing to verify against"
)
})?;
let actual = sha256_hex(bytes);
if actual == expected {
return Ok(());
}
Err(format!(
"SHA256 mismatch for {asset}: the release says {expected}, the download hashed to \
{actual}. The bytes were not installed; retry, and if it repeats, treat the \
mismatch as the finding rather than the obstacle."
))
}
pub fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest as _, Sha256};
const_hex::encode(Sha256::digest(bytes))
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
#[derive(Clone, Copy, Debug)]
enum Bundle {
Unavailable,
Attested,
Unattested,
Absent,
Empty,
}
struct Scripted {
downloads: bool,
bundle: Bundle,
sums: Option<bool>,
asset: Vec<u8>,
ran: RefCell<Vec<&'static str>>,
}
impl Scripted {
fn new(bundle: Bundle, sums: Option<bool>) -> Self {
Self {
downloads: true,
bundle,
sums,
asset: b"\x7fELF release bytes".to_vec(),
ran: RefCell::new(Vec::new()),
}
}
fn ran(&self) -> Vec<&'static str> {
self.ran.borrow().clone()
}
}
impl ReleaseSource for Scripted {
fn asset(&self, _: &str, name: &str) -> Result<Vec<u8>, String> {
self.ran.borrow_mut().push("asset");
assert_eq!(name, ASSET);
if self.downloads {
Ok(self.asset.clone())
} else {
Err("connection refused".into())
}
}
fn checksums(&self, _: &str) -> Result<String, String> {
self.ran.borrow_mut().push("checksums");
let digest = match self.sums {
None => return Err("HTTP 404".into()),
Some(true) => sha256_hex(&self.asset),
Some(false) => sha256_hex(b"other bytes"),
};
Ok(format!("{digest} agentd\n"))
}
fn attestations(&self, _: &str, sha256: &str) -> Bundles {
self.ran.borrow_mut().push("attestations");
assert_eq!(
sha256,
sha256_hex(&self.asset),
"looked up by the asset's digest"
);
match self.bundle {
Bundle::Unavailable => Bundles::Unreachable("HTTP 403: rate limited".into()),
Bundle::Attested => Bundles::Published(vec!["good".into()]),
Bundle::Unattested => Bundles::Published(vec!["bad".into()]),
Bundle::Absent => Bundles::Absent("HTTP 404".into()),
Bundle::Empty => Bundles::Published(Vec::new()),
}
}
}
struct Verifier;
impl AttestationVerifier for Verifier {
fn verify(&self, bundle: &str, _: &[u8], signer: &Signer) -> Result<(), String> {
assert_eq!(signer, &Signer::release("v9.9.9"));
match bundle {
"good" => Ok(()),
_ => Err("identity mismatch".into()),
}
}
}
fn fetch(source: &Scripted) -> Result<Fetched, String> {
fetch_release(source, &Verifier, "v9.9.9", &mut |_| {})
}
#[test]
fn an_attestation_failure_discards_the_bytes_and_stops() {
let source = Scripted::new(Bundle::Unattested, Some(true));
let refusal = fetch(&source).expect_err("bytes that failed provenance must not be served");
assert!(refusal.contains("discarded"), "{refusal}");
assert!(refusal.contains("identity mismatch"), "{refusal}");
assert_eq!(
source.ran(),
["asset", "attestations"],
"a refused attestation must not fall through to the checksum"
);
}
#[test]
fn a_release_that_publishes_no_attestation_refuses_without_the_checksum() {
for bundle in [Bundle::Absent, Bundle::Empty] {
let source = Scripted::new(bundle, Some(true));
let refusal = fetch(&source).expect_err("an unattested asset must not be served");
assert!(refusal.contains("publishes no attestation"), "{refusal}");
assert!(refusal.contains("discarded"), "{refusal}");
assert_eq!(
source.ran(),
["asset", "attestations"],
"{bundle:?}: a missing attestation must not fall through to the checksum"
);
}
}
#[test]
fn a_missing_sha256sums_fails_closed() {
let source = Scripted::new(Bundle::Unavailable, None);
let refusal = fetch(&source).expect_err("an unverifiable download must refuse");
assert!(refusal.contains("fails closed"), "{refusal}");
assert!(refusal.contains("rate limited"), "{refusal}");
assert!(refusal.contains("HTTP 404"), "{refusal}");
}
#[test]
fn a_checksum_mismatch_discards_the_bytes() {
let source = Scripted::new(Bundle::Unavailable, Some(false));
let refusal = fetch(&source).expect_err("a mismatch must refuse");
assert!(refusal.contains("SHA256 mismatch"), "{refusal}");
assert!(refusal.contains("not installed"), "{refusal}");
}
#[test]
fn one_verifying_bundle_among_several_is_enough() {
struct Several(Scripted);
impl ReleaseSource for Several {
fn asset(&self, tag: &str, name: &str) -> Result<Vec<u8>, String> {
self.0.asset(tag, name)
}
fn checksums(&self, tag: &str) -> Result<String, String> {
self.0.checksums(tag)
}
fn attestations(&self, _: &str, _: &str) -> Bundles {
Bundles::Published(vec!["bad".into(), "good".into()])
}
}
let source = Several(Scripted::new(Bundle::Attested, None));
let fetched = fetch_release(&source, &Verifier, "v9.9.9", &mut |_| {}).expect("verifies");
assert_eq!(fetched.verification, Verification::Attestation);
assert_eq!(fetched.bytes, source.0.asset);
}
#[test]
fn the_signer_is_the_release_workflow_at_the_requested_tag() {
assert_eq!(
Signer::release("v0.10.0"),
Signer {
identity: "https://github.com/laithalsaadoon/microvms-agentd/.github/workflows/\
release.yml@refs/tags/v0.10.0"
.into(),
issuer: "https://token.actions.githubusercontent.com".into(),
predicate_type: "https://slsa.dev/provenance/v1".into(),
}
);
}
#[test]
fn the_verification_table_matches_the_model() {
type Row = (bool, Bundle, Option<bool>, Option<Verification>);
let rows: [Row; 16] = [
(true, Bundle::Absent, Some(true), None),
(true, Bundle::Absent, None, None),
(true, Bundle::Empty, Some(true), None),
(false, Bundle::Absent, Some(true), None),
(
true,
Bundle::Attested,
Some(true),
Some(Verification::Attestation),
),
(
true,
Bundle::Attested,
Some(false),
Some(Verification::Attestation),
),
(
true,
Bundle::Attested,
None,
Some(Verification::Attestation),
),
(true, Bundle::Unattested, Some(true), None),
(true, Bundle::Unattested, None, None),
(
true,
Bundle::Unavailable,
Some(true),
Some(Verification::Checksum),
),
(true, Bundle::Unavailable, Some(false), None),
(true, Bundle::Unavailable, None, None),
(false, Bundle::Attested, Some(true), None),
(false, Bundle::Unattested, Some(true), None),
(false, Bundle::Unavailable, Some(true), None),
(false, Bundle::Unavailable, None, None),
];
for (downloads, bundle, sums, expected) in rows {
let source = Scripted {
downloads,
..Scripted::new(bundle, sums)
};
let fetched = fetch(&source);
let row = (downloads, bundle, sums);
assert_eq!(
fetched.as_ref().ok().map(|fetched| fetched.verification),
expected,
"{row:?}: {fetched:?}"
);
if let Ok(fetched) = fetched {
assert_eq!(fetched.bytes, source.asset, "{row:?}");
}
if !downloads {
assert_eq!(source.ran(), ["asset"], "{row:?}: nothing to verify");
}
if matches!(bundle, Bundle::Absent | Bundle::Empty | Bundle::Unattested) {
assert!(!source.ran().contains(&"checksums"), "{row:?}: laundered");
}
}
}
#[test]
fn the_checksum_verification_matches_mismatches_and_refuses_a_missing_entry() {
let digest = sha256_hex(b"agentd-bytes");
let sums = format!("{digest} agentd\nother microvm-x86_64.tar.gz\n");
assert!(verify_sha256(&sums, "agentd", b"agentd-bytes").is_ok());
let starred = format!("{digest} *agentd\n");
assert!(verify_sha256(&starred, "agentd", b"agentd-bytes").is_ok());
let mismatch = verify_sha256(&sums, "agentd", b"tampered").expect_err("must refuse");
assert!(mismatch.contains(&digest), "{mismatch}");
assert!(mismatch.contains("not installed"), "{mismatch}");
let missing =
verify_sha256("abc something-else\n", "agentd", b"x").expect_err("must refuse");
assert!(missing.contains("no entry"), "{missing}");
}
#[test]
fn each_verification_has_its_reported_name() {
assert_eq!(Verification::Attestation.as_str(), "attestation");
assert_eq!(Verification::Checksum.as_str(), "checksum");
}
#[test]
fn a_debug_print_of_fetched_bytes_gives_their_length() {
let fetched = Fetched {
bytes: vec![0x7f; 3],
verification: Verification::Checksum,
};
assert_eq!(
format!("{fetched:?}"),
"Fetched { bytes: <3 bytes>, verification: Checksum }"
);
}
}