use std::fs;
use std::path::{Path, PathBuf};
use rcgen::{
CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose, Ia5String, KeyPair,
KeyUsagePurpose, SanType, PKCS_ECDSA_P256_SHA256,
};
use rustls_pki_types::{CertificateDer, PrivateKeyDer};
use time::{Duration, OffsetDateTime};
use zeroize::Zeroizing;
use crate::binding::attach_binding;
use crate::bls::SecretKey;
use crate::ca::{DigCa, CLOCK_SKEW_BACKDATE};
use crate::error::{DigTlsError, Result};
use crate::identity::{peer_id_from_tls_spki_der, PeerId};
pub const LEAF_LIFETIME: Duration = Duration::days(365 * 10);
const LEAF_SAN: &str = "peer.dig";
const CERT_FILE: &str = "node.crt";
const KEY_FILE: &str = "node.key";
const CERT_FILE_PREV: &str = "node.crt.prev";
const KEY_FILE_PREV: &str = "node.key.prev";
#[derive(Debug)]
pub struct RotatedNodeCert {
previous: NodeCert,
current: NodeCert,
}
impl RotatedNodeCert {
pub fn previous(&self) -> &NodeCert {
&self.previous
}
pub fn current(&self) -> &NodeCert {
&self.current
}
pub fn into_current(self) -> NodeCert {
self.current
}
}
pub struct NodeCert {
cert_pem: String,
key_pem: Zeroizing<String>,
cert_der: Vec<u8>,
key_der: Zeroizing<Vec<u8>>,
spki_der: Vec<u8>,
peer_id: PeerId,
}
impl std::fmt::Debug for NodeCert {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NodeCert")
.field("peer_id", &self.peer_id)
.field("key_pem", &"<redacted>")
.finish()
}
}
impl NodeCert {
pub fn generate_signed(bls_sk: &SecretKey) -> Result<Self> {
Self::generate_signed_by(&DigCa::embedded()?, bls_sk, OffsetDateTime::now_utc())
}
pub fn generate_signed_by(ca: &DigCa, bls_sk: &SecretKey, now: OffsetDateTime) -> Result<Self> {
let leaf_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256)
.map_err(|e| DigTlsError::CertGen(format!("generate leaf key: {e}")))?;
let mut params = CertificateParams::new(Vec::<String>::new())
.map_err(|e| DigTlsError::CertGen(format!("leaf params: {e}")))?;
params.not_before = now - CLOCK_SKEW_BACKDATE;
params.not_after = now + LEAF_LIFETIME;
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, LEAF_SAN);
params.distinguished_name = dn;
let san = Ia5String::try_from(LEAF_SAN.to_string())
.map_err(|e| DigTlsError::CertGen(format!("leaf SAN: {e}")))?;
params.subject_alt_names = vec![SanType::DnsName(san)];
params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
params.extended_key_usages = vec![
ExtendedKeyUsagePurpose::ServerAuth,
ExtendedKeyUsagePurpose::ClientAuth,
];
attach_binding(&mut params, &leaf_key, bls_sk);
let cert = params
.signed_by(&leaf_key, &ca.cert, &ca.key)
.map_err(|e| DigTlsError::CertGen(format!("sign leaf: {e}")))?;
Self::from_parts(
cert.pem(),
Zeroizing::new(leaf_key.serialize_pem()),
&leaf_key,
)
}
pub fn load_or_generate(dir: impl AsRef<Path>, bls_sk: &SecretKey) -> Result<Self> {
let dir = dir.as_ref();
let cert_path = dir.join(CERT_FILE);
let key_path = dir.join(KEY_FILE);
if cert_path.exists() && key_path.exists() {
let cert_pem = fs::read_to_string(&cert_path)?;
let key_pem = read_key_to_zeroizing(&key_path)?;
return Self::from_pem(&cert_pem, &key_pem);
}
let node = Self::generate_signed(bls_sk)?;
fs::create_dir_all(dir)?;
harden_dir_permissions(dir)?;
atomic_write(&cert_path, node.cert_pem.as_bytes(), Secret::No)?;
atomic_write(&key_path, node.key_pem.as_bytes(), Secret::Yes)?;
Ok(node)
}
pub fn rotate(dir: impl AsRef<Path>, new_bls_sk: &SecretKey) -> Result<RotatedNodeCert> {
let dir = dir.as_ref();
let cert_path = dir.join(CERT_FILE);
let key_path = dir.join(KEY_FILE);
if !cert_path.exists() || !key_path.exists() {
return Err(DigTlsError::CertGen(
"rotate: no current node cert to rotate (call load_or_generate first)".into(),
));
}
let prev_cert_path = dir.join(CERT_FILE_PREV);
let prev_key_path = dir.join(KEY_FILE_PREV);
if prev_cert_path.exists() || prev_key_path.exists() {
return Err(DigTlsError::CertGen(
"rotate: a previous rotation is still un-retired (.prev slot present); \
call retire_previous before rotating again"
.into(),
));
}
let previous = {
let cert_pem = fs::read_to_string(&cert_path)?;
let key_pem = read_key_to_zeroizing(&key_path)?;
Self::from_pem(&cert_pem, &key_pem)?
};
let current = Self::generate_signed(new_bls_sk)?;
harden_dir_permissions(dir)?;
atomic_write(&prev_cert_path, previous.cert_pem.as_bytes(), Secret::No)?;
atomic_write(&prev_key_path, previous.key_pem.as_bytes(), Secret::Yes)?;
atomic_write(&cert_path, current.cert_pem.as_bytes(), Secret::No)?;
atomic_write(&key_path, current.key_pem.as_bytes(), Secret::Yes)?;
Ok(RotatedNodeCert { previous, current })
}
pub fn from_pem(cert_pem: &str, key_pem: &str) -> Result<Self> {
let key = KeyPair::from_pem(key_pem)
.map_err(|e| DigTlsError::Parse(format!("parse leaf key: {e}")))?;
Self::from_parts(
cert_pem.to_string(),
Zeroizing::new(key_pem.to_string()),
&key,
)
}
fn from_parts(cert_pem: String, key_pem: Zeroizing<String>, key: &KeyPair) -> Result<Self> {
let cert_der = rustls_pemfile::certs(&mut cert_pem.as_bytes())
.next()
.and_then(|r| r.ok())
.ok_or_else(|| DigTlsError::Parse("leaf PEM has no certificate".into()))?
.to_vec();
let key_der = key.serialize_der();
let spki_der = key.public_key_der();
let (_, x509) = x509_parser::parse_x509_certificate(&cert_der)
.map_err(|e| DigTlsError::Parse(format!("leaf certificate is not valid X.509: {e}")))?;
if x509.tbs_certificate.subject_pki.raw != spki_der.as_slice() {
return Err(DigTlsError::Parse(
"cert/key mismatch: the certificate does not certify the supplied private key"
.into(),
));
}
let peer_id = peer_id_from_tls_spki_der(&spki_der);
Ok(Self {
cert_pem,
key_pem,
cert_der,
key_der: Zeroizing::new(key_der),
spki_der,
peer_id,
})
}
pub fn peer_id(&self) -> PeerId {
self.peer_id
}
pub fn spki_der(&self) -> &[u8] {
&self.spki_der
}
pub fn cert_der(&self) -> &[u8] {
&self.cert_der
}
pub fn cert_pem(&self) -> &str {
&self.cert_pem
}
pub fn key_pem(&self) -> &str {
&self.key_pem
}
pub fn rustls_cert_chain(&self) -> Vec<CertificateDer<'static>> {
vec![CertificateDer::from(self.cert_der.clone())]
}
pub fn rustls_private_key(&self) -> PrivateKeyDer<'static> {
PrivateKeyDer::try_from(self.key_der.to_vec())
.expect("a freshly serialized PKCS#8 key is always a valid PrivateKeyDer")
}
}
pub fn load_previous(dir: impl AsRef<Path>) -> Result<Option<NodeCert>> {
let dir = dir.as_ref();
let cert_path = dir.join(CERT_FILE_PREV);
let key_path = dir.join(KEY_FILE_PREV);
if !cert_path.exists() || !key_path.exists() {
return Ok(None);
}
let cert_pem = fs::read_to_string(&cert_path)?;
let key_pem = fs::read_to_string(&key_path)?;
Ok(Some(NodeCert::from_pem(&cert_pem, &key_pem)?))
}
pub fn retire_previous(dir: impl AsRef<Path>) -> Result<()> {
let dir = dir.as_ref();
let cert_path = dir.join(CERT_FILE_PREV);
let key_path = dir.join(KEY_FILE_PREV);
if key_path.exists() {
drop(Zeroizing::new(fs::read(&key_path)?));
fs::remove_file(&key_path)?;
}
if cert_path.exists() {
fs::remove_file(&cert_path)?;
}
Ok(())
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Secret {
Yes,
No,
}
fn read_key_to_zeroizing(path: &Path) -> Result<Zeroizing<String>> {
Ok(Zeroizing::new(fs::read_to_string(path)?))
}
fn tmp_path(dest: &Path) -> PathBuf {
let mut name = dest.as_os_str().to_owned();
name.push(".tmp");
PathBuf::from(name)
}
fn atomic_write(dest: &Path, contents: &[u8], secret: Secret) -> Result<()> {
let tmp = tmp_path(dest);
match secret {
Secret::Yes => write_key_file(&tmp, contents)?,
Secret::No => fs::write(&tmp, contents)?,
}
fs::OpenOptions::new().write(true).open(&tmp)?.sync_all()?;
atomic_rename(&tmp, dest)?;
if let Some(parent) = dest.parent() {
fsync_dir(parent)?;
}
Ok(())
}
#[cfg(unix)]
fn atomic_rename(from: &Path, to: &Path) -> Result<()> {
fs::rename(from, to)?;
Ok(())
}
#[cfg(windows)]
fn atomic_rename(from: &Path, to: &Path) -> Result<()> {
match fs::rename(from, to) {
Ok(()) => Ok(()),
Err(_) if to.exists() => {
fs::remove_file(to)?;
fs::rename(from, to)?;
Ok(())
}
Err(e) => Err(e.into()),
}
}
#[cfg(unix)]
fn fsync_dir(dir: &Path) -> Result<()> {
fs::File::open(dir)?.sync_all()?;
Ok(())
}
#[cfg(windows)]
fn fsync_dir(_dir: &Path) -> Result<()> {
Ok(())
}
#[cfg(unix)]
fn harden_dir_permissions(dir: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?;
Ok(())
}
#[cfg(windows)]
fn harden_dir_permissions(_dir: &Path) -> Result<()> {
Ok(())
}
#[cfg(unix)]
fn write_key_file(path: &Path, key_pem: &[u8]) -> Result<()> {
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
file.write_all(key_pem)?;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
Ok(())
}
#[cfg(windows)]
fn write_key_file(path: &Path, key_pem: &[u8]) -> Result<()> {
fs::write(path, key_pem)?;
if let (Some(path_str), Ok(user)) = (path.to_str(), std::env::var("USERNAME")) {
let _ = std::process::Command::new("icacls")
.args([path_str, "/inheritance:r", "/grant:r", &format!("{user}:F")])
.output();
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::binding::{verify_binding_from_leaf_cert, BindingOutcome};
use crate::bls::public_key_bytes;
use crate::ca::generate_dig_ca;
use sha2::{Digest, Sha256};
fn test_ca() -> DigCa {
let m = generate_dig_ca(OffsetDateTime::now_utc()).unwrap();
DigCa::from_pem(&m.cert_pem, &m.key_pem).unwrap()
}
fn bls_sk(label: &str) -> SecretKey {
let seed: [u8; 32] = Sha256::digest(label.as_bytes()).into();
SecretKey::from_seed(&seed)
}
#[test]
fn generated_cert_binds_peer_id_to_the_bls_key() {
let ca = test_ca();
let sk = bls_sk("node-cert/bind");
let node = NodeCert::generate_signed_by(&ca, &sk, OffsetDateTime::now_utc()).unwrap();
let expected: [u8; 32] = Sha256::digest(node.spki_der()).into();
assert_eq!(node.peer_id().as_bytes(), &expected);
match verify_binding_from_leaf_cert(node.cert_der()) {
BindingOutcome::Bound { bls_pub } => assert_eq!(bls_pub, public_key_bytes(&sk)),
other => panic!("expected Bound, got {other:?}"),
}
}
#[test]
fn distinct_peers_get_distinct_ids() {
let ca = test_ca();
let a = NodeCert::generate_signed_by(&ca, &bls_sk("a"), OffsetDateTime::now_utc()).unwrap();
let b = NodeCert::generate_signed_by(&ca, &bls_sk("b"), OffsetDateTime::now_utc()).unwrap();
assert_ne!(a.peer_id(), b.peer_id());
}
#[test]
fn pem_round_trips_preserving_peer_id() {
let ca = test_ca();
let node =
NodeCert::generate_signed_by(&ca, &bls_sk("rt"), OffsetDateTime::now_utc()).unwrap();
let restored = NodeCert::from_pem(node.cert_pem(), node.key_pem()).unwrap();
assert_eq!(node.peer_id(), restored.peer_id());
}
#[test]
fn load_or_generate_is_stable_across_calls() {
let dir = tempfile::tempdir().unwrap();
let sk = bls_sk("persist");
let first = NodeCert::load_or_generate(dir.path(), &sk).unwrap();
let second = NodeCert::load_or_generate(dir.path(), &sk).unwrap();
assert_eq!(
first.peer_id(),
second.peer_id(),
"a persisted cert is reloaded, not regenerated"
);
}
#[test]
#[cfg(unix)]
fn load_or_generate_persists_the_key_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let sk = bls_sk("perms");
NodeCert::load_or_generate(dir.path(), &sk).unwrap();
let dir_mode = fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777;
assert_eq!(dir_mode, 0o700, "cert directory must be owner-only");
let key_mode = fs::metadata(dir.path().join(KEY_FILE))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(key_mode, 0o600, "private key file must be owner-only");
}
fn bls_pub_in_cert(cert_der: &[u8]) -> [u8; 48] {
match verify_binding_from_leaf_cert(cert_der) {
BindingOutcome::Bound { bls_pub } => bls_pub,
other => panic!("expected Bound, got {other:?}"),
}
}
#[test]
fn rotate_yields_a_new_peer_id() {
let dir = tempfile::tempdir().unwrap();
let before = NodeCert::load_or_generate(dir.path(), &bls_sk("rotate/before")).unwrap();
let old_peer_id = before.peer_id();
drop(before);
let rotated = NodeCert::rotate(dir.path(), &bls_sk("rotate/after")).unwrap();
assert_eq!(rotated.previous().peer_id(), old_peer_id);
assert_ne!(
rotated.current().peer_id(),
old_peer_id,
"rotation mints a fresh key, so the peer_id changes"
);
}
#[test]
fn rotate_returns_two_valid_spki_bound_certs() {
let dir = tempfile::tempdir().unwrap();
let old_sk = bls_sk("rotate/valid-old");
let new_sk = bls_sk("rotate/valid-new");
NodeCert::load_or_generate(dir.path(), &old_sk).unwrap();
let rotated = NodeCert::rotate(dir.path(), &new_sk).unwrap();
for node in [rotated.previous(), rotated.current()] {
let expected: [u8; 32] = Sha256::digest(node.spki_der()).into();
assert_eq!(node.peer_id().as_bytes(), &expected);
}
assert_eq!(
bls_pub_in_cert(rotated.previous().cert_der()),
public_key_bytes(&old_sk)
);
assert_eq!(
bls_pub_in_cert(rotated.current().cert_der()),
public_key_bytes(&new_sk)
);
}
#[test]
fn rotate_persists_current_and_previous_slots() {
let dir = tempfile::tempdir().unwrap();
NodeCert::load_or_generate(dir.path(), &bls_sk("rotate/persist-old")).unwrap();
let rotated = NodeCert::rotate(dir.path(), &bls_sk("rotate/persist-new")).unwrap();
let reloaded = NodeCert::load_or_generate(dir.path(), &bls_sk("unused")).unwrap();
assert_eq!(reloaded.peer_id(), rotated.current().peer_id());
let prev = load_previous(dir.path())
.unwrap()
.expect("a .prev slot exists after rotate");
assert_eq!(prev.peer_id(), rotated.previous().peer_id());
}
#[test]
#[cfg(unix)]
fn rotate_persists_both_keys_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
NodeCert::load_or_generate(dir.path(), &bls_sk("rotate/perm-old")).unwrap();
NodeCert::rotate(dir.path(), &bls_sk("rotate/perm-new")).unwrap();
for f in [KEY_FILE, KEY_FILE_PREV] {
let mode = fs::metadata(dir.path().join(f))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "{f} must be owner-only after rotate");
}
}
#[test]
fn retire_previous_deletes_the_prev_slot() {
let dir = tempfile::tempdir().unwrap();
NodeCert::load_or_generate(dir.path(), &bls_sk("retire/old")).unwrap();
let rotated = NodeCert::rotate(dir.path(), &bls_sk("retire/new")).unwrap();
let current_peer_id = rotated.current().peer_id();
retire_previous(dir.path()).unwrap();
assert!(
!dir.path().join(CERT_FILE_PREV).exists(),
"prev cert deleted"
);
assert!(!dir.path().join(KEY_FILE_PREV).exists(), "prev key deleted");
assert!(
load_previous(dir.path()).unwrap().is_none(),
"no .prev after retire"
);
let reloaded = NodeCert::load_or_generate(dir.path(), &bls_sk("unused")).unwrap();
assert_eq!(reloaded.peer_id(), current_peer_id);
}
#[test]
fn retire_previous_is_a_noop_without_a_prev_slot() {
let dir = tempfile::tempdir().unwrap();
NodeCert::load_or_generate(dir.path(), &bls_sk("retire/noop")).unwrap();
retire_previous(dir.path()).unwrap();
retire_previous(dir.path()).unwrap();
}
#[test]
fn rotate_requires_an_existing_current_cert() {
let dir = tempfile::tempdir().unwrap();
assert!(NodeCert::rotate(dir.path(), &bls_sk("rotate/empty")).is_err());
}
#[test]
fn load_previous_is_none_for_a_pre_rotate_dir() {
let dir = tempfile::tempdir().unwrap();
NodeCert::load_or_generate(dir.path(), &bls_sk("prev/none")).unwrap();
assert!(load_previous(dir.path()).unwrap().is_none());
}
#[test]
fn old_single_cert_dir_still_loads() {
let dir = tempfile::tempdir().unwrap();
let sk = bls_sk("compat/single");
let original = NodeCert::load_or_generate(dir.path(), &sk).unwrap();
let original_peer_id = original.peer_id();
drop(original);
assert!(!dir.path().join(CERT_FILE_PREV).exists());
assert!(load_previous(dir.path()).unwrap().is_none());
let reloaded = NodeCert::load_or_generate(dir.path(), &sk).unwrap();
assert_eq!(
reloaded.peer_id(),
original_peer_id,
"old single-cert dir loads identically"
);
}
#[test]
fn from_pem_rejects_a_mismatched_cert_and_key() {
let ca = test_ca();
let a = NodeCert::generate_signed_by(&ca, &bls_sk("mismatch/a"), OffsetDateTime::now_utc())
.unwrap();
let b = NodeCert::generate_signed_by(&ca, &bls_sk("mismatch/b"), OffsetDateTime::now_utc())
.unwrap();
let err = NodeCert::from_pem(a.cert_pem(), b.key_pem())
.expect_err("a mismatched cert+key pair must be rejected");
assert!(matches!(err, DigTlsError::Parse(_)), "got {err:?}");
assert!(NodeCert::from_pem(a.cert_pem(), a.key_pem()).is_ok());
assert!(NodeCert::from_pem(b.cert_pem(), b.key_pem()).is_ok());
}
#[test]
fn rotate_twice_without_retiring_is_rejected() {
let dir = tempfile::tempdir().unwrap();
NodeCert::load_or_generate(dir.path(), &bls_sk("double/old")).unwrap();
let first = NodeCert::rotate(dir.path(), &bls_sk("double/one")).unwrap();
let first_current = first.current().peer_id();
drop(first);
let err = NodeCert::rotate(dir.path(), &bls_sk("double/two"))
.expect_err("a second rotation before retiring the first must error");
assert!(matches!(err, DigTlsError::CertGen(_)), "got {err:?}");
let reloaded = NodeCert::load_or_generate(dir.path(), &bls_sk("unused")).unwrap();
assert_eq!(reloaded.peer_id(), first_current);
drop(reloaded);
retire_previous(dir.path()).unwrap();
let second = NodeCert::rotate(dir.path(), &bls_sk("double/three")).unwrap();
assert_eq!(second.previous().peer_id(), first_current);
assert_ne!(second.current().peer_id(), first_current);
}
#[test]
fn debug_never_leaks_the_key() {
let ca = test_ca();
let node =
NodeCert::generate_signed_by(&ca, &bls_sk("dbg"), OffsetDateTime::now_utc()).unwrap();
assert!(format!("{node:?}").contains("<redacted>"));
}
}