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

nordisk/Skidbladnir

crates/installer/nordisk-twin/src/credential.rs

//! **The reading box's side: request, install, load.**
//!
//! The box that will DIAL a replication door — a twin following its primary, or
//! a fresh main recharging from its twin — keeps its credential in one
//! directory the product names:
//!
//! | file | what |
//! |---|---|
//! | [`KEY`] | the private key, `0600`, born here, never on the wire |
//! | [`CSR`] | the request for it, kept so a repeated request answers the same one |
//! | [`CA`] | the source's issuer, PINNED — the only CA its door is accepted from |
//! | [`PORT`], [`HOSTS`] | where the source's door is |
//! | [`CERT`] | the leaf the source's issuer signed — written LAST, it IS "paired" |
//!
//! [`install`] refuses, each by name ([`crate::TwinError::kind`]): no key here
//! (`twin-no-key`), a certificate for another key (`twin-key-mismatch`), a CA
//! that did not sign it (`twin-not-issued-by`), a CA other than the one already
//! pinned (`twin-repointed`); and a bad host list or port. A second install
//! under the SAME CA for the SAME key is a RENEWAL and is taken.

use std::path::Path;

use crate::{pki, read_opt, write_atomic, Result, TwinError};

pub const KEY: &str = "twin.key";
pub const CSR: &str = "twin.csr";
pub const CERT: &str = "twin.crt";
pub const CA: &str = "twin-ca.pem";
pub const PORT: &str = "twin.port";
pub const HOSTS: &str = "twin.hosts";

/// **The request, minting the key on first call.** Idempotent — the same key,
/// the same request — so a courier that dies half-way simply runs again.
pub fn request(dir: &Path, cn: &str) -> Result<String> {
    if let (Some(_), Some(csr)) = (read_opt(dir, KEY)?, read_opt(dir, CSR)?) {
        return Ok(csr);
    }
    let (key, csr) = pki::mint_request(cn)?;
    // The key first: a request on disk whose key is not is a request nobody can
    // ever use; a key without its request is re-requested next call.
    write_atomic(dir, KEY, key.as_bytes(), true)?;
    write_atomic(dir, CSR, csr.as_bytes(), false)?;
    Ok(csr)
}

/// What an install did.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Installed {
    /// Whole days of certificate left.
    pub days_left: i64,
    /// `notAfter`, Unix seconds.
    pub not_after: i64,
    /// True when a credential under the same CA was already installed.
    pub renewal: bool,
}

/// A host list as the source reported it: every one an IP, at least one.
pub fn parse_hosts(hosts: &[String]) -> Result<Vec<String>> {
    let v: Vec<String> = hosts.iter().map(|h| h.trim().to_string()).filter(|h| !h.is_empty()).collect();
    if v.is_empty() || v.iter().any(|h| h.parse::<std::net::IpAddr>().is_err()) {
        return Err(TwinError::BadHost(hosts.join(",")));
    }
    Ok(v)
}

/// **Take the certificate the source's issuer signed, and pin the issuer.**
pub fn install(dir: &Path, cert_pem: &str, ca_pem: &str, port: u16, hosts: &[String]) -> Result<Installed> {
    install_with(dir, cert_pem, ca_pem, port, hosts, false)
}

/// **Re-point this box at a NEW source's issuer, deliberately** — [`install`]
/// with every check but the pin: our key, the CA signed it, the window. For the
/// one time a twin MUST change what it trusts: its primary died, a fresh main
/// was recharged from it and has an issuer of its own (an issuer is never
/// replicated). Never reached by [`install`]; a product exposes it only behind
/// its admin gate and an explicit flag, so "a door that could swap the CA" is
/// an operator's act and never a courier's retry.
pub fn repoint(dir: &Path, cert_pem: &str, ca_pem: &str, port: u16, hosts: &[String]) -> Result<Installed> {
    install_with(dir, cert_pem, ca_pem, port, hosts, true)
}

