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:?}");
}
}
}