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

nordisk/holger

update-bundle/src/signer.rs

//! Signature and certificate primitives for the bundle trust model.
//!
//! Signatures are standard **ed25519** over exact stored bytes — no new algorithm
//! and no proprietary format. Signer identity is an **X.509 certificate** (DER);
//! the fingerprint is `sha256(cert DER)` and the subject is the certificate
//! subject. The ed25519 public key is taken from the certificate's SPKI. Chain
//! verification (leaf signed by a trusted root) uses `x509-parser`'s ring-backed
//! `verify_signature` — no OpenSSL.

use chrono::{DateTime, TimeZone, Utc};
use ed25519_dalek::pkcs8::KeypairBytes;
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use x509_parser::prelude::*;

use crate::digest::sha256_hex;
use crate::error::{BundleError, Result};
use crate::policy::TrustMode;

/// A parsed signer certificate.
#[derive(Debug, Clone)]
pub struct SignerCert {
    der: Vec<u8>,
    fingerprint: String,
    subject: String,
    pubkey: [u8; 32],
    not_before: DateTime<Utc>,
    not_after: DateTime<Utc>,
}

impl SignerCert {
    /// Parse an X.509 certificate (DER). The certificate must carry a 32-byte
    /// ed25519 public key.
    pub fn parse(der: &[u8]) -> Result<Self> {
        let (_, cert) = X509Certificate::from_der(der)
            .map_err(|_| BundleError::Certificate("cannot parse certificate DER".into()))?;
        let spki = cert.public_key();
        let key = spki.subject_public_key.data.as_ref();
        if key.len() != 32 {
            return Err(BundleError::Certificate(
                "signer certificate is not ed25519 (expected a 32-byte key)".into(),
            ));
        }
        let mut pubkey = [0u8; 32];
        pubkey.copy_from_slice(key);
        let subject = cert.subject().to_string();
        // The validity window is read HERE, at parse time, so that every holder of
        // a `SignerCert` can ask about it and none of them has to re-parse DER to
        // find out. A certificate whose dates do not fit a `DateTime<Utc>` is not a
        // certificate this verifier can reason about in time, so it is refused
        // rather than clamped — a clamped date is a silently different policy.
        let not_before = unix_to_utc(cert.validity().not_before.timestamp()).ok_or_else(|| {
            BundleError::Certificate("certificate notBefore is out of range".into())
        })?;
        let not_after = unix_to_utc(cert.validity().not_after.timestamp()).ok_or_else(|| {
            BundleError::Certificate("certificate notAfter is out of range".into())
        })?;
        Ok(SignerCert {
            der: der.to_vec(),
            fingerprint: sha256_hex(der),
            subject,
            pubkey,
            not_before,
            not_after,
        })
    }

    /// `notBefore` — the instant the certificate comes into force.
    pub fn not_before(&self) -> DateTime<Utc> {
        self.not_before
    }

    /// `notAfter` — the instant the certificate stops being in force.
    pub fn not_after(&self) -> DateTime<Utc> {
        self.not_after
    }

    /// Refuse a certificate that is outside its validity window at `now`.
    ///
    /// WHY THIS IS ENFORCED AT ALL. Behind an airgap there is no CRL fetch and no
    /// OCSP responder, so `notAfter` is the ONLY revocation mechanism that reaches
    /// the verifier: it is the supplier saying, at issuance time, "stop believing
    /// this key after this date". Skipping it — which every verify path in this
    /// crate did — means a signer certificate compromised in 2019 still authorizes
    /// firmware in 2031, and nothing the supplier can do offline changes that.
    ///
    /// WHY IT IS STILL POLICY-CONTROLLED. The check is only as good as `now`, and
    /// on the far side of an airgap `now` is the weakest input in the system: a
    /// flat RTC battery hands the verifier 1970 (every certificate "not yet
    /// valid"), a mis-set clock hands it 2106 (every certificate "expired"), and
    /// there is no NTP to correct either. Made unconditional, this check turns a
    /// dead coin cell into a site that can no longer take updates — including the
    /// update that would fix it. So it follows the posture
    /// [`TrustPolicy::maximum_bundle_age_secs`](crate::policy::TrustPolicy) already
    /// takes for the other time-dependent rule: time is a policy input, stated in
    /// the policy file by the people who know whether that box has a trustworthy
    /// clock. The difference is the DEFAULT — `maximum_bundle_age_secs` defaults to
    /// `None` because a missing freshness floor refuses nothing that a signature
    /// already covers, whereas a missing validity check discards revocation, so
    /// [`CertificateValidity::Enforce`](crate::policy::CertificateValidity) is the
    /// default and the waiver must be written down.
    ///
    /// Expired and not-yet-valid are DIFFERENT verdicts because the operator's next
    /// move differs: a lapsed certificate means renew and re-sign, a not-yet-valid
    /// one almost always means the verifier's clock is wrong.
    pub fn ensure_valid_at(&self, now: DateTime<Utc>) -> Result<()> {
        if now < self.not_before {
            return Err(BundleError::CertificateNotYetValid {
                fingerprint: self.fingerprint.clone(),
                not_before: self.not_before.to_rfc3339(),
            });
        }
        if now > self.not_after {
            return Err(BundleError::CertificateExpired {
                fingerprint: self.fingerprint.clone(),
                not_after: self.not_after.to_rfc3339(),
            });
        }
        Ok(())
    }

