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

nordisk/holger

server/lib/tests/the_evidence_signer_lives_in_an_hsm.rs

//! ★ **The evidence signer's key lives in a hardware token** (lane HOL-NCSC, NCSC P3): with
//! `attestation.signer_hsm: Some(Pkcs11(..))` Holger signs its DSSE evidence and audit
//! checkpoints through a PKCS#11 module; the key never leaves the token, and what it signs
//! verifies against the certificate and the root like a software key's. The PIN file is a
//! key file (`holger-key-1` at 0644). A build without `pkcs11` refuses the block by name
//! (`holger-key-2`).
//!
//! The token is SoftHSM2 (`softhsm2-util`, `libsofthsm2.so`). Where it is not installed the
//! signing test says so by name and does not run; `HOLGER_PKCS11_REQUIRED=1` (the gate's
//! SoftHSM container) makes that a failure.

use std::path::Path;

use holger_server_lib::read_ron_config;

fn ron(dir: &Path, module: &str, pin_file: &Path, cert: &Path, root: &Path) -> String {
    format!(
        r#"(
    repositories: [],
    exposed_endpoints: [ (ron_name: "g", ron_url: "127.0.0.1:1") ],
    storage_endpoints: [],
    audit: (dir: "{a}", enabled: true),
    attestation: Some((
        trust_roots: ["{root}"],
        signer_hsm: Some(Pkcs11(module: "{module}", token_label: "holger", key_label: "scan", pin_file: "{pin}")),
        signer_cert: Some("{cert}"),
        signer_alg: Some("ecdsa-p256"),
    )),
)"#,
        a = dir.join("audit").display(),
        root = root.display(),
        pin = pin_file.display(),
        cert = cert.display(),
    )
}

fn load(dir: &Path, text: &str) -> holger_server_lib::Holger {
    let p = dir.join("h.ron");
    std::fs::write(&p, text).unwrap();
    read_ron_config(&p).expect("RON parses")
}

fn chmod(p: &Path, m: u32) {
    use std::os::unix::fs::PermissionsExt as _;
    std::fs::set_permissions(p, std::fs::Permissions::from_mode(m)).unwrap();
}

#[cfg(not(feature = "pkcs11"))]
#[test]
fn a_build_without_pkcs11_refuses_the_hsm_signer_by_name() {
    if !cfg!(all(feature = "intake", feature = "sign")) {
        return;
    }
    let d = tempfile::tempdir().unwrap();
    let pin = d.path().join("pin");
    std::fs::write(&pin, "1234").unwrap();
    chmod(&pin, 0o600);
    std::fs::write(d.path().join("leaf.der"), b"x").unwrap();
    std::fs::write(d.path().join("root.der"), b"x").unwrap();
    let h = load(
        d.path(),
        &ron(
            d.path(),
            "/usr/lib/softhsm/libsofthsm2.so",
            &pin,
            &d.path().join("leaf.der"),
            &d.path().join("root.der"),
        ),
    );
    #[cfg(all(feature = "intake", feature = "sign"))]
    {
        let e = format!(
            "{:#}",
            h.attestation_signer()
                .err()
                .expect("refused without pkcs11")
        );
        assert!(e.contains("[holger-key-2]") && e.contains("pkcs11"), "{e}");
    }
    let _ = h;
}

/// The SoftHSM2 module, if this host has one.
#[cfg(feature = "pkcs11")]
fn softhsm() -> Option<std::path::PathBuf> {
    let found = [
        "/usr/lib/softhsm/libsofthsm2.so",
        "/usr/lib/x86_64-linux-gnu/softhsm/libsofthsm2.so",
        "/usr/local/lib/softhsm/libsofthsm2.so",
    ]
    .iter()
    .map(std::path::PathBuf::from)
    .find(|p| p.is_file());
    let tool = std::process::Command::new("softhsm2-util")
        .arg("--version")
        .output()
        .is_ok_and(|o| o.status.success());
    match (found, tool) {
        (Some(m), true) => Some(m),
        _ => {
            assert!(std::env::var("HOLGER_PKCS11_REQUIRED").is_err(), "HOLGER_PKCS11_REQUIRED is set and this host has no SoftHSM2 (softhsm2-util + libsofthsm2.so)");
            eprintln!("SKIPPED the_evidence_signer_signs_through_pkcs11: no SoftHSM2 here (install softhsm2, or run the gate's SoftHSM container)");
            None
        }
    }
}

