crates/nornir-build-thing/src/sign.rs
//! **A variant whose artifact is signed** — the declaration half of the signing
//! step.
//!
//! ```toml
//! [[build.variant]]
//! name = "monetize-guest-toy"
//! kind = "wasm"
//! sign = { purpose = "plugin" }
//! ```
//!
//! A variant that declares `sign` gets, beside every artifact its build
//! produced, two files:
//!
//! * `<artifact>.p7s` — a DETACHED CMS `SignedData` over the BLAKE3-256 digest
//! of the artifact's bytes, the exact shape `guest_host::verify::ModuleTrust`
//! checks when a host loads a module (holger's plugin host, monetize's
//! hot-load). Appended to the full file name, so `foo.wasm` is signed by
//! `foo.wasm.p7s`;
//! * `<artifact>.blake3` — the same digest as 64 lowercase hex, one line: what
//! an operator pins as `guest_digest`.
//!
//! **This crate declares; it does not sign.** It carries no key, no certificate
//! and no CMS writer. The step runs in the FORGE (dwarves), on the host side of
//! the container boundary, because the signing key must never enter a build
//! container. The forge signs through the one existing writer
//! (`guest_host::verify::sign::sign_module_file`) so a signed artifact and a
//! host's verifier cannot drift.
//!
//! A variant that declares `sign` and whose build produced nothing to sign is a
//! REFUSAL at the forge, never a green with no signature behind it.
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// **What the signature vouches for** — which certificate signs. An enum and not
/// a string: a purpose nobody issued a certificate for must not parse.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SignPurpose {
/// A module a host loads into its sandbox (a wasm guest, a plugin). Signed
/// by the plugin-signing certificate, which is issued under the owner's root
/// key, so a host names ONE trust root.
#[default]
Plugin,
}
impl SignPurpose {
/// The spelling in a recipe and on the forge's command line.
pub fn as_str(self) -> &'static str {
match self {
SignPurpose::Plugin => "plugin",
}
}
}
/// **`sign = { purpose = "plugin" }`** on a variant.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SignSpec {
/// Which certificate signs.
#[serde(default)]
pub purpose: SignPurpose,
}
/// The extension of the detached signature, appended to the artifact's full
/// file name. The same string `guest_host::verify::SIGNATURE_EXT` holds; the
/// forge's tests hold the two equal.
pub const SIGNATURE_EXT: &str = "p7s";
/// The extension of the digest file.
pub const DIGEST_EXT: &str = "blake3";
fn appended(artifact: &Path, ext: &str) -> PathBuf {
let mut name = artifact.file_name().unwrap_or_default().to_os_string();
name.push(".");
name.push(ext);
artifact.with_file_name(name)
}
/// `<artifact>.p7s`.
pub fn signature_path(artifact: &Path) -> PathBuf {
appended(artifact, SIGNATURE_EXT)
}
/// `<artifact>.blake3`.
pub fn digest_path(artifact: &Path) -> PathBuf {
appended(artifact, DIGEST_EXT)
}
/// Is this path one of the step's own outputs? A second run over a set that
/// already carries sidecars must not sign a signature.
pub fn is_sidecar(path: &Path) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()),
Some(SIGNATURE_EXT) | Some(DIGEST_EXT)
)
}
/// **The artifacts the step signs**: every produced regular file that is not
/// itself a sidecar, in the order given, each once.
pub fn signable(artifacts: &[PathBuf]) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = Vec::new();
for a in artifacts {
if is_sidecar(a) || !a.is_file() || out.contains(a) {
continue;
}
out.push(a.clone());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sidecars_are_appended_to_the_full_file_name() {
let a = Path::new("/t/wasm32-unknown-unknown/release/foo.wasm");
assert_eq!(signature_path(a), PathBuf::from("/t/wasm32-unknown-unknown/release/foo.wasm.p7s"));
assert_eq!(digest_path(a), PathBuf::from("/t/wasm32-unknown-unknown/release/foo.wasm.blake3"));
assert_ne!(signature_path(Path::new("x/foo.wasm")), signature_path(Path::new("x/foo.wat.wasm")));
}
#[test]
fn a_sidecar_is_never_signed_again() {
let d = std::env::temp_dir().join(format!("nbt-sign-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
let m = d.join("m.wasm");
std::fs::write(&m, b"\0asm").unwrap();
std::fs::write(signature_path(&m), b"cms").unwrap();
std::fs::write(digest_path(&m), b"hex").unwrap();
let got = signable(&[m.clone(), signature_path(&m), digest_path(&m), m.clone(), d.join("absent.wasm")]);
assert_eq!(got, vec![m]);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn the_purpose_spells_itself_and_an_unknown_one_does_not_parse() {
let s: SignSpec = toml::from_str("purpose = \"plugin\"").unwrap();
assert_eq!(s.purpose, SignPurpose::Plugin);
assert_eq!(s.purpose.as_str(), "plugin");
assert!(toml::from_str::<SignSpec>("purpose = \"media\"").is_err());
assert!(toml::from_str::<SignSpec>("purpose = \"plugin\"\nkey = \"x\"").is_err(), "a key path in a recipe must not parse");
}
}