    /// [`Self::ensure_valid_at`] under a policy posture: the waiver is applied
    /// here, in ONE place, so no call site can forget which posture it is under.
    pub fn ensure_valid_under(
        &self,
        now: DateTime<Utc>,
        posture: crate::policy::CertificateValidity,
    ) -> Result<()> {
        match posture {
            crate::policy::CertificateValidity::Enforce => self.ensure_valid_at(now),
            crate::policy::CertificateValidity::IgnoreUnreliableClock => Ok(()),
        }
    }

    /// SHA-256 of the certificate DER (lowercase hex) — the identity used by the policy.
    pub fn fingerprint(&self) -> &str {
        &self.fingerprint
    }

    /// The certificate subject.
    pub fn subject(&self) -> &str {
        &self.subject
    }

    /// The certificate DER bytes.
    pub fn der(&self) -> &[u8] {
        &self.der
    }

    /// Verify a detached ed25519 signature over `msg` under this certificate's key.
    pub fn verify(&self, msg: &[u8], sig: &[u8]) -> Result<()> {
        if sig.len() != 64 {
            return Err(BundleError::Certificate(
                "signature length is not 64 bytes".into(),
            ));
        }
        let vk = VerifyingKey::from_bytes(&self.pubkey)
            .map_err(|_| BundleError::Certificate("invalid ed25519 public key".into()))?;
        let mut sig_arr = [0u8; 64];
        sig_arr.copy_from_slice(sig);
        let signature = Signature::from_bytes(&sig_arr);
        vk.verify_strict(msg, &signature)
            .map_err(|_| BundleError::Certificate("signature verification failed".into()))
    }
}

/// Sign `msg` with an ed25519 private key supplied as PKCS#8 DER. Returns the
/// 64-byte detached signature. The private key never leaves this function.
pub fn sign_pkcs8(pkcs8_der: &[u8], msg: &[u8]) -> Result<Vec<u8>> {
    let kb = KeypairBytes::try_from(pkcs8_der)
        .map_err(|_| BundleError::Certificate("cannot load ed25519 private key (PKCS#8)".into()))?;
    let sk = SigningKey::try_from(&kb)
        .map_err(|_| BundleError::Certificate("invalid ed25519 private key".into()))?;
    let sig: Signature = sk.sign(msg);
    Ok(sig.to_bytes().to_vec())
}

/// Unix seconds → `DateTime<Utc>`, `None` when the value is not representable.
fn unix_to_utc(secs: i64) -> Option<DateTime<Utc>> {
    Utc.timestamp_opt(secs, 0).single()
}