#[cfg(feature = "pkcs11")]
#[test]
fn the_evidence_signer_signs_through_pkcs11_and_verifies() {
    use holger_server_lib::audit::chain::CheckpointSigner as _;
    use rcgen::{
        BasicConstraints, CertificateParams, IsCa, Issuer, KeyPair, KeyUsagePurpose,
        PKCS_ECDSA_P256_SHA256,
    };
    let Some(module) = softhsm() else { return };
    let d = tempfile::tempdir().unwrap();
    // A token of our own: SoftHSM2 keeps it under the directory its config names.
    let tokens = d.path().join("tokens");
    std::fs::create_dir_all(&tokens).unwrap();
    let conf = d.path().join("softhsm2.conf");
    std::fs::write(
        &conf,
        format!(
            "directories.tokendir = {}\nobjectstore.backend = file\nlog.level = ERROR\n",
            tokens.display()
        ),
    )
    .unwrap();
    std::env::set_var("SOFTHSM2_CONF", &conf);
    let run = |args: &[&str]| {
        let o = std::process::Command::new("softhsm2-util")
            .args(args)
            .output()
            .unwrap();
        assert!(
            o.status.success(),
            "softhsm2-util {args:?}: {}",
            String::from_utf8_lossy(&o.stderr)
        );
    };
    run(&[
        "--init-token",
        "--free",
        "--label",
        "holger",
        "--pin",
        "1234",
        "--so-pin",
        "5678",
    ]);
    // The evidence PKI: a root and a leaf; the leaf's key goes into the token.
    let ca_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
    let mut ca = CertificateParams::new(vec![]).unwrap();
    ca.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
    ca.key_usages = vec![
        KeyUsagePurpose::KeyCertSign,
        KeyUsagePurpose::DigitalSignature,
    ];
    let ca_cert = ca.self_signed(&ca_key).unwrap();
    let issuer = Issuer::new(ca, ca_key);
    let leaf_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
    let mut leaf = CertificateParams::new(vec![]).unwrap();
    leaf.key_usages = vec![KeyUsagePurpose::DigitalSignature];
    let leaf_cert = leaf.signed_by(&leaf_key, &issuer).unwrap();
    let (root, cert, pem, pin) = (
        d.path().join("root.der"),
        d.path().join("leaf.der"),
        d.path().join("leaf.pem"),
        d.path().join("pin"),
    );
    std::fs::write(&root, ca_cert.der()).unwrap();
    std::fs::write(&cert, leaf_cert.der()).unwrap();
    std::fs::write(&pem, leaf_key.serialize_pem()).unwrap();
    run(&[
        "--import",
        pem.to_str().unwrap(),
        "--token",
        "holger",
        "--label",
        "scan",
        "--id",
        "01",
        "--pin",
        "1234",
    ]);
    std::fs::remove_file(&pem).unwrap();
    std::fs::write(&pin, "1234\n").unwrap();

    // The PIN file is a key file.
    chmod(&pin, 0o644);
    let h = load(
        d.path(),
        &ron(d.path(), module.to_str().unwrap(), &pin, &cert, &root),
    );
    let e = format!(
        "{:#}",
        h.attestation_signer()
            .err()
            .expect("a PIN others can read is refused")
    );
    assert!(
        e.contains("[holger-key-1]") && e.contains("pin_file"),
        "{e}"
    );
    chmod(&pin, 0o600);

    let h = load(
        d.path(),
        &ron(d.path(), module.to_str().unwrap(), &pin, &cert, &root),
    );
    let signer = h
        .attestation_signer()
        .expect("the HSM signer opens")
        .expect("a signer");
    let signed = signer
        .sign_checkpoint("application/vnd.holger.test+json", b"{\"n\":1}")
        .expect("the token signs");
    let env = znippy_common::sign::DsseEnvelope::from_json(&signed.envelope_json).unwrap();
    let store = znippy_common::sign::CertStore::from_der_certs(&[ca_cert.der().to_vec()]).unwrap();
    let ok = znippy_common::sign::dsse_verify(&env, &[leaf_cert.der().to_vec()], &store)
        .expect("what the token signed verifies");
    assert!(!ok.is_empty());
    // A wrong label is refused by name, never a silent software fallback.
    let bad = ron(d.path(), module.to_str().unwrap(), &pin, &cert, &root)
        .replace("key_label: \"scan\"", "key_label: \"nope\"");
    let e = format!(
        "{:#}",
        load(d.path(), &bad)
            .attestation_signer()
            .err()
            .expect("no such key")
    );
    assert!(e.contains("[holger-key-2]") && e.contains("nope"), "{e}");
}