//! Persistent node identity at `~/.config/peerspeak/identity.key`. //! //! Historically peerspeak called `SecretKey::generate()` once per process, so a //! peer's `EndpointId` changed on every launch. The friends-first contacts model //! (`docs/contacts-plan.md`) identifies people by that id and keeps a saved //! address for each, so the id must stay **stable across launches** — that's the //! foundation that makes a saved address worth keeping. iroh does not force //! rolling ids; we just pass it a persistent key. //! //! The key is the ed25519 secret (32 bytes) stored as hex on its own line, in a //! `0600` file separate from `config.json`. It's a secret, not a preference: //! keeping it out of the config means a config reset / hand-edit can't clobber //! your identity, and the restrictive perms keep it from being world-readable. //! //! A `Regenerate identity` action (Settings) deliberately overwrites this file //! with a fresh key — the intended "unlink / fresh start." After that, peers who //! saved the old id can no longer recognise or reach this node until a new //! exchange happens. use anyhow::{Context, Result, bail}; use iroh::SecretKey; use std::fs; use std::io::Write; use std::path::PathBuf; /// Returns `~/.config/peerspeak/identity.key` (or the XDG equivalent). Shares the /// config directory with [`crate::config`]; the parent is created on save. pub fn identity_path() -> Option { dirs::config_dir().map(|mut p| { p.push("peerspeak"); p.push("identity.key"); p }) } /// Load the persisted secret key, or generate-and-save one on first run. /// /// A *malformed* file is a hard error rather than a silent regenerate: quietly /// minting a new identity would orphan every friend who saved the old id, so we /// fail loud and let the user notice (and decide) instead of losing it silently. /// A *missing* file (first ever run, or right after a reset) is the normal /// create path. pub fn load_or_create() -> Result { let path = identity_path().context("could not determine a config directory for the identity key")?; load_or_create_at(&path) } /// Mint a brand-new identity, overwrite the key file, and return it. This is the /// deliberate "Regenerate identity" / unlink action — the old id is discarded and /// unrecoverable, so callers should confirm with the user first. pub fn regenerate() -> Result { let path = identity_path().context("could not determine a config directory for the identity key")?; let key = SecretKey::generate(); save_at(&path, &key)?; Ok(key) } /// Atomic, `0600` write at the default identity path. See [`save_at`]. pub fn save(key: &SecretKey) -> Result<()> { let path = identity_path().context("could not determine a config directory for the identity key")?; save_at(&path, key) } /// Path-injectable core of [`load_or_create`], so the filesystem round-trip is /// testable in a temp dir without touching the real config. fn load_or_create_at(path: &std::path::Path) -> Result { match fs::read_to_string(path) { Ok(s) => parse_key(s.trim()) .with_context(|| format!("failed to parse the identity key at {}", path.display())), Err(e) if e.kind() == std::io::ErrorKind::NotFound => { let key = SecretKey::generate(); save_at(path, &key)?; Ok(key) } Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())), } } /// Atomic, `0600` write: tempfile-in-same-dir, chmod, then rename. Restrictive /// perms are applied before the rename so the secret is never briefly /// world-readable. fn save_at(path: &std::path::Path, key: &SecretKey) -> Result<()> { let parent = path.parent().context("identity path has no parent directory")?; fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?; let tmp = parent.join(format!(".identity.key.tmp.{}", std::process::id())); { let mut f = fs::File::create(&tmp).with_context(|| format!("failed to create {}", tmp.display()))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; f.set_permissions(fs::Permissions::from_mode(0o600)) .with_context(|| format!("failed to chmod {}", tmp.display()))?; } f.write_all(encode_hex(&key.to_bytes()).as_bytes()) .with_context(|| format!("failed to write {}", tmp.display()))?; f.write_all(b"\n").ok(); f.sync_all().ok(); } fs::rename(&tmp, path) .with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?; Ok(()) } fn parse_key(hex: &str) -> Result { let bytes = decode_hex(hex)?; let arr: [u8; 32] = bytes .try_into() .map_err(|_| anyhow::anyhow!("identity key must be 32 bytes (64 hex chars)"))?; Ok(SecretKey::from_bytes(&arr)) } fn encode_hex(bytes: &[u8]) -> String { let mut s = String::with_capacity(bytes.len() * 2); for b in bytes { s.push_str(&format!("{b:02x}")); } s } fn decode_hex(s: &str) -> Result> { if !s.len().is_multiple_of(2) { bail!("hex string has an odd length"); } (0..s.len()) .step_by(2) .map(|i| { u8::from_str_radix(&s[i..i + 2], 16) .with_context(|| format!("invalid hex byte at offset {i}")) }) .collect() } #[cfg(test)] mod tests { use super::*; #[test] fn hex_round_trips() { let bytes: Vec = (0u8..=255).collect(); let encoded = encode_hex(&bytes); assert_eq!(encoded.len(), bytes.len() * 2); assert_eq!(decode_hex(&encoded).unwrap(), bytes); } #[test] fn key_round_trips_through_hex() { let key = SecretKey::generate(); let hex = encode_hex(&key.to_bytes()); let parsed = parse_key(&hex).unwrap(); assert_eq!(parsed.to_bytes(), key.to_bytes()); assert_eq!(parsed.public(), key.public()); } #[test] fn rejects_wrong_length() { // 2 hex chars = 1 byte, not 32. assert!(parse_key("dead").is_err()); assert!(parse_key("").is_err()); } #[test] fn rejects_odd_and_nonhex() { assert!(decode_hex("abc").is_err()); assert!(decode_hex("zz").is_err()); } /// A unique temp path under the system temp dir (no extra deps). The parent /// is the temp dir itself; `save_at` creates a nested subdir to exercise the /// `create_dir_all` path. fn temp_key_path(tag: &str) -> PathBuf { let mut p = std::env::temp_dir(); p.push(format!("peerspeak-idtest-{}-{}", std::process::id(), tag)); p.push("identity.key"); p } #[test] fn missing_file_creates_then_persists() { let path = temp_key_path("create"); let _ = fs::remove_dir_all(path.parent().unwrap()); // First call creates a fresh key + writes the file. let k1 = load_or_create_at(&path).unwrap(); assert!(path.exists(), "key file should have been created"); // Second call loads the SAME key back (stable across "launches"). let k2 = load_or_create_at(&path).unwrap(); assert_eq!(k1.to_bytes(), k2.to_bytes()); assert_eq!(k1.public(), k2.public()); let _ = fs::remove_dir_all(path.parent().unwrap()); } #[test] fn malformed_file_is_a_hard_error_not_a_silent_regenerate() { let path = temp_key_path("malformed"); let _ = fs::remove_dir_all(path.parent().unwrap()); fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(&path, "not-valid-hex").unwrap(); // Must error rather than mint a new id (which would orphan saved friends). assert!(load_or_create_at(&path).is_err()); let _ = fs::remove_dir_all(path.parent().unwrap()); } #[test] fn regenerate_changes_the_key_on_disk() { let path = temp_key_path("regen"); let _ = fs::remove_dir_all(path.parent().unwrap()); let original = load_or_create_at(&path).unwrap(); // save_at with a fresh key models the regenerate overwrite. let fresh = SecretKey::generate(); save_at(&path, &fresh).unwrap(); let reloaded = load_or_create_at(&path).unwrap(); assert_eq!(reloaded.to_bytes(), fresh.to_bytes()); assert_ne!(reloaded.to_bytes(), original.to_bytes()); let _ = fs::remove_dir_all(path.parent().unwrap()); } #[cfg(unix)] #[test] fn key_file_is_0600() { use std::os::unix::fs::PermissionsExt; let path = temp_key_path("perms"); let _ = fs::remove_dir_all(path.parent().unwrap()); load_or_create_at(&path).unwrap(); let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o600, "identity key must not be group/world readable"); let _ = fs::remove_dir_all(path.parent().unwrap()); } }