fn install_with(dir: &Path, cert_pem: &str, ca_pem: &str, port: u16, hosts: &[String], repoint: bool) -> Result<Installed> {
    let hosts = parse_hosts(hosts)?;
    if port == 0 {
        return Err(TwinError::NoPort);
    }
    let key = read_opt(dir, KEY)?.ok_or_else(|| TwinError::NoKey(dir.join(KEY).display().to_string()))?;
    if pki::spki_of_key(&key)? != pki::spki_of_cert(cert_pem)? {
        return Err(TwinError::KeyMismatch);
    }
    // Signature, window and clientAuth — the door's own question.
    crate::tls::verify_client_chain(ca_pem, cert_pem)?;
    let pinned = read_opt(dir, CA)?;
    if let (Some(p), false) = (&pinned, repoint) {
        if pki::pem_to_der(p)? != pki::pem_to_der(ca_pem)? {
            return Err(TwinError::Repointed);
        }
    }
    let (_, not_after) = pki::validity(cert_pem)?;
    let now = crate::now_unix();
    if not_after <= now {
        return Err(TwinError::Expired(format!("notAfter {not_after} is not after now {now}")));
    }
    let same_ca = match &pinned {
        Some(p) => pki::pem_to_der(p)? == pki::pem_to_der(ca_pem)?,
        None => false,
    };
    let renewal = same_ca && read_opt(dir, CERT)?.is_some();
    write_atomic(dir, CA, ca_pem.as_bytes(), false)?;
    write_atomic(dir, PORT, port.to_string().as_bytes(), false)?;
    write_atomic(dir, HOSTS, hosts.join(",").as_bytes(), false)?;
    // LAST: its presence is what `load` treats as paired, so a cut before this
    // line leaves an unpaired box, never a paired one with no CA.
    write_atomic(dir, CERT, cert_pem.as_bytes(), false)?;
    Ok(Installed { days_left: (not_after - now) / 86_400, not_after, renewal })
}

/// **The installed credential.** `Debug` redacts the key.
#[derive(Clone, PartialEq, Eq)]
pub struct Credential {
    pub cert_pem: String,
    pub key_pem: String,
    pub ca_pem: String,
    pub port: u16,
    /// In the source's order of preference.
    pub hosts: Vec<String>,
}

impl std::fmt::Debug for Credential {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Credential").field("cn", &pki::common_name_of_cert(&self.cert_pem).ok().flatten()).field("port", &self.port).field("hosts", &self.hosts).field("key", &"<redacted>").finish()
    }
}

impl Credential {
    /// This credential's rustls client config (TLS 1.3, the pinned CA only).
    pub fn client_config(&self) -> Result<std::sync::Arc<rustls::ClientConfig>> {
        crate::tls::client_config(&self.ca_pem, &self.cert_pem, &self.key_pem)
    }

    /// `(host, port)` pairs to try, in order.
    pub fn doors(&self) -> Vec<String> {
        self.hosts.iter().map(|h| if h.contains(':') { format!("[{h}]:{}", self.port) } else { format!("{h}:{}", self.port) }).collect()
    }
}

