//! Persistent node identity at `~/.config/pixelpass/identity.key`. //! //! Without this, [`super::endpoint::bind`] would let iroh mint a fresh random //! keypair on every launch, so a peer's `EndpointId` would change each run. //! The friends system identifies people by that id (it's the public key already //! embedded in every share code), so it must stay stable across launches — and //! across roles: the same machine gets the same id whether it's hosting, //! viewing, or just sitting in the GUI. //! //! The key is the ed25519 secret (32 bytes) stored as hex on its own line, in a //! `0600` file separate from `config.toml` — it's a secret, not a preference, //! and keeping it out of the TOML means a hand-edit or a config reset can't //! clobber your identity. use anyhow::{Context, Result, bail}; use iroh::SecretKey; use std::fs; use std::io::Write; use std::path::PathBuf; /// Returns `~/.config/pixelpass/identity.key` (or the XDG equivalent). Shares /// the config directory with [`super::config`]; the parent is created on save. pub fn identity_path() -> Result { Ok(super::config::config_path()? .parent() .context("config path has no parent directory")? .join("identity.key")) } /// 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: silently /// minting a new identity would orphan every friend who has the old id, so we'd /// rather fail loud and let the user notice (and decide) than lose it quietly. pub fn load_or_create() -> Result { let path = identity_path()?; 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(&key)?; tracing::info!(id = %key.public(), "generated a new persistent identity"); Ok(key) } Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())), } } 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)) } /// Atomic, `0600` write: tempfile-in-same-dir, chmod, then rename. Same /// approach as [`super::config::save`], but with restrictive perms applied /// before the rename so the secret is never briefly world-readable. pub fn save(key: &SecretKey) -> Result<()> { let path = identity_path()?; 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 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() { 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()); } }