/// True if `leaf` is anchored under `mode`: either it IS a trusted root
/// (fingerprint match, i.e. a pinned self-signed anchor) or it is directly signed
/// by a trusted root.
///
/// `mode` is a REQUIRED argument, not something read off `roots.is_empty()`. This
/// function used to return `true` for an empty root set and call that
/// "certificate-pinning mode", which meant the most dangerous configuration in the
/// system — no trust anchors at all — was indistinguishable from a deliberate
/// posture, and was reached by deleting a line. Under
/// [`TrustMode::RootedChain`] an empty root set now anchors NOTHING and every leaf
/// is refused; pinning is honored only when the policy asked for it by name, and
/// the caller then enforces fingerprint authorization separately (it always did —
/// `policy.authorizes_role` / `policy.is_release_signer` run before this).
pub fn chains_to_root(leaf: &SignerCert, roots: &[SignerCert], mode: TrustMode) -> bool {
    match mode {
        // The policy anchors by fingerprint alone; the caller has already checked
        // that fingerprint against its own list.
        TrustMode::PinnedSigners => return true,
        TrustMode::RootedChain => {}
    }
    if roots.is_empty() {
        return false;
    }
    if roots.iter().any(|r| r.fingerprint == leaf.fingerprint) {
        return true;
    }
    let leaf_cert = match X509Certificate::from_der(&leaf.der) {
        Ok((_, c)) => c,
        Err(_) => return false,
    };
    for root in roots {
        if let Ok((_, root_cert)) = X509Certificate::from_der(&root.der) {
            // A root may only ISSUE a leaf if it is actually a CA (basicConstraints
            // cA=true). Without this, any pinned end-entity cert could be abused to
            // sign further leaves. (Pinned self-anchored certs took the fingerprint
            // branch above and never reach here.)
            if root_cert.is_ca()
                && leaf_cert
                    .verify_signature(Some(root_cert.public_key()))
                    .is_ok()
            {
                return true;
            }
        }
    }
    false
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Known-answer test (2026-10-02, W3 crypto bump): an update-bundle signing
    /// key stored as PKCS#8 by the PRE-bump stack (ed25519-dalek 2 / pkcs8 0.10)
    /// must load on every later stack and sign to the SAME 64 bytes (RFC 8032 is
    /// deterministic) — the owner's bundle key on disk keeps working and every
    /// appliance that pinned its public key keeps accepting bundles. Regenerate
    /// ONLY on purpose: `HOLGER_BUNDLE_KAT_REGEN=1 cargo test -p holger-update-bundle bundle_kat`.
    #[test]
    fn bundle_kat_old_pkcs8_signs_byte_identically() {
        let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata/bundle-kat");
        const MSG: &[u8] = b"holger update bundle kat: manifest root";
        if std::env::var_os("HOLGER_BUNDLE_KAT_REGEN").is_some() {
            use ed25519_dalek::pkcs8::EncodePrivateKey as _;
            let sk = SigningKey::from_bytes(&[4u8; 32]);
            let pk8 = sk.to_pkcs8_der().unwrap().as_bytes().to_vec();
            std::fs::create_dir_all(&dir).unwrap();
            std::fs::write(dir.join("key.pk8"), &pk8).unwrap();
            std::fs::write(dir.join("key.pub"), sk.verifying_key().to_bytes()).unwrap();
            std::fs::write(dir.join("msg.sig"), sign_pkcs8(&pk8, MSG).unwrap()).unwrap();
        }
        let read =
            |n: &str| std::fs::read(dir.join(n)).unwrap_or_else(|e| panic!("fixture {n}: {e}"));
        assert_eq!(
            sign_pkcs8(&read("key.pk8"), MSG).unwrap(),
            read("msg.sig"),
            "bundle signature bytes changed"
        );
        let vk = VerifyingKey::from_bytes(&read("key.pub").try_into().unwrap()).unwrap();
        let sig = Signature::from_bytes(&read("msg.sig").try_into().unwrap());
        assert!(
            vk.verify_strict(MSG, &sig).is_ok(),
            "an old bundle signature no longer verifies"
        );
    }
    use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, PKCS_ED25519};

    // rcgen 0.14 (2026-10-02, W3): a leaf is signed by an `Issuer` (the CA's params
    // + key) — what 0.13 kept inside its `Certificate`.
    fn self_cert(cn: &str, is_ca: bool) -> (rcgen::Certificate, rcgen::Issuer<'static, KeyPair>) {
        let key = KeyPair::generate_for(&PKCS_ED25519).unwrap();
        let mut p = CertificateParams::new(vec![]).unwrap();
        if is_ca {
            p.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
        }
        p.distinguished_name.push(DnType::CommonName, cn);
        let c = p.self_signed(&key).unwrap();
        (c, rcgen::Issuer::new(p, key))
    }

    fn issue(cn: &str, issuer: &rcgen::Issuer<'static, KeyPair>) -> SignerCert {
        let key = KeyPair::generate_for(&PKCS_ED25519).unwrap();
        let mut p = CertificateParams::new(vec![]).unwrap();
        p.distinguished_name.push(DnType::CommonName, cn);
        let c = p.signed_by(&key, issuer).unwrap();
        SignerCert::parse(c.der()).unwrap()
    }

    #[test]
    fn ca_root_can_issue_but_non_ca_cannot() {
        let (ca_c, ca_k) = self_cert("root-ca", true);
        let (ee_c, ee_k) = self_cert("end-entity", false);
        let ca_root = SignerCert::parse(ca_c.der()).unwrap();
        let ee_root = SignerCert::parse(ee_c.der()).unwrap();

        let leaf_from_ca = issue("leaf", &ca_k);
        assert!(
            chains_to_root(
                &leaf_from_ca,
                std::slice::from_ref(&ca_root),
                TrustMode::RootedChain
            ),
            "CA may issue"
        );

        // A leaf signed by a NON-CA cert must not chain to that cert.
        let leaf_from_ee = issue("leaf", &ee_k);
        assert!(
            !chains_to_root(&leaf_from_ee, &[ee_root], TrustMode::RootedChain),
            "a non-CA cert must not be honored as an issuing root"
        );
    }
}