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