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