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())
}
}