Skip to main content

microvms_app/
provision.rs

1// SPDX-License-Identifier: Apache-2.0
2//! How a fetched daemon release is proven (BIND-18): which checks, in what order, and which
3//! failures stop the fetch.
4//!
5//! The policy is [`fetch_release`], written against two ports: [`ReleaseSource`] for the
6//! release's files and [`AttestationVerifier`] for the Sigstore check. `microvms-edges`
7//! implements both (reqwest against GitHub, `sigstore-verify` with its embedded trusted root)
8//! and owns the cache and the file writes around them. Here, the order is the whole point:
9//!
10//! 1. Download the asset.
11//! 2. Fetch an attestation bundle for its digest. When one arrives, it must verify against
12//!    [`Signer::release`] for the requested tag, or the fetch stops: **the bytes are discarded
13//!    and nothing else is tried.** Falling through to the checksum here would launder bytes
14//!    that failed provenance into a weaker check that can't see what was wrong with them.
15//! 3. When the release answers that it publishes no bundle for these bytes
16//!    ([`Bundles::Absent`]), the fetch stops the same way. Every release that ships
17//!    `SHA256SUMS` (v0.5.0 on) also ships its bundle, so a missing one never means an older
18//!    release: it means the asset isn't the one the release workflow built. Someone who can
19//!    edit a release's assets but can't run its workflow would otherwise delete the bundle,
20//!    upload their own asset with a matching `SHA256SUMS`, and pass as `checksum`.
21//! 4. Only when no answer can be had at all ([`Bundles::Unreachable`]: a transport failure, a
22//!    rate limit, a server error), check the bytes against the release's `SHA256SUMS`. A
23//!    release with no `SHA256SUMS` (every tag before v0.5.0) fails closed rather than trusting
24//!    TLS alone.
25//!
26//! That's the table `crates/model/src/provision.rs` checks, with "a bundle arrived, or the release
27//! answered that it has none" where it says "`gh` downloaded". [`Verification`] tells the
28//! caller which proof it got.
29
30/// The repository whose releases carry the daemon asset.
31pub const RELEASE_REPO: &str = "laithalsaadoon/microvms-agentd";
32
33/// The release asset's name: a literal, because the README's `--pattern agentd` and the
34/// checksum lookup both match it exactly.
35pub const ASSET: &str = "agentd";
36
37/// The release asset listing every other asset's SHA-256, in `sha256sum`'s format.
38pub const CHECKSUMS: &str = "SHA256SUMS";
39
40/// The workflow that builds and attests the release, relative to the repository root.
41pub const RELEASE_WORKFLOW: &str = ".github/workflows/release.yml";
42
43/// The OIDC issuer of a GitHub Actions workflow identity.
44pub const ACTIONS_ISSUER: &str = "https://token.actions.githubusercontent.com";
45
46/// The in-toto predicate type `actions/attest-build-provenance` writes, and the one
47/// `gh attestation verify` requires by default.
48pub const SLSA_PROVENANCE_V1: &str = "https://slsa.dev/provenance/v1";
49
50/// How a fetched binary's bytes were proven.
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum Verification {
53    /// The release workflow's Sigstore attestation for these bytes verified: provenance.
54    Attestation,
55    /// The release's `SHA256SUMS` entry matched, hashed in-process: integrity against a
56    /// corrupted or truncated download, not provenance.
57    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/// Who must have signed a release's attestation, and what it must attest.
70#[derive(Clone, Debug, PartialEq, Eq)]
71pub struct Signer {
72    /// The certificate's subject alternative name, matched exactly.
73    pub identity: String,
74    /// The certificate's OIDC issuer, matched exactly.
75    pub issuer: String,
76    /// The in-toto statement's `predicateType`, matched exactly.
77    pub predicate_type: String,
78}
79
80impl Signer {
81    /// This repository's release workflow, run for exactly `tag`.
82    ///
83    /// Stricter than `gh attestation verify --repo`, which accepts any workflow in the
84    /// repository at any ref. Pinning the tag ties the bytes to the version asked for, so an
85    /// older release's genuine asset and bundle can't answer a request for a newer one.
86    ///
87    /// The owner is today's name. Releases v0.5.0 and v0.6.0 were signed before the account
88    /// was renamed, under a name GitHub now reports as unregistered, so anyone could claim it
89    /// and sign as it. Those two tags are refused here, as `gh attestation verify --repo`
90    /// refuses them (measured 2026-09-26).
91    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/// What a lookup of the attestation bundles for one digest found. Each reason is prose; the
103/// policy adds what it means.
104#[derive(Clone, Debug, PartialEq, Eq)]
105pub enum Bundles {
106    /// The bundles (Sigstore bundle JSON) published for the digest. An empty list is read as
107    /// [`Bundles::Absent`].
108    Published(Vec<String>),
109    /// The release answered, and it publishes no bundle for the digest. This refuses the
110    /// bytes, so a source returns it only for an answer the release itself gave (a
111    /// "not found"), never for a failure to get one.
112    Absent(String),
113    /// No answer could be had: a transport failure, a rate limit, a server error. The only
114    /// case the checksum is consulted in.
115    Unreachable(String),
116}
117
118/// Where a release's files come from. The error is prose; the policy adds what it means.
119pub trait ReleaseSource {
120    /// The bytes of release `tag`'s asset `name`.
121    fn asset(&self, tag: &str, name: &str) -> Result<Vec<u8>, String>;
122
123    /// The body of release `tag`'s [`CHECKSUMS`] asset.
124    fn checksums(&self, tag: &str) -> Result<String, String>;
125
126    /// Every attestation bundle published for the asset whose SHA-256 is `sha256`, lowercase
127    /// hex, on release `tag`, or why there are none.
128    fn attestations(&self, tag: &str, sha256: &str) -> Bundles;
129}
130
131/// The Sigstore check over one bundle.
132pub trait AttestationVerifier {
133    /// Whether `bundle` proves `artifact` was attested by `signer`: the certificate chain, the
134    /// transparency log evidence, the signature over the artifact's digest, the certificate
135    /// identity and issuer, and the predicate type.
136    fn verify(&self, bundle: &str, artifact: &[u8], signer: &Signer) -> Result<(), String>;
137}
138
139/// The asset's bytes, and how they were proven.
140#[derive(Clone, PartialEq, Eq)]
141pub struct Fetched {
142    pub bytes: Vec<u8>,
143    pub verification: Verification,
144}
145
146/// Written by hand so a debug print never dumps megabytes of binary.
147impl 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
156/// Release `tag`'s [`ASSET`], proven by attestation or, when no answer about its bundle can
157/// be had, by checksum. See the module docs for the order and why it's that order.
158pub 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            // A digest can carry more than one attestation; one from the release workflow
176            // is the proof.
177            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
223/// The refusal for a download the release publishes no attestation for. It takes the bytes
224/// so they're gone before the caller sees the error.
225fn 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
236/// Checks `bytes` against `asset`'s entry in a `SHA256SUMS` body (`<64 hex>  <name>` per
237/// line, sha256sum's own format). In-process, because `sha256sum` the tool doesn't exist on
238/// Windows.
239pub 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            // sha256sum marks a binary-mode entry with a leading `*`.
247            (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
266/// The lowercase hex SHA-256 of `bytes`.
267pub 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    /// What a scripted release's attestation lookup answers.
278    #[derive(Clone, Copy, Debug)]
279    enum Bundle {
280        /// Neither the release asset nor the API answered.
281        Unavailable,
282        /// A bundle arrived and verifies.
283        Attested,
284        /// A bundle arrived and doesn't verify.
285        Unattested,
286        /// The release answered that it publishes no bundle for the digest.
287        Absent,
288        /// The lookup answered with an empty list.
289        Empty,
290    }
291
292    /// A release played from a table, recording which operations ran.
293    struct Scripted {
294        downloads: bool,
295        bundle: Bundle,
296        /// `None`: no `SHA256SUMS` on the release; `Some(true)`: matching; `Some(false)`: not.
297        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    /// Accepts the bundle spelled `good` for the release signer of `v9.9.9`, and nothing else.
357    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    /// **An attestation that doesn't verify discards the bytes and stops** (BIND-18): no
374    /// bytes come back, and the checksum is never consulted, even though it would match.
375    #[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    /// **A release that answers it has no attestation for the bytes refuses them without the
389    /// checksum**, even one that would match: that's what a replaced asset with a rewritten
390    /// `SHA256SUMS` and a deleted bundle looks like. An empty list reads the same.
391    #[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    /// **A release with no `SHA256SUMS` fails closed** when no bundle could be had.
407    #[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    /// **A checksum mismatch discards the bytes.**
417    #[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    /// One verifying bundle among several is the proof: the API lists every attestation for
426    /// a digest, and another workflow's is no reason to refuse the release workflow's.
427    #[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    /// The signer is the release workflow at exactly the requested tag, from GitHub Actions,
448    /// attesting SLSA provenance.
449    #[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    /// **The verification table**, the literal mirror of `the_verification_table` in
464    /// `crates/model/src/provision.rs` (BIND-18), with "a bundle arrived, or the release answered it
465    /// has none" in place of "`gh` downloaded": attestation when a bundle verifies, refusal
466    /// without the checksum when one arrives and doesn't or the release says there is none
467    /// (the model's `Unattested`), and otherwise only a matching `SHA256SUMS` passes. A failed
468    /// download refuses whatever the rest would say.
469    #[test]
470    fn the_verification_table_matches_the_model() {
471        // (downloads, bundle, sums) → verdict; `None` is refused.
472        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    /// The SHA256SUMS check: a matching entry passes, a mismatch refuses with both digests,
536    /// and a missing entry refuses rather than passing vacuously.
537    #[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    /// The names the CLI's JSON and both bindings report for how the bytes were proven.
555    #[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    /// A debug print of fetched bytes gives their length, not the binary.
562    #[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}