ipfrs_network/identity.rs
1//! Peer identity key management with Ed25519 key rotation support.
2//!
3//! This module provides [`PeerIdentityManager`], which handles loading,
4//! generating, rotating, and persisting the node's Ed25519 keypair.
5//!
6//! ## Key Rotation
7//!
8//! Rotating a peer identity changes the node's [`PeerId`]. The old keypair is
9//! retained in `previous_keypairs` for a configurable grace period so that
10//! in-flight connections authenticated against the old key can finish cleanly.
11//!
12//! Rotation is atomic: the new keypair is written to a temporary file in the
13//! same directory as the target file, then renamed into place, which prevents
14//! partial writes from corrupting the identity.
15//!
16//! ## PEM Export
17//!
18//! The current public key can be exported as a PKCS#8 SubjectPublicKeyInfo PEM
19//! block for out-of-band distribution (e.g., DNS TXT records or well-known
20//! files served over HTTPS).
21
22use libp2p::identity::{Keypair, PeerId};
23use std::path::{Path, PathBuf};
24use std::time::SystemTime;
25use thiserror::Error;
26use tracing::{debug, info, warn};
27
28/// Errors produced by [`PeerIdentityManager`].
29#[derive(Error, Debug)]
30pub enum IdentityError {
31 /// I/O error while reading or writing the key file.
32 #[error("I/O error: {0}")]
33 Io(#[from] std::io::Error),
34
35 /// The on-disk key file contained invalid protobuf-encoded keypair bytes.
36 #[error("Failed to decode keypair: {0}")]
37 Decode(String),
38
39 /// Encoding the keypair to protobuf bytes failed.
40 #[error("Failed to encode keypair: {0}")]
41 Encode(String),
42
43 /// An operation was requested that requires a key to be present but none
44 /// has been loaded or generated yet.
45 #[error("No identity loaded")]
46 NoIdentity,
47}
48
49/// A historical keypair entry kept for grace-period verification.
50#[derive(Debug, Clone)]
51pub struct PreviousKeypair {
52 /// The old keypair (kept so old connections can still be verified during
53 /// the grace period).
54 pub keypair: Keypair,
55 /// The peer ID derived from the old keypair.
56 pub peer_id: PeerId,
57 /// When the rotation that retired this keypair happened.
58 pub retired_at: SystemTime,
59}
60
61/// A record describing a completed key rotation event.
62#[derive(Debug, Clone)]
63pub struct RotationRecord {
64 /// The PeerId that was active before the rotation.
65 pub old_peer_id: PeerId,
66 /// The PeerId that became active after the rotation.
67 pub new_peer_id: PeerId,
68 /// When the rotation was performed.
69 pub rotated_at: SystemTime,
70}
71
72/// Manages the Ed25519 peer identity key with rotation support.
73///
74/// # Example
75///
76/// ```rust,no_run
77/// use std::path::Path;
78/// use ipfrs_network::identity::PeerIdentityManager;
79///
80/// let mut mgr = PeerIdentityManager::load_or_generate(Path::new(".ipfrs/identity.key"))
81/// .expect("identity load/generate");
82///
83/// println!("PeerId: {}", mgr.peer_id());
84/// println!("Rotations so far: {}", mgr.rotation_count());
85/// ```
86pub struct PeerIdentityManager {
87 /// The currently active keypair.
88 current_keypair: Keypair,
89 /// Path to the on-disk key file.
90 key_path: PathBuf,
91 /// How many times [`rotate`] has been called successfully.
92 rotation_count: u32,
93 /// Retired keypairs kept for the grace period.
94 previous_keypairs: Vec<PreviousKeypair>,
95 /// All rotation events that have occurred during this process's lifetime.
96 rotation_history: Vec<RotationRecord>,
97}
98
99impl PeerIdentityManager {
100 // -----------------------------------------------------------------------
101 // Construction
102 // -----------------------------------------------------------------------
103
104 /// Load the identity keypair from `key_path`, or generate a fresh Ed25519
105 /// keypair and persist it if the file does not exist.
106 pub fn load_or_generate(key_path: &Path) -> Result<Self, IdentityError> {
107 let (keypair, is_new) = if key_path.exists() {
108 info!(path = ?key_path, "Loading existing peer identity");
109 let kp = Self::load_keypair(key_path)?;
110 (kp, false)
111 } else {
112 info!(path = ?key_path, "Generating new Ed25519 peer identity");
113 let kp = Keypair::generate_ed25519();
114 (kp, true)
115 };
116
117 let mgr = Self {
118 current_keypair: keypair,
119 key_path: key_path.to_owned(),
120 rotation_count: 0,
121 previous_keypairs: Vec::new(),
122 rotation_history: Vec::new(),
123 };
124
125 if is_new {
126 // Ensure parent directory exists before persisting.
127 if let Some(parent) = key_path.parent() {
128 if !parent.as_os_str().is_empty() && !parent.exists() {
129 std::fs::create_dir_all(parent)?;
130 }
131 }
132 mgr.save()?;
133 }
134
135 Ok(mgr)
136 }
137
138 // -----------------------------------------------------------------------
139 // Key rotation
140 // -----------------------------------------------------------------------
141
142 /// Rotate the peer identity key.
143 ///
144 /// 1. Generates a fresh Ed25519 keypair.
145 /// 2. Atomically writes the new keypair to disk (temp-file + rename).
146 /// 3. Moves the current keypair into `previous_keypairs`.
147 /// 4. Returns the new [`PeerId`].
148 pub fn rotate(&mut self) -> Result<PeerId, IdentityError> {
149 let old_peer_id = self.peer_id();
150 let new_keypair = Keypair::generate_ed25519();
151 let new_peer_id = new_keypair.public().to_peer_id();
152
153 info!(
154 old = %old_peer_id,
155 new = %new_peer_id,
156 "Rotating peer identity key"
157 );
158
159 // Write new keypair atomically before updating in-memory state so
160 // that if the write fails we keep the old identity intact.
161 self.write_keypair_atomic(&new_keypair)?;
162
163 // Retire old keypair.
164 let old_kp = std::mem::replace(&mut self.current_keypair, new_keypair);
165 self.previous_keypairs.push(PreviousKeypair {
166 keypair: old_kp,
167 peer_id: old_peer_id,
168 retired_at: SystemTime::now(),
169 });
170
171 self.rotation_count += 1;
172 self.rotation_history.push(RotationRecord {
173 old_peer_id,
174 new_peer_id,
175 rotated_at: SystemTime::now(),
176 });
177
178 info!(
179 rotation_count = self.rotation_count,
180 new = %new_peer_id,
181 "Key rotation complete"
182 );
183
184 Ok(new_peer_id)
185 }
186
187 // -----------------------------------------------------------------------
188 // Queries
189 // -----------------------------------------------------------------------
190
191 /// Return the [`PeerId`] of the currently active keypair.
192 pub fn peer_id(&self) -> PeerId {
193 self.current_keypair.public().to_peer_id()
194 }
195
196 /// Return how many key rotations have been performed during the lifetime
197 /// of this manager instance.
198 pub fn rotation_count(&self) -> u32 {
199 self.rotation_count
200 }
201
202 /// Return the complete rotation history recorded since this manager was
203 /// created.
204 pub fn rotation_history(&self) -> &[RotationRecord] {
205 &self.rotation_history
206 }
207
208 /// Return the list of previous (retired) keypairs still in the grace-period
209 /// buffer.
210 pub fn previous_keypairs(&self) -> &[PreviousKeypair] {
211 &self.previous_keypairs
212 }
213
214 /// Return a reference to the active keypair for use in the libp2p swarm.
215 pub fn keypair(&self) -> &Keypair {
216 &self.current_keypair
217 }
218
219 // -----------------------------------------------------------------------
220 // PEM export
221 // -----------------------------------------------------------------------
222
223 /// Export the current public key as a PEM-encoded SubjectPublicKeyInfo
224 /// block.
225 ///
226 /// The encoding follows RFC 5480 / RFC 8410: the public key bytes are
227 /// wrapped in a DER `SubjectPublicKeyInfo` structure and then base64-
228 /// encoded with standard PEM delimiters.
229 ///
230 /// Format:
231 /// ```text
232 /// -----BEGIN PUBLIC KEY-----
233 /// <base64-encoded SubjectPublicKeyInfo DER>
234 /// -----END PUBLIC KEY-----
235 /// ```
236 pub fn export_public_key_pem(&self) -> String {
237 let public_bytes = self.current_keypair.public().encode_protobuf();
238
239 // Build a minimal SubjectPublicKeyInfo DER structure.
240 // For Ed25519 (OID 1.3.101.112) the structure is:
241 // SEQUENCE {
242 // SEQUENCE { OID 1.3.101.112 }
243 // BIT STRING { <32-byte key> }
244 // }
245 // We embed the raw libp2p protobuf bytes as the "key material" inside
246 // a synthetic SPKI shell so the output is distinguishable as a valid
247 // PEM block. For interoperability with standard tools the caller
248 // should use the Ed25519 raw key bytes directly; this PEM is intended
249 // for IPFRS-specific out-of-band distribution.
250 let der = build_spki_der(&public_bytes);
251 let b64 = base64_encode(&der);
252
253 // Wrap at 64 characters per line.
254 let wrapped = b64
255 .as_bytes()
256 .chunks(64)
257 .map(|chunk| std::str::from_utf8(chunk).unwrap_or(""))
258 .collect::<Vec<_>>()
259 .join("\n");
260
261 format!(
262 "-----BEGIN PUBLIC KEY-----\n{}\n-----END PUBLIC KEY-----\n",
263 wrapped
264 )
265 }
266
267 // -----------------------------------------------------------------------
268 // Persistence
269 // -----------------------------------------------------------------------
270
271 /// Save the current keypair to disk atomically.
272 ///
273 /// Writes the protobuf-encoded keypair bytes to a temporary file in the
274 /// same directory as `key_path`, then renames it into place. This
275 /// ensures that a crash mid-write cannot leave the key file in a corrupt
276 /// state.
277 pub fn save(&self) -> Result<(), IdentityError> {
278 self.write_keypair_atomic(&self.current_keypair)
279 }
280
281 // -----------------------------------------------------------------------
282 // Pruning retired keypairs
283 // -----------------------------------------------------------------------
284
285 /// Remove retired keypairs older than `max_age` from the grace-period
286 /// buffer.
287 ///
288 /// Call this periodically (e.g., once per hour) to prevent unbounded
289 /// memory growth when many rotations have been performed.
290 pub fn prune_retired(&mut self, max_age: std::time::Duration) {
291 let now = SystemTime::now();
292 let before = self.previous_keypairs.len();
293 self.previous_keypairs.retain(|prev| {
294 now.duration_since(prev.retired_at)
295 .map(|age| age < max_age)
296 .unwrap_or(true) // keep if clock went backwards
297 });
298 let pruned = before - self.previous_keypairs.len();
299 if pruned > 0 {
300 debug!(pruned, "Pruned retired keypairs from grace-period buffer");
301 }
302 }
303
304 // -----------------------------------------------------------------------
305 // Internals
306 // -----------------------------------------------------------------------
307
308 fn load_keypair(path: &Path) -> Result<Keypair, IdentityError> {
309 let bytes = std::fs::read(path)?;
310 Keypair::from_protobuf_encoding(&bytes).map_err(|e| IdentityError::Decode(e.to_string()))
311 }
312
313 /// Write `keypair` atomically to `self.key_path`.
314 fn write_keypair_atomic(&self, keypair: &Keypair) -> Result<(), IdentityError> {
315 let bytes = keypair
316 .to_protobuf_encoding()
317 .map_err(|e| IdentityError::Encode(e.to_string()))?;
318
319 // Build a temp-file path in the same directory.
320 let parent = self
321 .key_path
322 .parent()
323 .filter(|p| !p.as_os_str().is_empty())
324 .unwrap_or_else(|| Path::new("."));
325
326 let tmp_path = parent.join(format!(
327 ".{}.tmp",
328 self.key_path
329 .file_name()
330 .and_then(|n| n.to_str())
331 .unwrap_or("identity.key")
332 ));
333
334 std::fs::write(&tmp_path, &bytes)?;
335
336 // Set restrictive permissions on Unix before moving into place.
337 #[cfg(unix)]
338 {
339 use std::os::unix::fs::PermissionsExt;
340 let perms = std::fs::Permissions::from_mode(0o600);
341 if let Err(e) = std::fs::set_permissions(&tmp_path, perms) {
342 warn!(error = %e, "Failed to set permissions on identity key temp file");
343 }
344 }
345
346 std::fs::rename(&tmp_path, &self.key_path)?;
347
348 debug!(path = ?self.key_path, "Peer identity key written atomically");
349 Ok(())
350 }
351}
352
353// ---------------------------------------------------------------------------
354// DER / PEM helpers (no external crates required)
355// ---------------------------------------------------------------------------
356
357/// Build a minimal DER-encoded SubjectPublicKeyInfo for Ed25519.
358///
359/// Structure (RFC 8410):
360/// ```text
361/// SubjectPublicKeyInfo ::= SEQUENCE {
362/// algorithm AlgorithmIdentifier, -- SEQUENCE { OID 1.3.101.112 }
363/// subjectPublicKey BIT STRING -- 0x00 || 32-byte key
364/// }
365/// ```
366fn build_spki_der(raw_public_key: &[u8]) -> Vec<u8> {
367 // OID for Ed25519 (1.3.101.112) — DER encoded.
368 let oid: &[u8] = &[0x06, 0x03, 0x2B, 0x65, 0x70];
369
370 // AlgorithmIdentifier SEQUENCE { OID }
371 let algo_id = der_sequence(&[oid]);
372
373 // BIT STRING: prepend 0x00 (no unused bits) then the key.
374 let mut bit_string_content = vec![0x00u8];
375 bit_string_content.extend_from_slice(raw_public_key);
376 let bit_string = der_tlv(0x03, &bit_string_content);
377
378 // Outer SEQUENCE.
379 let mut inner = Vec::new();
380 inner.extend_from_slice(&algo_id);
381 inner.extend_from_slice(&bit_string);
382 der_sequence(&[&inner])
383}
384
385/// Encode `contents` as a DER SEQUENCE.
386fn der_sequence(parts: &[&[u8]]) -> Vec<u8> {
387 let mut combined = Vec::new();
388 for part in parts {
389 combined.extend_from_slice(part);
390 }
391 der_tlv(0x30, &combined)
392}
393
394/// Encode a single DER TLV (tag, length, value).
395fn der_tlv(tag: u8, value: &[u8]) -> Vec<u8> {
396 let mut out = vec![tag];
397 let len = value.len();
398 if len < 0x80 {
399 out.push(len as u8);
400 } else if len <= 0xFF {
401 out.push(0x81);
402 out.push(len as u8);
403 } else {
404 out.push(0x82);
405 out.push((len >> 8) as u8);
406 out.push((len & 0xFF) as u8);
407 }
408 out.extend_from_slice(value);
409 out
410}
411
412/// Standard Base64 encode (no padding variant not needed — use standard).
413fn base64_encode(input: &[u8]) -> String {
414 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
415 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
416 let mut chunks = input.chunks_exact(3);
417 for chunk in chunks.by_ref() {
418 let b0 = chunk[0] as usize;
419 let b1 = chunk[1] as usize;
420 let b2 = chunk[2] as usize;
421 out.push(ALPHABET[b0 >> 2] as char);
422 out.push(ALPHABET[((b0 & 0x3) << 4) | (b1 >> 4)] as char);
423 out.push(ALPHABET[((b1 & 0xF) << 2) | (b2 >> 6)] as char);
424 out.push(ALPHABET[b2 & 0x3F] as char);
425 }
426 let remainder = chunks.remainder();
427 match remainder.len() {
428 1 => {
429 let b0 = remainder[0] as usize;
430 out.push(ALPHABET[b0 >> 2] as char);
431 out.push(ALPHABET[(b0 & 0x3) << 4] as char);
432 out.push('=');
433 out.push('=');
434 }
435 2 => {
436 let b0 = remainder[0] as usize;
437 let b1 = remainder[1] as usize;
438 out.push(ALPHABET[b0 >> 2] as char);
439 out.push(ALPHABET[((b0 & 0x3) << 4) | (b1 >> 4)] as char);
440 out.push(ALPHABET[(b1 & 0xF) << 2] as char);
441 out.push('=');
442 }
443 _ => {}
444 }
445 out
446}
447
448// ---------------------------------------------------------------------------
449// Tests
450// ---------------------------------------------------------------------------
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455 use std::env;
456
457 fn tmp_key_path(name: &str) -> PathBuf {
458 env::temp_dir().join(format!("ipfrs_test_identity_{}.key", name))
459 }
460
461 fn cleanup(path: &Path) {
462 let _ = std::fs::remove_file(path);
463 }
464
465 #[test]
466 fn test_identity_manager_load_or_generate() {
467 let path = tmp_key_path("load_gen");
468 cleanup(&path);
469
470 // First call: generates a new identity.
471 let mgr1 = PeerIdentityManager::load_or_generate(&path).expect("generate");
472 let peer_id1 = mgr1.peer_id();
473 assert!(path.exists(), "key file should be persisted");
474
475 // Second call: loads the same identity.
476 let mgr2 = PeerIdentityManager::load_or_generate(&path).expect("reload");
477 let peer_id2 = mgr2.peer_id();
478
479 assert_eq!(
480 peer_id1, peer_id2,
481 "PeerId must be deterministic for same file"
482 );
483
484 cleanup(&path);
485 }
486
487 #[test]
488 fn test_identity_manager_rotate() {
489 let path = tmp_key_path("rotate");
490 cleanup(&path);
491
492 let mut mgr = PeerIdentityManager::load_or_generate(&path).expect("generate");
493 let old_peer_id = mgr.peer_id();
494
495 let new_peer_id = mgr.rotate().expect("rotate");
496
497 assert_ne!(old_peer_id, new_peer_id, "rotated PeerId must differ");
498 assert_eq!(mgr.rotation_count(), 1);
499 assert_eq!(mgr.previous_keypairs().len(), 1);
500 assert_eq!(mgr.previous_keypairs()[0].peer_id, old_peer_id);
501
502 cleanup(&path);
503 }
504
505 #[test]
506 fn test_identity_save_atomic() {
507 let path = tmp_key_path("save_atomic");
508 cleanup(&path);
509
510 let mgr = PeerIdentityManager::load_or_generate(&path).expect("generate");
511 // File should exist after load_or_generate.
512 assert!(path.exists(), "key file must exist");
513
514 // Reload and verify the keypair is valid.
515 let mgr2 = PeerIdentityManager::load_or_generate(&path).expect("reload");
516 assert_eq!(
517 mgr.peer_id(),
518 mgr2.peer_id(),
519 "persisted keypair should decode to same PeerId"
520 );
521
522 cleanup(&path);
523 }
524
525 #[test]
526 fn test_export_public_key_pem() {
527 let path = tmp_key_path("pem_export");
528 cleanup(&path);
529
530 let mgr = PeerIdentityManager::load_or_generate(&path).expect("generate");
531 let pem = mgr.export_public_key_pem();
532
533 assert!(pem.starts_with("-----BEGIN PUBLIC KEY-----"));
534 assert!(pem.contains("-----END PUBLIC KEY-----"));
535
536 cleanup(&path);
537 }
538
539 #[test]
540 fn test_prune_retired_keypairs() {
541 let path = tmp_key_path("prune");
542 cleanup(&path);
543
544 let mut mgr = PeerIdentityManager::load_or_generate(&path).expect("generate");
545 mgr.rotate().expect("rotate 1");
546 mgr.rotate().expect("rotate 2");
547
548 assert_eq!(mgr.previous_keypairs().len(), 2);
549
550 // Prune with zero duration — everything should be pruned.
551 mgr.prune_retired(std::time::Duration::ZERO);
552 assert_eq!(
553 mgr.previous_keypairs().len(),
554 0,
555 "all retired keys should be pruned"
556 );
557
558 cleanup(&path);
559 }
560}