Skip to main content

dig_tls/
node_cert.rs

1//! The per-peer node certificate — generated locally at first run, signed by the DigNetwork CA.
2//!
3//! Every DIG peer mints ONE leaf certificate (mirroring Chia's `create_all_ssl`): a fresh ECDSA
4//! P-256 TLS key pair, a leaf signed by the shipped [`crate::ca::DigCa`], carrying the #1204 BLS-G1
5//! binding ([`crate::binding`]). The leaf's `peer_id = SHA-256(SPKI DER)` is the peer's transport
6//! identity. The cert serves BOTH directions of mutual TLS (it has `serverAuth` + `clientAuth` EKUs),
7//! so the same `NodeCert` is presented whether the peer dials out or accepts a dial.
8//!
9//! The cert + key are persisted PEM under a caller-chosen directory and regenerated only if absent,
10//! so a peer keeps a stable `peer_id` across restarts.
11
12use std::fs;
13use std::path::{Path, PathBuf};
14
15use rcgen::{
16    CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose, Ia5String, KeyPair,
17    KeyUsagePurpose, SanType, PKCS_ECDSA_P256_SHA256,
18};
19use rustls_pki_types::{CertificateDer, PrivateKeyDer};
20use time::{Duration, OffsetDateTime};
21use zeroize::Zeroizing;
22
23use crate::binding::attach_binding;
24use crate::bls::SecretKey;
25use crate::ca::{DigCa, CLOCK_SKEW_BACKDATE};
26use crate::error::{DigTlsError, Result};
27use crate::identity::{peer_id_from_tls_spki_der, PeerId};
28
29/// Leaf validity window: 10 years. A DIG peer's identity is its `peer_id` + BLS binding, not the
30/// cert's lifetime, so a long-lived leaf keeps the identity stable without any renewal machinery at
31/// this foundation layer. (A future consumer MAY rotate by deleting the persisted pair.)
32pub const LEAF_LIFETIME: Duration = Duration::days(365 * 10);
33
34/// The single SAN on a DIG peer leaf. Peers authenticate by `peer_id` + BLS binding, NOT by
35/// hostname, so the SAN is a fixed, non-load-bearing placeholder (the rustls verifiers do not check
36/// it — see [`crate::verify`]).
37const LEAF_SAN: &str = "peer.dig";
38
39/// The on-disk file names for the persisted node cert + key.
40const CERT_FILE: &str = "node.crt";
41const KEY_FILE: &str = "node.key";
42
43/// The on-disk file names for the RETIRING (previous) cert + key, written by [`NodeCert::rotate`] and
44/// deleted by [`retire_previous`]. These are NEW, additive files — the current identity always stays
45/// in [`CERT_FILE`]/[`KEY_FILE`], so a reader that predates rotation still loads unchanged (§5.1).
46const CERT_FILE_PREV: &str = "node.crt.prev";
47const KEY_FILE_PREV: &str = "node.key.prev";
48
49/// The outcome of a machine-key rotation ([`NodeCert::rotate`]): the retiring `previous` identity and
50/// the freshly minted `current` one.
51///
52/// Because `peer_id = SHA-256(SPKI DER)` and the SPKI commits the BLS binding, a rotation mints a
53/// brand-new key pair and therefore a brand-new `peer_id` — this is an IDENTITY CHANGE, not a cert
54/// renewal. dig-tls is a library and does NOT do networking; it hands back BOTH identities so the
55/// CALLER (dig-node) can dual-present — keep accepting inbound on the old `peer_id` while it
56/// re-announces the new `peer_id` to DHT/PEX/relay — then call [`retire_previous`] once the
57/// re-announce converges.
58///
59/// The derived `Debug` renders only each identity's redacting [`NodeCert`] `Debug` — never key bytes.
60#[derive(Debug)]
61pub struct RotatedNodeCert {
62    previous: NodeCert,
63    current: NodeCert,
64}
65
66impl RotatedNodeCert {
67    /// The retiring identity. Present it (and accept inbound on its `peer_id`) during the overlap
68    /// window, until the caller's re-announce converges and it calls [`retire_previous`].
69    pub fn previous(&self) -> &NodeCert {
70        &self.previous
71    }
72
73    /// The freshly minted identity to advertise going forward.
74    pub fn current(&self) -> &NodeCert {
75        &self.current
76    }
77
78    /// Consume the rotation, keeping only the new current identity (drops + scrubs the previous key).
79    pub fn into_current(self) -> NodeCert {
80        self.current
81    }
82}
83
84/// A peer's mTLS identity certificate + private key, plus its derived `peer_id`.
85///
86/// The private key is held in [`Zeroizing`] so every clone/drop scrubs the plaintext PKCS#8 bytes
87/// from freed heap. [`NodeCert`] deliberately does not derive `Clone` for the same reason — pass a
88/// reference.
89pub struct NodeCert {
90    cert_pem: String,
91    key_pem: Zeroizing<String>,
92    cert_der: Vec<u8>,
93    key_der: Zeroizing<Vec<u8>>,
94    spki_der: Vec<u8>,
95    peer_id: PeerId,
96}
97
98impl std::fmt::Debug for NodeCert {
99    /// Never renders the private key material.
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        f.debug_struct("NodeCert")
102            .field("peer_id", &self.peer_id)
103            .field("key_pem", &"<redacted>")
104            .finish()
105    }
106}
107
108impl NodeCert {
109    /// Generate a new node cert signed by the shipped, public DigNetwork CA (the common path).
110    pub fn generate_signed(bls_sk: &SecretKey) -> Result<Self> {
111        Self::generate_signed_by(&DigCa::embedded()?, bls_sk, OffsetDateTime::now_utc())
112    }
113
114    /// Generate a new node cert signed by an explicit CA at an explicit issuance time (used by tests
115    /// with a throwaway CA, and internally by [`Self::generate_signed`]).
116    pub fn generate_signed_by(ca: &DigCa, bls_sk: &SecretKey, now: OffsetDateTime) -> Result<Self> {
117        let leaf_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256)
118            .map_err(|e| DigTlsError::CertGen(format!("generate leaf key: {e}")))?;
119
120        let mut params = CertificateParams::new(Vec::<String>::new())
121            .map_err(|e| DigTlsError::CertGen(format!("leaf params: {e}")))?;
122        params.not_before = now - CLOCK_SKEW_BACKDATE;
123        params.not_after = now + LEAF_LIFETIME;
124
125        let mut dn = DistinguishedName::new();
126        dn.push(DnType::CommonName, LEAF_SAN);
127        params.distinguished_name = dn;
128
129        let san = Ia5String::try_from(LEAF_SAN.to_string())
130            .map_err(|e| DigTlsError::CertGen(format!("leaf SAN: {e}")))?;
131        params.subject_alt_names = vec![SanType::DnsName(san)];
132        params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
133        // Both EKUs so the ONE leaf authenticates in either direction of the mutual-TLS handshake.
134        params.extended_key_usages = vec![
135            ExtendedKeyUsagePurpose::ServerAuth,
136            ExtendedKeyUsagePurpose::ClientAuth,
137        ];
138
139        // Bind peer_id ↔ BLS key BEFORE signing (the extension is part of the signed TBS cert).
140        attach_binding(&mut params, &leaf_key, bls_sk);
141
142        let cert = params
143            .signed_by(&leaf_key, &ca.cert, &ca.key)
144            .map_err(|e| DigTlsError::CertGen(format!("sign leaf: {e}")))?;
145
146        Self::from_parts(
147            cert.pem(),
148            Zeroizing::new(leaf_key.serialize_pem()),
149            &leaf_key,
150        )
151    }
152
153    /// Load a persisted node cert + key from `dir`, or generate + persist a new one signed by the
154    /// shipped DigNetwork CA if either file is absent. Keeps a peer's `peer_id` stable across restarts.
155    pub fn load_or_generate(dir: impl AsRef<Path>, bls_sk: &SecretKey) -> Result<Self> {
156        let dir = dir.as_ref();
157        let cert_path = dir.join(CERT_FILE);
158        let key_path = dir.join(KEY_FILE);
159        if cert_path.exists() && key_path.exists() {
160            let cert_pem = fs::read_to_string(&cert_path)?;
161            let key_pem = read_key_to_zeroizing(&key_path)?;
162            return Self::from_pem(&cert_pem, &key_pem);
163        }
164        let node = Self::generate_signed(bls_sk)?;
165        fs::create_dir_all(dir)?;
166        harden_dir_permissions(dir)?;
167        atomic_write(&cert_path, node.cert_pem.as_bytes(), Secret::No)?;
168        atomic_write(&key_path, node.key_pem.as_bytes(), Secret::Yes)?;
169        Ok(node)
170    }
171
172    /// Rotate this peer's machine key: mint a FRESH `(TLS leaf, cert)` bound to `new_bls_sk`, persist
173    /// it as the new current identity, and demote the existing on-disk pair to the additive `.prev`
174    /// slot — WITHOUT losing it — so the caller can dual-present during the overlap window.
175    ///
176    /// The new leaf key means a new SPKI and therefore a new `peer_id`: rotation is an IDENTITY
177    /// CHANGE (see [`RotatedNodeCert`]). The caller mints the new BLS identity secret in dig-identity
178    /// and passes it in here; dig-tls never derives or stores a BLS key (mirroring
179    /// [`Self::generate_signed`]). Cert-EXPIRY renewal under the SAME key is a separate, cheaper path
180    /// — reissue with [`Self::generate_signed`] using the existing BLS secret; the `peer_id` is
181    /// preserved only when the SAME TLS leaf key is reused, which this rotation deliberately is NOT.
182    ///
183    /// The existing `dir` MUST already hold a current cert + key (rotation replaces a live identity).
184    /// The persisted key files stay owner-only `0600` (see [`write_key_file`]).
185    pub fn rotate(dir: impl AsRef<Path>, new_bls_sk: &SecretKey) -> Result<RotatedNodeCert> {
186        let dir = dir.as_ref();
187        let cert_path = dir.join(CERT_FILE);
188        let key_path = dir.join(KEY_FILE);
189        if !cert_path.exists() || !key_path.exists() {
190            return Err(DigTlsError::CertGen(
191                "rotate: no current node cert to rotate (call load_or_generate first)".into(),
192            ));
193        }
194
195        // Refuse to rotate while a prior rotation is still un-retired: a populated `.prev` slot means
196        // an identity is mid-overlap. Overwriting it would silently DISCARD that in-flight retiring
197        // identity (the caller may still be accepting inbound on its peer_id), so require the caller to
198        // `retire_previous` first — one `.prev` generation at a time.
199        let prev_cert_path = dir.join(CERT_FILE_PREV);
200        let prev_key_path = dir.join(KEY_FILE_PREV);
201        if prev_cert_path.exists() || prev_key_path.exists() {
202            return Err(DigTlsError::CertGen(
203                "rotate: a previous rotation is still un-retired (.prev slot present); \
204                 call retire_previous before rotating again"
205                    .into(),
206            ));
207        }
208
209        // Load the identity being retired BEFORE touching disk, so a mid-rotation failure never
210        // loses the live key: the caller still holds it in the returned `previous`.
211        let previous = {
212            let cert_pem = fs::read_to_string(&cert_path)?;
213            let key_pem = read_key_to_zeroizing(&key_path)?;
214            Self::from_pem(&cert_pem, &key_pem)?
215        };
216
217        // Mint the fresh identity (new leaf key ⇒ new SPKI ⇒ new peer_id) bound to the caller's new
218        // BLS secret. Generate it in full BEFORE any file write, so a generation error leaves the
219        // on-disk current identity untouched.
220        let current = Self::generate_signed(new_bls_sk)?;
221
222        // Persist the retiring pair into the additive `.prev` slot FIRST (so the old key is durable in
223        // two places), THEN atomically replace the current slot. Each `atomic_write` writes a sibling
224        // `.tmp`, fsyncs it, renames it over the target (an atomic same-dir replace), and fsyncs the
225        // parent dir — so a crash at any point leaves EITHER the intact old current or the intact new
226        // one in `node.crt`/`node.key`, never a torn/truncated half-write of either.
227        harden_dir_permissions(dir)?;
228        atomic_write(&prev_cert_path, previous.cert_pem.as_bytes(), Secret::No)?;
229        atomic_write(&prev_key_path, previous.key_pem.as_bytes(), Secret::Yes)?;
230        atomic_write(&cert_path, current.cert_pem.as_bytes(), Secret::No)?;
231        atomic_write(&key_path, current.key_pem.as_bytes(), Secret::Yes)?;
232
233        Ok(RotatedNodeCert { previous, current })
234    }
235
236    /// Reconstruct a [`NodeCert`] from persisted PEM (its cert + private key).
237    pub fn from_pem(cert_pem: &str, key_pem: &str) -> Result<Self> {
238        let key = KeyPair::from_pem(key_pem)
239            .map_err(|e| DigTlsError::Parse(format!("parse leaf key: {e}")))?;
240        Self::from_parts(
241            cert_pem.to_string(),
242            Zeroizing::new(key_pem.to_string()),
243            &key,
244        )
245    }
246
247    /// Assemble a [`NodeCert`] from its PEM parts, deriving the DER forms + `peer_id` once.
248    ///
249    /// Enforces cert⇔key consistency: the certificate MUST certify the SAME public key the private key
250    /// holds (SPKI DER equal). A mismatched pair — a cert paired with the wrong key on disk, or a
251    /// half-completed swap of one slot but not the other — is REJECTED rather than loaded, so a peer
252    /// never presents a cert it cannot prove possession of.
253    fn from_parts(cert_pem: String, key_pem: Zeroizing<String>, key: &KeyPair) -> Result<Self> {
254        let cert_der = rustls_pemfile::certs(&mut cert_pem.as_bytes())
255            .next()
256            .and_then(|r| r.ok())
257            .ok_or_else(|| DigTlsError::Parse("leaf PEM has no certificate".into()))?
258            .to_vec();
259        let key_der = key.serialize_der();
260        let spki_der = key.public_key_der();
261
262        // The cert must certify this exact key: compare the cert's SubjectPublicKeyInfo DER to the
263        // key's own SPKI DER. peer_id is derived from the KEY's SPKI, so a silent mismatch would pin a
264        // peer_id the presented cert does not actually carry.
265        let (_, x509) = x509_parser::parse_x509_certificate(&cert_der)
266            .map_err(|e| DigTlsError::Parse(format!("leaf certificate is not valid X.509: {e}")))?;
267        if x509.tbs_certificate.subject_pki.raw != spki_der.as_slice() {
268            return Err(DigTlsError::Parse(
269                "cert/key mismatch: the certificate does not certify the supplied private key"
270                    .into(),
271            ));
272        }
273
274        let peer_id = peer_id_from_tls_spki_der(&spki_der);
275        Ok(Self {
276            cert_pem,
277            key_pem,
278            cert_der,
279            key_der: Zeroizing::new(key_der),
280            spki_der,
281            peer_id,
282        })
283    }
284
285    /// This peer's transport identity, `peer_id = SHA-256(SPKI DER)`.
286    pub fn peer_id(&self) -> PeerId {
287        self.peer_id
288    }
289
290    /// The leaf's SubjectPublicKeyInfo DER (what `peer_id` is the SHA-256 of).
291    pub fn spki_der(&self) -> &[u8] {
292        &self.spki_der
293    }
294
295    /// The leaf certificate in DER form.
296    pub fn cert_der(&self) -> &[u8] {
297        &self.cert_der
298    }
299
300    /// The leaf certificate, PEM-encoded (for persistence / inspection).
301    pub fn cert_pem(&self) -> &str {
302        &self.cert_pem
303    }
304
305    /// The private key, PEM-encoded. Handle with care — this is secret-ADJACENT (the key authorizes
306    /// the peer's identity, though the DigNetwork CA itself is public).
307    pub fn key_pem(&self) -> &str {
308        &self.key_pem
309    }
310
311    /// The rustls certificate chain to present in a handshake (just the leaf — the DigNetwork CA is a
312    /// well-known trust anchor every peer already embeds, so it is not sent on the wire).
313    pub fn rustls_cert_chain(&self) -> Vec<CertificateDer<'static>> {
314        vec![CertificateDer::from(self.cert_der.clone())]
315    }
316
317    /// The rustls private key for the handshake.
318    pub fn rustls_private_key(&self) -> PrivateKeyDer<'static> {
319        PrivateKeyDer::try_from(self.key_der.to_vec())
320            .expect("a freshly serialized PKCS#8 key is always a valid PrivateKeyDer")
321    }
322}
323
324/// Load the RETIRING (previous) identity persisted by [`NodeCert::rotate`], if a `.prev` slot exists.
325///
326/// Lets a caller resume dual-presenting the old `peer_id` after a restart that happened mid-overlap
327/// (before [`retire_previous`] ran). Returns `Ok(None)` when no `.prev` slot is present.
328pub fn load_previous(dir: impl AsRef<Path>) -> Result<Option<NodeCert>> {
329    let dir = dir.as_ref();
330    let cert_path = dir.join(CERT_FILE_PREV);
331    let key_path = dir.join(KEY_FILE_PREV);
332    if !cert_path.exists() || !key_path.exists() {
333        return Ok(None);
334    }
335    let cert_pem = fs::read_to_string(&cert_path)?;
336    let key_pem = fs::read_to_string(&key_path)?;
337    Ok(Some(NodeCert::from_pem(&cert_pem, &key_pem)?))
338}
339
340/// Retire the previous identity: ZEROIZE the in-memory copy of the old key and delete both `.prev`
341/// files. Call this only AFTER the caller's re-announce of the new `peer_id` has converged, since it
342/// makes the old identity permanently unrecoverable. A no-op (returns `Ok(())`) when no `.prev` slot
343/// exists, so it is safe to call unconditionally.
344pub fn retire_previous(dir: impl AsRef<Path>) -> Result<()> {
345    let dir = dir.as_ref();
346    let cert_path = dir.join(CERT_FILE_PREV);
347    let key_path = dir.join(KEY_FILE_PREV);
348    if key_path.exists() {
349        // Read the old key into a scrubbing buffer and let it drop: this zeroizes the plaintext key
350        // bytes in our address space. (Portable filesystems cannot guarantee the on-disk blocks are
351        // physically overwritten; deleting the file is the strongest cross-platform guarantee.)
352        drop(Zeroizing::new(fs::read(&key_path)?));
353        fs::remove_file(&key_path)?;
354    }
355    if cert_path.exists() {
356        fs::remove_file(&cert_path)?;
357    }
358    Ok(())
359}
360
361/// Whether an [`atomic_write`] target holds secret key material (created `0600` / ACL-hardened) or a
362/// public certificate.
363#[derive(Clone, Copy, PartialEq, Eq)]
364enum Secret {
365    Yes,
366    No,
367}
368
369/// Read a private-key PEM file into a scrubbing buffer so the intermediate plaintext PEM never lands
370/// in a plain, non-zeroized `String` that lingers on the heap after use.
371fn read_key_to_zeroizing(path: &Path) -> Result<Zeroizing<String>> {
372    Ok(Zeroizing::new(fs::read_to_string(path)?))
373}
374
375/// The sibling temporary path an [`atomic_write`] stages `dest` through (e.g. `node.key.tmp`).
376fn tmp_path(dest: &Path) -> PathBuf {
377    let mut name = dest.as_os_str().to_owned();
378    name.push(".tmp");
379    PathBuf::from(name)
380}
381
382/// Durably and atomically write `contents` to `dest`: stage a sibling `.tmp`, fsync its bytes, rename
383/// it over `dest` (an atomic same-directory replace), then fsync the parent directory so the rename
384/// itself survives a crash. A `Secret::Yes` target's tmp file is created owner-only `0600` (Unix) /
385/// ACL-hardened (Windows) via [`write_key_file`], so key material is never briefly world-readable even
386/// in the staging file. This replaces the old in-place truncate-then-write, which could leave a torn
387/// (partially written / truncated) current slot if the process died mid-write.
388fn atomic_write(dest: &Path, contents: &[u8], secret: Secret) -> Result<()> {
389    let tmp = tmp_path(dest);
390    match secret {
391        Secret::Yes => write_key_file(&tmp, contents)?,
392        Secret::No => fs::write(&tmp, contents)?,
393    }
394    // Flush the staged bytes to stable storage BEFORE the rename exposes them as `dest`.
395    fs::OpenOptions::new().write(true).open(&tmp)?.sync_all()?;
396    atomic_rename(&tmp, dest)?;
397    if let Some(parent) = dest.parent() {
398        fsync_dir(parent)?;
399    }
400    Ok(())
401}
402
403/// Atomically replace `to` with `from`. POSIX `rename(2)` is atomic within a directory and replaces an
404/// existing destination in one step.
405#[cfg(unix)]
406fn atomic_rename(from: &Path, to: &Path) -> Result<()> {
407    fs::rename(from, to)?;
408    Ok(())
409}
410
411/// Atomically replace `to` with `from` on Windows. `fs::rename` maps to `MoveFileExW` with
412/// `REPLACE_EXISTING` on modern Rust, but historically failed when the destination existed; fall back
413/// to remove-then-rename in that case. The staged `.tmp` still holds the full new contents throughout,
414/// so a crash in the sub-millisecond window between remove and rename is recovered on the next write.
415#[cfg(windows)]
416fn atomic_rename(from: &Path, to: &Path) -> Result<()> {
417    match fs::rename(from, to) {
418        Ok(()) => Ok(()),
419        Err(_) if to.exists() => {
420            fs::remove_file(to)?;
421            fs::rename(from, to)?;
422            Ok(())
423        }
424        Err(e) => Err(e.into()),
425    }
426}
427
428/// Fsync a directory so a rename into it is durable (POSIX: an fsync of the file's data does not
429/// guarantee the directory entry is on stable storage).
430#[cfg(unix)]
431fn fsync_dir(dir: &Path) -> Result<()> {
432    fs::File::open(dir)?.sync_all()?;
433    Ok(())
434}
435
436/// Windows cannot fsync a directory handle the POSIX way; `MoveFileExW` with `WRITE_THROUGH` semantics
437/// makes the rename durable on its own, so there is nothing further to do here.
438#[cfg(windows)]
439fn fsync_dir(_dir: &Path) -> Result<()> {
440    Ok(())
441}
442
443/// Restrict `dir` to owner-only access (`0700`) before any secret is written into it.
444///
445/// The leaf private key is the peer's long-lived (10yr) transport-identity secret — the same
446/// material Chia's `create_ssl.py` chmods `0600`. Without this, `fs::create_dir_all` leaves the
447/// directory at the process umask default (commonly `0755`), letting any local unprivileged user
448/// read the key and fully impersonate the peer (the SPKI pin + #1204 BLS binding are genuine, so a
449/// stolen key is a genuine identity, not a detectable forgery).
450#[cfg(unix)]
451fn harden_dir_permissions(dir: &Path) -> Result<()> {
452    use std::os::unix::fs::PermissionsExt;
453    fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?;
454    Ok(())
455}
456
457/// Windows has no POSIX mode bits; directory ACL hardening is handled per-file on the key itself
458/// (see [`write_key_file`]), so there is nothing additional to do here.
459#[cfg(windows)]
460fn harden_dir_permissions(_dir: &Path) -> Result<()> {
461    Ok(())
462}
463
464/// Persist the private key PEM at `path` with owner-only access, closing the world-readable window
465/// a plain `fs::write` would leave open at the process umask default.
466#[cfg(unix)]
467fn write_key_file(path: &Path, key_pem: &[u8]) -> Result<()> {
468    use std::io::Write;
469    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
470
471    // Create with 0600 baked into the `open(2)` call itself — no window where the key is briefly
472    // world-readable between "write the file" and "chmod it after".
473    let mut file = fs::OpenOptions::new()
474        .write(true)
475        .create(true)
476        .truncate(true)
477        .mode(0o600)
478        .open(path)?;
479    file.write_all(key_pem)?;
480    // `mode()` on open() is masked by umask on some platforms; re-assert explicitly so the key is
481    // always 0600 regardless of the caller's umask.
482    fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
483    Ok(())
484}
485
486/// Best-effort ACL hardening on Windows: strip inherited access and grant only the current user.
487///
488/// This is defense-in-depth, not the gating fix (the exploit this PR closes is the Unix `0644`
489/// world-readable case). `icacls` ships with every supported Windows version, so shelling out to it
490/// avoids pulling a heavyweight ACL crate for a best-effort hardening step; a failure here is logged
491/// as a best-effort miss, not a hard error, since the key is still written successfully.
492#[cfg(windows)]
493fn write_key_file(path: &Path, key_pem: &[u8]) -> Result<()> {
494    fs::write(path, key_pem)?;
495    if let (Some(path_str), Ok(user)) = (path.to_str(), std::env::var("USERNAME")) {
496        // Remove inherited ACEs, then grant only the current user full control.
497        let _ = std::process::Command::new("icacls")
498            .args([path_str, "/inheritance:r", "/grant:r", &format!("{user}:F")])
499            .output();
500    }
501    Ok(())
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507    use crate::binding::{verify_binding_from_leaf_cert, BindingOutcome};
508    use crate::bls::public_key_bytes;
509    use crate::ca::generate_dig_ca;
510    use sha2::{Digest, Sha256};
511
512    fn test_ca() -> DigCa {
513        let m = generate_dig_ca(OffsetDateTime::now_utc()).unwrap();
514        DigCa::from_pem(&m.cert_pem, &m.key_pem).unwrap()
515    }
516
517    fn bls_sk(label: &str) -> SecretKey {
518        let seed: [u8; 32] = Sha256::digest(label.as_bytes()).into();
519        SecretKey::from_seed(&seed)
520    }
521
522    #[test]
523    fn generated_cert_binds_peer_id_to_the_bls_key() {
524        let ca = test_ca();
525        let sk = bls_sk("node-cert/bind");
526        let node = NodeCert::generate_signed_by(&ca, &sk, OffsetDateTime::now_utc()).unwrap();
527
528        // peer_id is SHA-256 of the leaf SPKI.
529        let expected: [u8; 32] = Sha256::digest(node.spki_der()).into();
530        assert_eq!(node.peer_id().as_bytes(), &expected);
531
532        // The cert carries a VALID binding to exactly this BLS key.
533        match verify_binding_from_leaf_cert(node.cert_der()) {
534            BindingOutcome::Bound { bls_pub } => assert_eq!(bls_pub, public_key_bytes(&sk)),
535            other => panic!("expected Bound, got {other:?}"),
536        }
537    }
538
539    #[test]
540    fn distinct_peers_get_distinct_ids() {
541        let ca = test_ca();
542        let a = NodeCert::generate_signed_by(&ca, &bls_sk("a"), OffsetDateTime::now_utc()).unwrap();
543        let b = NodeCert::generate_signed_by(&ca, &bls_sk("b"), OffsetDateTime::now_utc()).unwrap();
544        assert_ne!(a.peer_id(), b.peer_id());
545    }
546
547    #[test]
548    fn pem_round_trips_preserving_peer_id() {
549        let ca = test_ca();
550        let node =
551            NodeCert::generate_signed_by(&ca, &bls_sk("rt"), OffsetDateTime::now_utc()).unwrap();
552        let restored = NodeCert::from_pem(node.cert_pem(), node.key_pem()).unwrap();
553        assert_eq!(node.peer_id(), restored.peer_id());
554    }
555
556    #[test]
557    fn load_or_generate_is_stable_across_calls() {
558        let dir = tempfile::tempdir().unwrap();
559        let sk = bls_sk("persist");
560        let first = NodeCert::load_or_generate(dir.path(), &sk).unwrap();
561        let second = NodeCert::load_or_generate(dir.path(), &sk).unwrap();
562        assert_eq!(
563            first.peer_id(),
564            second.peer_id(),
565            "a persisted cert is reloaded, not regenerated"
566        );
567    }
568
569    /// Regression test for the key-at-rest finding: a persisted leaf key MUST be `0600` (owner
570    /// read/write only) and its directory `0700` — never the umask-default world-readable
571    /// `0644`/`0755` a plain `fs::write`/`fs::create_dir_all` would leave behind.
572    #[test]
573    #[cfg(unix)]
574    fn load_or_generate_persists_the_key_owner_only() {
575        use std::os::unix::fs::PermissionsExt;
576
577        let dir = tempfile::tempdir().unwrap();
578        let sk = bls_sk("perms");
579        NodeCert::load_or_generate(dir.path(), &sk).unwrap();
580
581        let dir_mode = fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777;
582        assert_eq!(dir_mode, 0o700, "cert directory must be owner-only");
583
584        let key_mode = fs::metadata(dir.path().join(KEY_FILE))
585            .unwrap()
586            .permissions()
587            .mode()
588            & 0o777;
589        assert_eq!(key_mode, 0o600, "private key file must be owner-only");
590    }
591
592    fn bls_pub_in_cert(cert_der: &[u8]) -> [u8; 48] {
593        match verify_binding_from_leaf_cert(cert_der) {
594            BindingOutcome::Bound { bls_pub } => bls_pub,
595            other => panic!("expected Bound, got {other:?}"),
596        }
597    }
598
599    #[test]
600    fn rotate_yields_a_new_peer_id() {
601        let dir = tempfile::tempdir().unwrap();
602        let before = NodeCert::load_or_generate(dir.path(), &bls_sk("rotate/before")).unwrap();
603        let old_peer_id = before.peer_id();
604        drop(before);
605
606        let rotated = NodeCert::rotate(dir.path(), &bls_sk("rotate/after")).unwrap();
607        assert_eq!(rotated.previous().peer_id(), old_peer_id);
608        assert_ne!(
609            rotated.current().peer_id(),
610            old_peer_id,
611            "rotation mints a fresh key, so the peer_id changes"
612        );
613    }
614
615    #[test]
616    fn rotate_returns_two_valid_spki_bound_certs() {
617        let dir = tempfile::tempdir().unwrap();
618        let old_sk = bls_sk("rotate/valid-old");
619        let new_sk = bls_sk("rotate/valid-new");
620        NodeCert::load_or_generate(dir.path(), &old_sk).unwrap();
621
622        let rotated = NodeCert::rotate(dir.path(), &new_sk).unwrap();
623
624        // Each cert's peer_id is SHA-256 of its own SPKI...
625        for node in [rotated.previous(), rotated.current()] {
626            let expected: [u8; 32] = Sha256::digest(node.spki_der()).into();
627            assert_eq!(node.peer_id().as_bytes(), &expected);
628        }
629        // ...and each carries a valid binding to the RIGHT BLS key.
630        assert_eq!(
631            bls_pub_in_cert(rotated.previous().cert_der()),
632            public_key_bytes(&old_sk)
633        );
634        assert_eq!(
635            bls_pub_in_cert(rotated.current().cert_der()),
636            public_key_bytes(&new_sk)
637        );
638    }
639
640    #[test]
641    fn rotate_persists_current_and_previous_slots() {
642        let dir = tempfile::tempdir().unwrap();
643        NodeCert::load_or_generate(dir.path(), &bls_sk("rotate/persist-old")).unwrap();
644        let rotated = NodeCert::rotate(dir.path(), &bls_sk("rotate/persist-new")).unwrap();
645
646        // The current slot now holds the NEW identity...
647        let reloaded = NodeCert::load_or_generate(dir.path(), &bls_sk("unused")).unwrap();
648        assert_eq!(reloaded.peer_id(), rotated.current().peer_id());
649        // ...and the additive .prev slot holds the OLD identity, reloadable across a restart.
650        let prev = load_previous(dir.path())
651            .unwrap()
652            .expect("a .prev slot exists after rotate");
653        assert_eq!(prev.peer_id(), rotated.previous().peer_id());
654    }
655
656    #[test]
657    #[cfg(unix)]
658    fn rotate_persists_both_keys_owner_only() {
659        use std::os::unix::fs::PermissionsExt;
660        let dir = tempfile::tempdir().unwrap();
661        NodeCert::load_or_generate(dir.path(), &bls_sk("rotate/perm-old")).unwrap();
662        NodeCert::rotate(dir.path(), &bls_sk("rotate/perm-new")).unwrap();
663
664        for f in [KEY_FILE, KEY_FILE_PREV] {
665            let mode = fs::metadata(dir.path().join(f))
666                .unwrap()
667                .permissions()
668                .mode()
669                & 0o777;
670            assert_eq!(mode, 0o600, "{f} must be owner-only after rotate");
671        }
672    }
673
674    #[test]
675    fn retire_previous_deletes_the_prev_slot() {
676        let dir = tempfile::tempdir().unwrap();
677        NodeCert::load_or_generate(dir.path(), &bls_sk("retire/old")).unwrap();
678        let rotated = NodeCert::rotate(dir.path(), &bls_sk("retire/new")).unwrap();
679        let current_peer_id = rotated.current().peer_id();
680
681        retire_previous(dir.path()).unwrap();
682
683        assert!(
684            !dir.path().join(CERT_FILE_PREV).exists(),
685            "prev cert deleted"
686        );
687        assert!(!dir.path().join(KEY_FILE_PREV).exists(), "prev key deleted");
688        assert!(
689            load_previous(dir.path()).unwrap().is_none(),
690            "no .prev after retire"
691        );
692        // The current identity is untouched by retirement.
693        let reloaded = NodeCert::load_or_generate(dir.path(), &bls_sk("unused")).unwrap();
694        assert_eq!(reloaded.peer_id(), current_peer_id);
695    }
696
697    #[test]
698    fn retire_previous_is_a_noop_without_a_prev_slot() {
699        let dir = tempfile::tempdir().unwrap();
700        NodeCert::load_or_generate(dir.path(), &bls_sk("retire/noop")).unwrap();
701        // No rotation has happened; retiring is safe and idempotent.
702        retire_previous(dir.path()).unwrap();
703        retire_previous(dir.path()).unwrap();
704    }
705
706    #[test]
707    fn rotate_requires_an_existing_current_cert() {
708        let dir = tempfile::tempdir().unwrap();
709        // Empty dir — nothing to rotate.
710        assert!(NodeCert::rotate(dir.path(), &bls_sk("rotate/empty")).is_err());
711    }
712
713    #[test]
714    fn load_previous_is_none_for_a_pre_rotate_dir() {
715        let dir = tempfile::tempdir().unwrap();
716        NodeCert::load_or_generate(dir.path(), &bls_sk("prev/none")).unwrap();
717        assert!(load_previous(dir.path()).unwrap().is_none());
718    }
719
720    /// §5.1 additive guarantee: a dir written by a PRE-rotation reader (only `node.crt`/`node.key`,
721    /// no `.prev` slot) still loads unchanged, and `load_previous` reports no previous identity.
722    #[test]
723    fn old_single_cert_dir_still_loads() {
724        let dir = tempfile::tempdir().unwrap();
725        let sk = bls_sk("compat/single");
726        let original = NodeCert::load_or_generate(dir.path(), &sk).unwrap();
727        let original_peer_id = original.peer_id();
728        drop(original);
729
730        // Exactly the two files an old writer produced; assert no .prev exists.
731        assert!(!dir.path().join(CERT_FILE_PREV).exists());
732        assert!(load_previous(dir.path()).unwrap().is_none());
733
734        let reloaded = NodeCert::load_or_generate(dir.path(), &sk).unwrap();
735        assert_eq!(
736            reloaded.peer_id(),
737            original_peer_id,
738            "old single-cert dir loads identically"
739        );
740    }
741
742    /// Defense-in-depth regression: a cert paired with the WRONG private key (their SPKIs differ) is
743    /// rejected on load, never silently accepted — otherwise a peer would pin a `peer_id` derived from
744    /// a key the presented certificate does not certify.
745    #[test]
746    fn from_pem_rejects_a_mismatched_cert_and_key() {
747        let ca = test_ca();
748        let a = NodeCert::generate_signed_by(&ca, &bls_sk("mismatch/a"), OffsetDateTime::now_utc())
749            .unwrap();
750        let b = NodeCert::generate_signed_by(&ca, &bls_sk("mismatch/b"), OffsetDateTime::now_utc())
751            .unwrap();
752
753        // A's certificate paired with B's key — two independent key pairs, so the cert does not
754        // certify the key.
755        let err = NodeCert::from_pem(a.cert_pem(), b.key_pem())
756            .expect_err("a mismatched cert+key pair must be rejected");
757        assert!(matches!(err, DigTlsError::Parse(_)), "got {err:?}");
758
759        // The matching pairs still load fine.
760        assert!(NodeCert::from_pem(a.cert_pem(), a.key_pem()).is_ok());
761        assert!(NodeCert::from_pem(b.cert_pem(), b.key_pem()).is_ok());
762    }
763
764    /// Double-rotate guard: rotating again while a `.prev` slot is still un-retired must error, so an
765    /// in-overlap retiring identity is never silently overwritten. After `retire_previous`, rotation
766    /// succeeds again.
767    #[test]
768    fn rotate_twice_without_retiring_is_rejected() {
769        let dir = tempfile::tempdir().unwrap();
770        NodeCert::load_or_generate(dir.path(), &bls_sk("double/old")).unwrap();
771
772        let first = NodeCert::rotate(dir.path(), &bls_sk("double/one")).unwrap();
773        let first_current = first.current().peer_id();
774        drop(first);
775
776        // A second rotation with the .prev slot still populated is refused...
777        let err = NodeCert::rotate(dir.path(), &bls_sk("double/two"))
778            .expect_err("a second rotation before retiring the first must error");
779        assert!(matches!(err, DigTlsError::CertGen(_)), "got {err:?}");
780
781        // ...and the current slot is untouched by the refused attempt.
782        let reloaded = NodeCert::load_or_generate(dir.path(), &bls_sk("unused")).unwrap();
783        assert_eq!(reloaded.peer_id(), first_current);
784        drop(reloaded);
785
786        // After retiring the previous identity, rotation is permitted again.
787        retire_previous(dir.path()).unwrap();
788        let second = NodeCert::rotate(dir.path(), &bls_sk("double/three")).unwrap();
789        assert_eq!(second.previous().peer_id(), first_current);
790        assert_ne!(second.current().peer_id(), first_current);
791    }
792
793    #[test]
794    fn debug_never_leaks_the_key() {
795        let ca = test_ca();
796        let node =
797            NodeCert::generate_signed_by(&ca, &bls_sk("dbg"), OffsetDateTime::now_utc()).unwrap();
798        assert!(format!("{node:?}").contains("<redacted>"));
799    }
800}