diff --git a/src/core/mod.rs b/src/core/mod.rs index 057740d..0e7a314 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -418,7 +418,19 @@ async fn run_core_loop( ui_tx: mpsc::Sender, ) -> Result<(), anyhow::Error> { let memory_lookup = iroh::address_lookup::memory::MemoryLookup::new(); - let secret_key = iroh::SecretKey::generate(); + // Persistent identity (W7 P1): load a stable key so our node id survives + // launches — the foundation for the friends-first contacts model. Fall back + // to an ephemeral key only if the key file can't be read/created (e.g. no + // writable config dir), so a bad disk never blocks getting on a call. + let secret_key = match crate::identity::load_or_create() { + Ok(key) => key, + Err(e) => { + crate::log_msg(&format!( + "identity: falling back to an ephemeral key (persistent load failed: {e:#})" + )); + iroh::SecretKey::generate() + } + }; // Peers seen in the current/most-recent room, retained ACROSS leave so a // rejoin can bootstrap to them. This is the fix for A8: the room creator's own // ticket lists only themselves as host, so on rejoin these retained peers are diff --git a/src/identity.rs b/src/identity.rs new file mode 100644 index 0000000..540acd3 --- /dev/null +++ b/src/identity.rs @@ -0,0 +1,229 @@ +//! 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()); + } +} diff --git a/src/lib.rs b/src/lib.rs index a1030ef..c8e2ec8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ pub mod network; pub mod core; pub mod app; pub mod config; +pub mod identity; pub mod theme; pub mod notify; pub mod screenshare;