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;
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/// A peer's mTLS identity certificate + private key, plus its derived `peer_id`.
44///
45/// The private key is held in [`Zeroizing`] so every clone/drop scrubs the plaintext PKCS#8 bytes
46/// from freed heap. [`NodeCert`] deliberately does not derive `Clone` for the same reason — pass a
47/// reference.
48pub struct NodeCert {
49    cert_pem: String,
50    key_pem: Zeroizing<String>,
51    cert_der: Vec<u8>,
52    key_der: Zeroizing<Vec<u8>>,
53    spki_der: Vec<u8>,
54    peer_id: PeerId,
55}
56
57impl std::fmt::Debug for NodeCert {
58    /// Never renders the private key material.
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        f.debug_struct("NodeCert")
61            .field("peer_id", &self.peer_id)
62            .field("key_pem", &"<redacted>")
63            .finish()
64    }
65}
66
67impl NodeCert {
68    /// Generate a new node cert signed by the shipped, public DigNetwork CA (the common path).
69    pub fn generate_signed(bls_sk: &SecretKey) -> Result<Self> {
70        Self::generate_signed_by(&DigCa::embedded()?, bls_sk, OffsetDateTime::now_utc())
71    }
72
73    /// Generate a new node cert signed by an explicit CA at an explicit issuance time (used by tests
74    /// with a throwaway CA, and internally by [`Self::generate_signed`]).
75    pub fn generate_signed_by(ca: &DigCa, bls_sk: &SecretKey, now: OffsetDateTime) -> Result<Self> {
76        let leaf_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256)
77            .map_err(|e| DigTlsError::CertGen(format!("generate leaf key: {e}")))?;
78
79        let mut params = CertificateParams::new(Vec::<String>::new())
80            .map_err(|e| DigTlsError::CertGen(format!("leaf params: {e}")))?;
81        params.not_before = now - CLOCK_SKEW_BACKDATE;
82        params.not_after = now + LEAF_LIFETIME;
83
84        let mut dn = DistinguishedName::new();
85        dn.push(DnType::CommonName, LEAF_SAN);
86        params.distinguished_name = dn;
87
88        let san = Ia5String::try_from(LEAF_SAN.to_string())
89            .map_err(|e| DigTlsError::CertGen(format!("leaf SAN: {e}")))?;
90        params.subject_alt_names = vec![SanType::DnsName(san)];
91        params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
92        // Both EKUs so the ONE leaf authenticates in either direction of the mutual-TLS handshake.
93        params.extended_key_usages = vec![
94            ExtendedKeyUsagePurpose::ServerAuth,
95            ExtendedKeyUsagePurpose::ClientAuth,
96        ];
97
98        // Bind peer_id ↔ BLS key BEFORE signing (the extension is part of the signed TBS cert).
99        attach_binding(&mut params, &leaf_key, bls_sk);
100
101        let cert = params
102            .signed_by(&leaf_key, &ca.cert, &ca.key)
103            .map_err(|e| DigTlsError::CertGen(format!("sign leaf: {e}")))?;
104
105        Self::from_parts(cert.pem(), leaf_key.serialize_pem(), &leaf_key)
106    }
107
108    /// Load a persisted node cert + key from `dir`, or generate + persist a new one signed by the
109    /// shipped DigNetwork CA if either file is absent. Keeps a peer's `peer_id` stable across restarts.
110    pub fn load_or_generate(dir: impl AsRef<Path>, bls_sk: &SecretKey) -> Result<Self> {
111        let dir = dir.as_ref();
112        let cert_path = dir.join(CERT_FILE);
113        let key_path = dir.join(KEY_FILE);
114        if cert_path.exists() && key_path.exists() {
115            let cert_pem = fs::read_to_string(&cert_path)?;
116            let key_pem = fs::read_to_string(&key_path)?;
117            return Self::from_pem(&cert_pem, &key_pem);
118        }
119        let node = Self::generate_signed(bls_sk)?;
120        fs::create_dir_all(dir)?;
121        harden_dir_permissions(dir)?;
122        fs::write(&cert_path, node.cert_pem.as_bytes())?;
123        write_key_file(&key_path, node.key_pem.as_bytes())?;
124        Ok(node)
125    }
126
127    /// Reconstruct a [`NodeCert`] from persisted PEM (its cert + private key).
128    pub fn from_pem(cert_pem: &str, key_pem: &str) -> Result<Self> {
129        let key = KeyPair::from_pem(key_pem)
130            .map_err(|e| DigTlsError::Parse(format!("parse leaf key: {e}")))?;
131        Self::from_parts(cert_pem.to_string(), key_pem.to_string(), &key)
132    }
133
134    /// Assemble a [`NodeCert`] from its PEM parts, deriving the DER forms + `peer_id` once.
135    fn from_parts(cert_pem: String, key_pem: String, key: &KeyPair) -> Result<Self> {
136        let cert_der = rustls_pemfile::certs(&mut cert_pem.as_bytes())
137            .next()
138            .and_then(|r| r.ok())
139            .ok_or_else(|| DigTlsError::Parse("leaf PEM has no certificate".into()))?
140            .to_vec();
141        let key_der = key.serialize_der();
142        let spki_der = key.public_key_der();
143        let peer_id = peer_id_from_tls_spki_der(&spki_der);
144        Ok(Self {
145            cert_pem,
146            key_pem: Zeroizing::new(key_pem),
147            cert_der,
148            key_der: Zeroizing::new(key_der),
149            spki_der,
150            peer_id,
151        })
152    }
153
154    /// This peer's transport identity, `peer_id = SHA-256(SPKI DER)`.
155    pub fn peer_id(&self) -> PeerId {
156        self.peer_id
157    }
158
159    /// The leaf's SubjectPublicKeyInfo DER (what `peer_id` is the SHA-256 of).
160    pub fn spki_der(&self) -> &[u8] {
161        &self.spki_der
162    }
163
164    /// The leaf certificate in DER form.
165    pub fn cert_der(&self) -> &[u8] {
166        &self.cert_der
167    }
168
169    /// The leaf certificate, PEM-encoded (for persistence / inspection).
170    pub fn cert_pem(&self) -> &str {
171        &self.cert_pem
172    }
173
174    /// The private key, PEM-encoded. Handle with care — this is secret-ADJACENT (the key authorizes
175    /// the peer's identity, though the DigNetwork CA itself is public).
176    pub fn key_pem(&self) -> &str {
177        &self.key_pem
178    }
179
180    /// The rustls certificate chain to present in a handshake (just the leaf — the DigNetwork CA is a
181    /// well-known trust anchor every peer already embeds, so it is not sent on the wire).
182    pub fn rustls_cert_chain(&self) -> Vec<CertificateDer<'static>> {
183        vec![CertificateDer::from(self.cert_der.clone())]
184    }
185
186    /// The rustls private key for the handshake.
187    pub fn rustls_private_key(&self) -> PrivateKeyDer<'static> {
188        PrivateKeyDer::try_from(self.key_der.to_vec())
189            .expect("a freshly serialized PKCS#8 key is always a valid PrivateKeyDer")
190    }
191}
192
193/// Restrict `dir` to owner-only access (`0700`) before any secret is written into it.
194///
195/// The leaf private key is the peer's long-lived (10yr) transport-identity secret — the same
196/// material Chia's `create_ssl.py` chmods `0600`. Without this, `fs::create_dir_all` leaves the
197/// directory at the process umask default (commonly `0755`), letting any local unprivileged user
198/// read the key and fully impersonate the peer (the SPKI pin + #1204 BLS binding are genuine, so a
199/// stolen key is a genuine identity, not a detectable forgery).
200#[cfg(unix)]
201fn harden_dir_permissions(dir: &Path) -> Result<()> {
202    use std::os::unix::fs::PermissionsExt;
203    fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?;
204    Ok(())
205}
206
207/// Windows has no POSIX mode bits; directory ACL hardening is handled per-file on the key itself
208/// (see [`write_key_file`]), so there is nothing additional to do here.
209#[cfg(windows)]
210fn harden_dir_permissions(_dir: &Path) -> Result<()> {
211    Ok(())
212}
213
214/// Persist the private key PEM at `path` with owner-only access, closing the world-readable window
215/// a plain `fs::write` would leave open at the process umask default.
216#[cfg(unix)]
217fn write_key_file(path: &Path, key_pem: &[u8]) -> Result<()> {
218    use std::io::Write;
219    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
220
221    // Create with 0600 baked into the `open(2)` call itself — no window where the key is briefly
222    // world-readable between "write the file" and "chmod it after".
223    let mut file = fs::OpenOptions::new()
224        .write(true)
225        .create(true)
226        .truncate(true)
227        .mode(0o600)
228        .open(path)?;
229    file.write_all(key_pem)?;
230    // `mode()` on open() is masked by umask on some platforms; re-assert explicitly so the key is
231    // always 0600 regardless of the caller's umask.
232    fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
233    Ok(())
234}
235
236/// Best-effort ACL hardening on Windows: strip inherited access and grant only the current user.
237///
238/// This is defense-in-depth, not the gating fix (the exploit this PR closes is the Unix `0644`
239/// world-readable case). `icacls` ships with every supported Windows version, so shelling out to it
240/// avoids pulling a heavyweight ACL crate for a best-effort hardening step; a failure here is logged
241/// as a best-effort miss, not a hard error, since the key is still written successfully.
242#[cfg(windows)]
243fn write_key_file(path: &Path, key_pem: &[u8]) -> Result<()> {
244    fs::write(path, key_pem)?;
245    if let (Some(path_str), Ok(user)) = (path.to_str(), std::env::var("USERNAME")) {
246        // Remove inherited ACEs, then grant only the current user full control.
247        let _ = std::process::Command::new("icacls")
248            .args([path_str, "/inheritance:r", "/grant:r", &format!("{user}:F")])
249            .output();
250    }
251    Ok(())
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::binding::{verify_binding_from_leaf_cert, BindingOutcome};
258    use crate::bls::public_key_bytes;
259    use crate::ca::generate_dig_ca;
260    use sha2::{Digest, Sha256};
261
262    fn test_ca() -> DigCa {
263        let m = generate_dig_ca(OffsetDateTime::now_utc()).unwrap();
264        DigCa::from_pem(&m.cert_pem, &m.key_pem).unwrap()
265    }
266
267    fn bls_sk(label: &str) -> SecretKey {
268        let seed: [u8; 32] = Sha256::digest(label.as_bytes()).into();
269        SecretKey::from_seed(&seed)
270    }
271
272    #[test]
273    fn generated_cert_binds_peer_id_to_the_bls_key() {
274        let ca = test_ca();
275        let sk = bls_sk("node-cert/bind");
276        let node = NodeCert::generate_signed_by(&ca, &sk, OffsetDateTime::now_utc()).unwrap();
277
278        // peer_id is SHA-256 of the leaf SPKI.
279        let expected: [u8; 32] = Sha256::digest(node.spki_der()).into();
280        assert_eq!(node.peer_id().as_bytes(), &expected);
281
282        // The cert carries a VALID binding to exactly this BLS key.
283        match verify_binding_from_leaf_cert(node.cert_der()) {
284            BindingOutcome::Bound { bls_pub } => assert_eq!(bls_pub, public_key_bytes(&sk)),
285            other => panic!("expected Bound, got {other:?}"),
286        }
287    }
288
289    #[test]
290    fn distinct_peers_get_distinct_ids() {
291        let ca = test_ca();
292        let a = NodeCert::generate_signed_by(&ca, &bls_sk("a"), OffsetDateTime::now_utc()).unwrap();
293        let b = NodeCert::generate_signed_by(&ca, &bls_sk("b"), OffsetDateTime::now_utc()).unwrap();
294        assert_ne!(a.peer_id(), b.peer_id());
295    }
296
297    #[test]
298    fn pem_round_trips_preserving_peer_id() {
299        let ca = test_ca();
300        let node =
301            NodeCert::generate_signed_by(&ca, &bls_sk("rt"), OffsetDateTime::now_utc()).unwrap();
302        let restored = NodeCert::from_pem(node.cert_pem(), node.key_pem()).unwrap();
303        assert_eq!(node.peer_id(), restored.peer_id());
304    }
305
306    #[test]
307    fn load_or_generate_is_stable_across_calls() {
308        let dir = tempfile::tempdir().unwrap();
309        let sk = bls_sk("persist");
310        let first = NodeCert::load_or_generate(dir.path(), &sk).unwrap();
311        let second = NodeCert::load_or_generate(dir.path(), &sk).unwrap();
312        assert_eq!(
313            first.peer_id(),
314            second.peer_id(),
315            "a persisted cert is reloaded, not regenerated"
316        );
317    }
318
319    /// Regression test for the key-at-rest finding: a persisted leaf key MUST be `0600` (owner
320    /// read/write only) and its directory `0700` — never the umask-default world-readable
321    /// `0644`/`0755` a plain `fs::write`/`fs::create_dir_all` would leave behind.
322    #[test]
323    #[cfg(unix)]
324    fn load_or_generate_persists_the_key_owner_only() {
325        use std::os::unix::fs::PermissionsExt;
326
327        let dir = tempfile::tempdir().unwrap();
328        let sk = bls_sk("perms");
329        NodeCert::load_or_generate(dir.path(), &sk).unwrap();
330
331        let dir_mode = fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777;
332        assert_eq!(dir_mode, 0o700, "cert directory must be owner-only");
333
334        let key_mode = fs::metadata(dir.path().join(KEY_FILE))
335            .unwrap()
336            .permissions()
337            .mode()
338            & 0o777;
339        assert_eq!(key_mode, 0o600, "private key file must be owner-only");
340    }
341
342    #[test]
343    fn debug_never_leaks_the_key() {
344        let ca = test_ca();
345        let node =
346            NodeCert::generate_signed_by(&ca, &bls_sk("dbg"), OffsetDateTime::now_utc()).unwrap();
347        assert!(format!("{node:?}").contains("<redacted>"));
348    }
349}