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

nordisk/edda

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