Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/dwarves

crates/dwarves-slsa/src/verify.rs

//! ★ Verify a signed release bundle: the signature against the committed release
//! key, the statement's type, builder and build type, and the artifacts' hashes.

use std::collections::BTreeMap;
use std::path::Path;

use serde_json::Value;

use crate::dsse;
use crate::statement::{self, Subject, PAYLOAD_TYPE_INTOTO};
use crate::subjects::{sha256_hex, subjects_of_dir};
use crate::{bail, Result};

/// What a bundle must say besides its signature.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Expect {
    pub builder_id: String,
    pub build_type: Option<String>,
    /// The account the runner ran as (`runDetails.builder.version.runnerAccount`).
    pub runner_account: Option<String>,
}

impl Default for Expect {
    fn default() -> Self {
        Self {
            builder_id: crate::statement::BUILDER_ID_DWARVES_SERVER.to_string(),
            build_type: Some(crate::statement::BUILD_TYPE_CARGO_RELEASE.to_string()),
            runner_account: None,
        }
    }
}

/// A bundle that verified.
#[derive(Debug, Clone)]
pub struct Verified {
    pub statement: Value,
    pub summary: crate::statement::Summary,
    /// The key id of the key it verified with.
    pub keyid: String,
}

/// ★ Exactly one envelope, of the in-toto payload type, signed by the key; a
/// statement v1 with SLSA Provenance v1 from the expected builder and build type.
pub fn verify_bundle(text: &str, spki: &[u8], expect: &Expect) -> Result<Verified> {
    let envs = dsse::parse_bundle(text)?;
    if envs.len() != 1 {
        bail!("a release bundle holds exactly one envelope (one statement per release); this one holds {}", envs.len());
    }
    let env = &envs[0];
    if env.payload_type != PAYLOAD_TYPE_INTOTO {
        bail!(
            "payloadType {:?} is not {PAYLOAD_TYPE_INTOTO}",
            env.payload_type
        );
    }
    dsse::verify_p256(env, spki)?;
    let statement: Value = serde_json::from_slice(&env.payload_bytes()?)?;
    let summary = statement::summary(&statement)?;
    if summary.builder_id != expect.builder_id {
        bail!(
            "builder {:?} is not the expected builder {:?}",
            summary.builder_id,
            expect.builder_id
        );
    }
    if let Some(want) = &expect.runner_account {
        let got = statement
            .pointer("/predicate/runDetails/builder/version/runnerAccount")
            .and_then(Value::as_str);
        if got != Some(want.as_str()) {
            bail!(
                "the runner account is {}, not the release account {want:?}",
                got.map(|g| format!("{g:?}"))
                    .unwrap_or_else(|| "not recorded".into())
            );
        }
    }
    if let Some(bt) = &expect.build_type {
        if summary.build_type != *bt {
            bail!(
                "build type {:?} is not the expected build type {bt:?}",
                summary.build_type
            );
        }
    }
    Ok(Verified {
        statement,
        summary,
        keyid: dsse::keyid(spki),
    })
}

fn base(name: &str) -> &str {
    name.rsplit('/').next().unwrap_or(name)
}

impl Verified {
    /// The subject whose sha256 these bytes have.
    pub fn subject_for(&self, bytes: &[u8]) -> Option<&Subject> {
        let h = sha256_hex(bytes);
        self.summary
            .subjects
            .iter()
            .find(|s| s.sha256_hex() == Some(h.as_str()))
    }

    /// ★ An artifact: its sha256 is a subject's, and that subject's file name is
    /// `name`'s (a `.crate` cannot pass as another crate of the same release).
    pub fn check_artifact(&self, name: &str, bytes: &[u8]) -> Result<&Subject> {
        let h = sha256_hex(bytes);
        let same: Vec<&Subject> = self
            .summary
            .subjects
            .iter()
            .filter(|s| s.sha256_hex() == Some(h.as_str()))
            .collect();
        if let Some(s) = same.iter().find(|s| base(&s.name) == base(name)) {
            return Ok(s);
        }
        match same.first() {
            Some(s) => bail!(
                "{name}: its sha256 {h} is the subject {:?}'s, a different file",
                s.name
            ),
            None => bail!(
                "{name}: sha256 {h} is not a subject of release {:?}",
                self.summary.release
            ),
        }
    }

    /// ★ An out directory: exactly the subjects, no file more, none less, none changed.
    pub fn check_dir(&self, dir: &Path) -> Result<usize> {
        let want: BTreeMap<&str, Option<&str>> = self
            .summary
            .subjects
            .iter()
            .map(|s| (s.name.as_str(), s.sha256_hex()))
            .collect();
        let got_v = subjects_of_dir(dir)?;
        let got: BTreeMap<&str, Option<&str>> = got_v
            .iter()
            .map(|s| (s.name.as_str(), s.sha256_hex()))
            .collect();
        let mut bad = Vec::new();
        for (n, h) in &want {
            match got.get(n) {
                None => bad.push(format!("{n} missing")),
                Some(g) if g != h => bad.push(format!("{n} changed")),
                _ => {}
            }
        }
        bad.extend(
            got.keys()
                .filter(|n| !want.contains_key(*n))
                .map(|n| format!("{n} is not a subject")),
        );
        if !bad.is_empty() {
            bail!("{}: {}", dir.display(), bad.join("; "));
        }
        Ok(want.len())
    }
}