diff --git a/src/common/endpoint.rs b/src/common/endpoint.rs index b78ecee..6fb21f6 100644 --- a/src/common/endpoint.rs +++ b/src/common/endpoint.rs @@ -9,6 +9,7 @@ use iroh::endpoint::presets; use iroh::{Endpoint, RelayMap, RelayMode, RelayUrl}; use super::alpn::ALPN; +use super::identity; /// Environment variable consulted when `--relay` isn't passed. Lets the GUI's /// child processes and scripted runs inherit a relay choice without a flag. @@ -29,8 +30,15 @@ pub fn relay_override(flag: Option<&str>) -> Option { /// [`RelayMode::Custom`]; this is how a user gets off the rc's bundled /// (canary-grade) relays or points at a self-hosted one. Discovery is /// unchanged, so peers still resolve each other by endpoint id. +/// +/// The endpoint is bound with the machine's persistent identity (see +/// [`identity`]), so its `EndpointId` is stable across launches and roles — +/// the property the friends system is built on. pub async fn bind(relay: Option<&str>) -> Result { - let mut builder = Endpoint::builder(presets::N0).alpns(vec![ALPN.to_vec()]); + let secret_key = identity::load_or_create()?; + let mut builder = Endpoint::builder(presets::N0) + .secret_key(secret_key) + .alpns(vec![ALPN.to_vec()]); if let Some(url) = relay { let url = RelayUrl::from_str(url) diff --git a/src/common/identity.rs b/src/common/identity.rs new file mode 100644 index 0000000..01e8cb5 --- /dev/null +++ b/src/common/identity.rs @@ -0,0 +1,142 @@ +//! 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() % 2 != 0 { + 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()); + } +} diff --git a/src/common/mod.rs b/src/common/mod.rs index af5a575..9179857 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -3,6 +3,7 @@ pub mod bandwidth; pub mod config; pub mod deps; pub mod endpoint; +pub mod identity; pub mod display; pub mod output; pub mod process;