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

nordisk/holger

xtask/src/release_attest.rs

//! **`release-attest`**: the release statement that names EVERY shipped artifact (lane HOL-NCSC,
//! NCSC P3).
//!
//! ```text
//! cargo xtask release-attest --release <name> [--commit <sha>] [--iso-out <dir>] [--out <dir>]
//!                            [--to <dir>] [--check <statement.json>]
//! ```
//!
//! dwarves' release run attests the crates it packs (docs/v2/07-build-thing.md). A Holger
//! release ships more: the media the forge composes (`.iso-out/<variant>.iso`: the four estate
//! media, the installers, the appliance ISO), each app medium's **app archive** (korp's live app
//! `<stem>.live-app.znippy` and its record `<stem>.live.toml`, what a box takes into its live
//! slot), and the product binaries (`<out>/bin/<row>/<bin>`). This verb names every one of them
//! by sha256 in one in-toto Statement v1 with SLSA Provenance v1, written UNSIGNED to
//! `<to>/<release>/statement.json` (and `subjects.sha256`) for the release manager to read and
//! sign with the release key (`slsa/README.md`), exactly as dwarves' statement is signed.
//!
//! Fail-closed: an artifact the release ships that is not on disk is refused by name
//! (`[release-attest-missing]`); a row the declaration gains is attested or named in
//! [`NOT_IN_STATEMENT`] with why, or the unit test is red. `--check <statement.json>` is the
//! check before signing any statement (this one, or dwarves'): every shipped name must be a
//! subject with a sha256 (`[release-attest-incomplete]`). No release is cut, nothing is uploaded
//! and nothing is signed here.

use std::path::{Path, PathBuf};

use anyhow::{bail, Context, Result};
use nornir_build_thing::variant::ArtifactKind;

/// One shipped artifact: its name in the statement and where the release build left it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Shipped {
    pub name: String,
    pub path: PathBuf,
}

/// The builder id the statement names (dwarves' one builder, docs/v1/SLSA.md).
pub const BUILDER_ID: &str =
    "https://git.gunnar.rs/nordisk/dwarves/slsa/builders/dwarves-server/v1";
/// This statement's build type.
pub const BUILD_TYPE: &str = "https://git.gunnar.rs/nordisk/holger/release-attest/v1";

/// **The MVP rows not in this statement, each with why** (every other MVP row is attested).
pub const NOT_IN_STATEMENT: &[(&str, &str)] = &[
    ("holger-mvp-appliance", "a container image of mvp-server-musl's binary (attested as bin/mvp-server-musl/holger-mvp): the image is pushed to and served by Holger's own OCI registry by digest, not a file the release ships"),
    ("plugin-allocation", "a wasm meter guest compiled into the metered build: it ships inside the metered media's app archive, which is attested"),
    ("plugin-skidbladnir", "a wasm handler guest compiled into the build: it ships inside the media's app archive, which is attested"),
    ("plugin-rust-toolchain", "a wasm handler guest compiled into the build: it ships inside the media's app archive, which is attested"),
];

/// The app media: their live app (the app archive) ships beside the medium.
fn app_media() -> Vec<&'static str> {
    crate::estate_media::MEDIA
        .iter()
        .filter(|m| m.live)
        .map(|m| m.variant)
        .collect()
}

/// Pure: **what the release ships**, in declaration order, where the build leaves it: media and
/// their app archives under `iso_out`, binaries under `out`. Never a dev-narrate row.
pub fn expected(iso_out: &Path, out: &Path) -> Vec<Shipped> {
    let set = holger_recipe::declared();
    let live = app_media();
    let mut v = Vec::new();
    for row in set.in_suite("MVP") {
        if row.name.ends_with(holger_recipe::variants::NARRATE_SUFFIX)
            || NOT_IN_STATEMENT.iter().any(|(r, _)| *r == row.name)
        {
            continue;
        }
        match set.kind_of(&row.name) {
            Some(ArtifactKind::Iso) | Some(ArtifactKind::Installer) => {
                let iso = iso_out.join(format!("{}.iso", row.name));
                v.push(Shipped {
                    name: format!("media/{}.iso", row.name),
                    path: iso.clone(),
                });
                if live.contains(&row.name.as_str()) {
                    let (record, app) = crate::estate_media::live_companions(&iso);
                    v.push(Shipped {
                        name: format!("media/{}.live-app.znippy", row.name),
                        path: app,
                    });
                    v.push(Shipped {
                        name: format!("media/{}.live.toml", row.name),
                        path: record,
                    });
                }
            }
            _ => {
                if let Some(bin) = &row.bin {
                    v.push(Shipped {
                        name: format!("bin/{}/{bin}", row.name),
                        path: out.join("bin").join(&row.name).join(bin),
                    });
                }
            }
        }
    }
    v
}

