Skip to main content

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}