/// **The paired credential, or `None` before pairing.** A certificate without
/// its key, CA or door record is an error, never `None`: half a pairing read as
/// "not paired" would send a twin back to the clear.
pub fn load(dir: &Path) -> Result<Option<Credential>> {
    let Some(cert_pem) = read_opt(dir, CERT)? else { return Ok(None) };
    let need = |name: &str| read_opt(dir, name)?.ok_or_else(|| TwinError::Io(format!("{CERT} is installed but {name} is missing under {}", dir.display())));
    let key_pem = need(KEY)?;
    let ca_pem = need(CA)?;
    let port: u16 = need(PORT)?.trim().parse().map_err(|e| TwinError::Io(format!("{PORT} holds no port: {e}")))?;
    let hosts = parse_hosts(&need(HOSTS)?.split(',').map(str::to_string).collect::<Vec<_>>())?;
    Ok(Some(Credential { cert_pem, key_pem, ca_pem, port, hosts }))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::pki::{Issuer, DEFAULT_LEAF_DAYS};

    fn dir(tag: &str) -> std::path::PathBuf {
        let p = std::env::temp_dir().join(format!("nordisk-twin-cred-{tag}-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&p);
        p
    }

    #[test]
    fn the_four_install_checks_refuse_by_name_and_a_renewal_is_taken() {
        let root = dir("four");
        let issuer = Issuer::load_or_mint(&root.join("primary"), "primary-issuer").unwrap();
        let other = Issuer::load_or_mint(&root.join("other"), "other-issuer").unwrap();
        let twin = root.join("twin");
        let hosts = vec!["10.13.8.2".to_string()];

        // No key yet.
        let (_, foreign_csr) = pki::mint_request("holger-twin").unwrap();
        let foreign = issuer.issue_from_csr("holger-twin", &foreign_csr, DEFAULT_LEAF_DAYS).unwrap();
        assert_eq!(install(&twin, &foreign.cert_pem, &issuer.cert_pem, 50052, &hosts).unwrap_err().kind(), "twin-no-key");

        let csr = request(&twin, "holger-twin").unwrap();
        assert_eq!(request(&twin, "holger-twin").unwrap(), csr, "idempotent");
        // Another key's certificate.
        assert_eq!(install(&twin, &foreign.cert_pem, &issuer.cert_pem, 50052, &hosts).unwrap_err().kind(), "twin-key-mismatch");
        // A CA that did not sign it.
        let leaf = issuer.issue_from_csr("holger-twin", &csr, DEFAULT_LEAF_DAYS).unwrap();
        assert_eq!(install(&twin, &leaf.cert_pem, &other.cert_pem, 50052, &hosts).unwrap_err().kind(), "twin-not-issued-by");
        // Bad door records.
        assert_eq!(install(&twin, &leaf.cert_pem, &issuer.cert_pem, 0, &hosts).unwrap_err().kind(), "twin-no-port");
        assert_eq!(install(&twin, &leaf.cert_pem, &issuer.cert_pem, 50052, &["primary.utility".into()]).unwrap_err().kind(), "twin-bad-host");
        assert!(load(&twin).unwrap().is_none(), "nothing refused was installed");

        let first = install(&twin, &leaf.cert_pem, &issuer.cert_pem, 50052, &hosts).unwrap();
        assert!(!first.renewal && first.days_left >= 364);
        let c = load(&twin).unwrap().unwrap();
        assert_eq!((c.port, c.doors()), (50052, vec!["10.13.8.2:50052".to_string()]));
        assert!(!format!("{c:?}").contains("PRIVATE"));

        // Re-pointing at another issuer: refused, and the pin is untouched.
        let (_, _) = (other.clone(), ());
        let other_leaf = other.issue_from_csr("holger-twin", &csr, DEFAULT_LEAF_DAYS).unwrap();
        assert_eq!(install(&twin, &other_leaf.cert_pem, &other.cert_pem, 50052, &hosts).unwrap_err().kind(), "twin-repointed");
        assert_eq!(load(&twin).unwrap().unwrap().ca_pem, issuer.cert_pem);

        // Renewal: the same issuer, the same key, a fresh leaf and new hosts.
        let again = issuer.issue_from_csr("holger-twin", &csr, DEFAULT_LEAF_DAYS).unwrap();
        let r = install(&twin, &again.cert_pem, &issuer.cert_pem, 50052, &["10.44.0.3".into(), "10.13.8.2".into()]).unwrap();
        assert!(r.renewal);
        assert_eq!(load(&twin).unwrap().unwrap().hosts, ["10.44.0.3", "10.13.8.2"]);

        // A DELIBERATE re-point (the primary died; the recharged main has its
        // own issuer): every check but the pin, and the new issuer is pinned.
        let moved = other.issue_from_csr("holger-twin", &csr, DEFAULT_LEAF_DAYS).unwrap();
        assert_eq!(repoint(&twin, &moved.cert_pem, &issuer.cert_pem, 50052, &hosts).unwrap_err().kind(), "twin-not-issued-by", "still the CA that signed it");
        let r = repoint(&twin, &moved.cert_pem, &other.cert_pem, 50062, &["10.13.8.9".into()]).unwrap();
        assert!(!r.renewal);
        let c = load(&twin).unwrap().unwrap();
        assert_eq!((c.ca_pem.as_str(), c.port), (other.cert_pem.as_str(), 50062));
        assert_eq!(install(&twin, &again.cert_pem, &issuer.cert_pem, 50052, &hosts).unwrap_err().kind(), "twin-repointed", "the OLD issuer is now the foreign one");
        let _ = std::fs::remove_dir_all(&root);
    }
}