use std::fs;
use std::path::Path;
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";
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(), 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 = fs::read_to_string(&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)?;
fs::write(&cert_path, node.cert_pem.as_bytes())?;
write_key_file(&key_path, node.key_pem.as_bytes())?;
Ok(node)
}
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(), key_pem.to_string(), &key)
}
fn from_parts(cert_pem: String, key_pem: 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 peer_id = peer_id_from_tls_spki_der(&spki_der);
Ok(Self {
cert_pem,
key_pem: Zeroizing::new(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")
}
}
#[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");
}
#[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>"));
}
}