1use 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
29pub const LEAF_LIFETIME: Duration = Duration::days(365 * 10);
33
34const LEAF_SAN: &str = "peer.dig";
38
39const CERT_FILE: &str = "node.crt";
41const KEY_FILE: &str = "node.key";
42
43const CERT_FILE_PREV: &str = "node.crt.prev";
47const KEY_FILE_PREV: &str = "node.key.prev";
48
49#[derive(Debug)]
61pub struct RotatedNodeCert {
62 previous: NodeCert,
63 current: NodeCert,
64}
65
66impl RotatedNodeCert {
67 pub fn previous(&self) -> &NodeCert {
70 &self.previous
71 }
72
73 pub fn current(&self) -> &NodeCert {
75 &self.current
76 }
77
78 pub fn into_current(self) -> NodeCert {
80 self.current
81 }
82}
83
84pub 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 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 pub fn generate_signed(bls_sk: &SecretKey) -> Result<Self> {
111 Self::generate_signed_by(&DigCa::embedded()?, bls_sk, OffsetDateTime::now_utc())
112 }
113
114 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 params.extended_key_usages = vec![
135 ExtendedKeyUsagePurpose::ServerAuth,
136 ExtendedKeyUsagePurpose::ClientAuth,
137 ];
138
139 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 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 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 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 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 let current = Self::generate_signed(new_bls_sk)?;
221
222 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 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 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 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 pub fn peer_id(&self) -> PeerId {
287 self.peer_id
288 }
289
290 pub fn spki_der(&self) -> &[u8] {
292 &self.spki_der
293 }
294
295 pub fn cert_der(&self) -> &[u8] {
297 &self.cert_der
298 }
299
300 pub fn cert_pem(&self) -> &str {
302 &self.cert_pem
303 }
304
305 pub fn key_pem(&self) -> &str {
308 &self.key_pem
309 }
310
311 pub fn rustls_cert_chain(&self) -> Vec<CertificateDer<'static>> {
314 vec![CertificateDer::from(self.cert_der.clone())]
315 }
316
317 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
324pub 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
340pub 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 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#[derive(Clone, Copy, PartialEq, Eq)]
364enum Secret {
365 Yes,
366 No,
367}
368
369fn read_key_to_zeroizing(path: &Path) -> Result<Zeroizing<String>> {
372 Ok(Zeroizing::new(fs::read_to_string(path)?))
373}
374
375fn 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
382fn 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 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#[cfg(unix)]
406fn atomic_rename(from: &Path, to: &Path) -> Result<()> {
407 fs::rename(from, to)?;
408 Ok(())
409}
410
411#[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#[cfg(unix)]
431fn fsync_dir(dir: &Path) -> Result<()> {
432 fs::File::open(dir)?.sync_all()?;
433 Ok(())
434}
435
436#[cfg(windows)]
439fn fsync_dir(_dir: &Path) -> Result<()> {
440 Ok(())
441}
442
443#[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#[cfg(windows)]
460fn harden_dir_permissions(_dir: &Path) -> Result<()> {
461 Ok(())
462}
463
464#[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 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 fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
483 Ok(())
484}
485
486#[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 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 let expected: [u8; 32] = Sha256::digest(node.spki_der()).into();
530 assert_eq!(node.peer_id().as_bytes(), &expected);
531
532 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 #[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 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 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 let reloaded = NodeCert::load_or_generate(dir.path(), &bls_sk("unused")).unwrap();
648 assert_eq!(reloaded.peer_id(), rotated.current().peer_id());
649 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 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 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 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 #[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 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 #[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 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 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 #[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 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 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 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}