fn sha256_file(p: &Path) -> Result<String> {
    use sha2::Digest as _;
    let bytes = std::fs::read(p).with_context(|| p.display().to_string())?;
    Ok(sha2::Sha256::digest(&bytes)
        .iter()
        .map(|b| format!("{b:02x}"))
        .collect())
}

/// Pure: the in-toto Statement v1 (SLSA Provenance v1) over `subjects` (`(name, sha256)`).
pub fn statement(release: &str, commit: &str, subjects: &[(String, String)]) -> serde_json::Value {
    serde_json::json!({
        "_type": "https://in-toto.io/Statement/v1",
        "subject": subjects.iter().map(|(n, d)| serde_json::json!({ "name": n, "digest": { "sha256": d } })).collect::<Vec<_>>(),
        "predicateType": "https://slsa.dev/provenance/v1",
        "predicate": {
            "buildDefinition": {
                "buildType": BUILD_TYPE,
                "externalParameters": { "release": release, "commit": commit },
                "internalParameters": { "verb": "cargo xtask release-attest" },
                "resolvedDependencies": [ { "uri": format!("git+https://git.gunnar.rs/nordisk/holger@{commit}"), "digest": { "gitCommit": commit } } ],
            },
            "runDetails": { "builder": { "id": BUILDER_ID }, "metadata": { "invocationId": release } },
        },
    })
}

/// **The dry run**: hash every shipped artifact (refusing a missing one by name), write the
/// unsigned statement and `subjects.sha256` into `<to>/<release>/`; returns the statement's path.
pub fn dry_run(
    release: &str,
    commit: &str,
    iso_out: &Path,
    out: &Path,
    to: &Path,
) -> Result<PathBuf> {
    let shipped = expected(iso_out, out);
    let missing: Vec<String> = shipped
        .iter()
        .filter(|s| !s.path.is_file())
        .map(|s| format!("{} ({})", s.name, s.path.display()))
        .collect();
    if !missing.is_empty() {
        bail!("[release-attest-missing] the release ships {} artifact(s) that are not there, and a statement never leaves one out: {}", missing.len(), missing.join(", "));
    }
    let mut subjects = Vec::new();
    for s in &shipped {
        subjects.push((s.name.clone(), sha256_file(&s.path)?));
    }
    let dir = to.join(release);
    std::fs::create_dir_all(&dir).with_context(|| dir.display().to_string())?;
    let path = dir.join("statement.json");
    std::fs::write(
        &path,
        serde_json::to_vec_pretty(&statement(release, commit, &subjects))?,
    )
    .with_context(|| path.display().to_string())?;
    let sums: String = subjects
        .iter()
        .map(|(n, d)| format!("{d}  {n}\n"))
        .collect();
    std::fs::write(dir.join("subjects.sha256"), sums)?;
    Ok(path)
}

/// **The check before signing**: every shipped name is a subject of `statement` with a sha256.
/// `Err` lists each one that is not.
pub fn check(statement: &serde_json::Value, shipped: &[Shipped]) -> Result<(), Vec<String>> {
    let subjects = statement["subject"].as_array().cloned().unwrap_or_default();
    let missing: Vec<String> = shipped
        .iter()
        .filter(|s| {
            !subjects.iter().any(|x| {
                x["name"] == s.name.as_str()
                    && x["digest"]["sha256"]
                        .as_str()
                        .is_some_and(|d| d.len() == 64 && d.chars().all(|c| c.is_ascii_hexdigit()))
            })
        })
        .map(|s| s.name.clone())
        .collect();
    if missing.is_empty() {
        Ok(())
    } else {
        Err(missing)
    }
}

/// The verb.
pub fn run(root: &Path, args: &[String]) -> Result<()> {
    let mut release = None;
    let mut commit = None;
    let mut iso_out = root.join(".iso-out");
    let mut out = root.join("target").join("release-out");
    let mut to = root.join("target").join("release-attest");
    let mut check_file = None;
    let mut it = args.iter();
    while let Some(a) = it.next() {
        let mut val = |f: &str| {
            it.next()
                .cloned()
                .with_context(|| format!("{f} takes a value"))
        };
        match a.as_str() {
            "--release" => release = Some(val("--release")?),
            "--commit" => commit = Some(val("--commit")?),
            "--iso-out" => iso_out = PathBuf::from(val("--iso-out")?),
            "--out" => out = PathBuf::from(val("--out")?),
            "--to" => to = PathBuf::from(val("--to")?),
            "--check" => check_file = Some(PathBuf::from(val("--check")?)),
            other => bail!("unknown argument '{other}' (release-attest takes --release --commit --iso-out --out --to --check)"),
        }
    }
    if let Some(f) = check_file {
        let st: serde_json::Value = serde_json::from_str(
            &std::fs::read_to_string(&f).with_context(|| f.display().to_string())?,
        )
        .with_context(|| f.display().to_string())?;
        return check(&st, &expected(&iso_out, &out)).map_err(|m| {
            anyhow::anyhow!(
                "[release-attest-incomplete] {} names no sha256 for: {}",
                f.display(),
                m.join(", ")
            )
        });
    }
    let release = release.context("--release <name> is required (holger-<version>)")?;
    let commit = match commit {
        Some(c) => c,
        None => {
            let o = std::process::Command::new("git")
                .arg("-C")
                .arg(root)
                .args(["rev-parse", "HEAD"])
                .output()
                .context("git rev-parse HEAD")?;
            String::from_utf8_lossy(&o.stdout).trim().to_string()
        }
    };
    let path = dry_run(&release, &commit, &iso_out, &out, &to)?;
    println!(
        "release-attest: {} (UNSIGNED; read it, then sign it with the release key: slsa/README.md)",
        path.display()
    );
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use nornir_build_thing::variant::ArtifactKind;

    fn names(v: &[Shipped]) -> Vec<String> {
        v.iter().map(|s| s.name.clone()).collect()
    }

    /// ★ The release names every medium (the four estate media and the appliance ISO), the app
    /// archive of each app medium (korp's live app, what a box takes into its live slot), and
    /// every product binary; never a dev-narrate row.
    #[test]
    fn the_release_names_every_medium_its_app_archive_and_every_binary() {
        use holger_recipe::packaging::{
            FRONT_INSTALLER_METERED, FRONT_KEEP_MEDIUM, KEEP_MEDIUM, METERED_INSTALLER,
        };
        let s = names(&expected(Path::new("/iso"), Path::new("/out")));
        let mut want: Vec<String> = [
            METERED_INSTALLER,
            FRONT_INSTALLER_METERED,
            KEEP_MEDIUM,
            FRONT_KEEP_MEDIUM,
            "holger-appliance-iso",
        ]
        .iter()
        .map(|m| format!("media/{m}.iso"))
        .collect();
        for live in [METERED_INSTALLER, FRONT_INSTALLER_METERED] {
            want.push(format!("media/{live}.live-app.znippy"));
            want.push(format!("media/{live}.live.toml"));
        }
        for bin in [
            "bin/holger/holger",
            "bin/holger-downstream/holger-downstream",
            "bin/holger-cli/holger-cli",
            "bin/mvp-server-musl/holger-mvp",
        ] {
            want.push(bin.to_string());
        }
        for w in &want {
            assert!(s.iter().any(|n| n == w), "{w} is not named: {s:?}");
        }
        assert!(
            !s.iter()
                .any(|n| n.contains(holger_recipe::variants::NARRATE_SUFFIX)),
            "a dev-narrate row in a release: {s:?}"
        );
        let e = expected(Path::new("/iso"), Path::new("/out"));
        let iso = e
            .iter()
            .find(|x| x.name == format!("media/{METERED_INSTALLER}.iso"))
            .unwrap();
        assert_eq!(
            iso.path,
            Path::new("/iso").join(format!("{METERED_INSTALLER}.iso")),
            "the forge leaves a medium as <iso-out>/<variant>.iso"
        );
        let bin = e.iter().find(|x| x.name == "bin/holger/holger").unwrap();
        assert_eq!(bin.path, Path::new("/out/bin/holger/holger"));
    }

    /// Every MVP row is attested, or named here with why it is not in this statement: a new
    /// kind of row cannot ship unattested in silence.
    #[test]
    fn every_mvp_row_is_attested_or_named_as_not_in_the_statement() {
        let set = holger_recipe::declared();
        let s = names(&expected(Path::new("/iso"), Path::new("/out")));
        for v in set.in_suite("MVP") {
            if v.name.ends_with(holger_recipe::variants::NARRATE_SUFFIX) {
                continue;
            }
            let attested = s.iter().any(|n| {
                n.starts_with(&format!("bin/{}/", v.name)) || *n == format!("media/{}.iso", v.name)
            });
            let named = NOT_IN_STATEMENT
                .iter()
                .any(|(r, why)| *r == v.name && !why.is_empty());
            assert!(
                attested || named,
                "MVP row {} ({:?}) is neither attested nor named in NOT_IN_STATEMENT",
                v.name,
                set.kind_of(&v.name)
            );
            assert!(
                !(attested && named),
                "{} is attested and named as not attested",
                v.name
            );
        }
        let _ = ArtifactKind::Iso;
    }

    fn lay_out(iso: &Path, out: &Path) -> Vec<Shipped> {
        let all = expected(iso, out);
        for (i, s) in all.iter().enumerate() {
            std::fs::create_dir_all(s.path.parent().unwrap()).unwrap();
            std::fs::write(&s.path, format!("artifact {i} {}", s.name)).unwrap();
        }
        all
    }

    fn sha(bytes: &[u8]) -> String {
        use sha2::Digest as _;
        sha2::Sha256::digest(bytes)
            .iter()
            .map(|b| format!("{b:02x}"))
            .collect()
    }

    /// ★ The dry run writes the unsigned statement into target/: an in-toto Statement v1 with
    /// SLSA Provenance v1, every shipped artifact a subject with its sha256, the release and
    /// commit as its parameters, and the same list as `sha256sum` lines.
    #[test]
    fn a_dry_run_writes_the_statement_with_every_subject_and_its_sha256() {
        let d = tempfile::tempdir().unwrap();
        let (iso, out, to) = (
            d.path().join("iso"),
            d.path().join("out"),
            d.path().join("target"),
        );
        let all = lay_out(&iso, &out);
        assert!(!all.is_empty());
        let written = dry_run("holger-2.1.0", "c0ffee", &iso, &out, &to).expect("the dry run");
        let st: serde_json::Value =
            serde_json::from_str(&std::fs::read_to_string(&written).unwrap()).unwrap();
        assert_eq!(st["_type"], "https://in-toto.io/Statement/v1");
        assert_eq!(st["predicateType"], "https://slsa.dev/provenance/v1");
        assert_eq!(
            st["predicate"]["buildDefinition"]["externalParameters"]["release"],
            "holger-2.1.0"
        );
        assert_eq!(
            st["predicate"]["buildDefinition"]["externalParameters"]["commit"],
            "c0ffee"
        );
        let subjects = st["subject"].as_array().unwrap();
        assert_eq!(subjects.len(), all.len());
        for s in &all {
            let row = subjects
                .iter()
                .find(|x| x["name"] == s.name.as_str())
                .unwrap_or_else(|| panic!("{} is no subject", s.name));
            assert_eq!(
                row["digest"]["sha256"],
                sha(&std::fs::read(&s.path).unwrap()),
                "{}",
                s.name
            );
        }
        let sums = std::fs::read_to_string(written.with_file_name("subjects.sha256")).unwrap();
        assert_eq!(sums.lines().count(), all.len());
        check(&st, &all).expect("the statement names every shipped artifact");
    }

    /// A release whose app archive (or any artifact) is not there is refused by name: a
    /// statement never leaves an artifact out in silence.
    #[test]
    fn a_missing_artifact_is_refused_by_name() {
        let d = tempfile::tempdir().unwrap();
        let (iso, out, to) = (
            d.path().join("iso"),
            d.path().join("out"),
            d.path().join("target"),
        );
        let all = lay_out(&iso, &out);
        let app_name = format!(
            "media/{}.live-app.znippy",
            holger_recipe::packaging::METERED_INSTALLER
        );
        let app = all.iter().find(|s| s.name == app_name).unwrap();
        std::fs::remove_file(&app.path).unwrap();
        let e = format!(
            "{:#}",
            dry_run("holger-2.1.0", "c0ffee", &iso, &out, &to).unwrap_err()
        );
        assert!(
            e.contains(&app_name) && e.contains("[release-attest-missing]"),
            "{e}"
        );
    }

    /// ★ The owner's check before signing: a statement (dwarves' crate statement, say) that does
    /// not name the media, the app archive and the binaries is refused, each missing name said.
    #[test]
    fn check_refuses_a_statement_that_omits_the_media() {
        let all = expected(Path::new("/iso"), Path::new("/out"));
        let crates_only = serde_json::json!({
            "_type": "https://in-toto.io/Statement/v1",
            "subject": [ { "name": "holger-2.1.0.crate", "digest": { "sha256": "ab" } } ],
            "predicateType": "https://slsa.dev/provenance/v1",
        });
        let missing = check(&crates_only, &all).unwrap_err();
        let m = holger_recipe::packaging::METERED_INSTALLER;
        for want in [
            format!("media/{m}.iso"),
            format!("media/{m}.live-app.znippy"),
            "bin/holger/holger".to_string(),
        ] {
            assert!(missing.contains(&want), "{want}: {missing:?}");
        }
    }
}