feat(identity): persistent node identity (W7 P1 foundation)

Replace the per-launch SecretKey::generate() in the core loop with a stable
key loaded from ~/.config/peerspeak/identity.key, so a peer's node id now
survives restarts. This is the foundation of the friends-first contacts model
(docs/contacts-plan.md): friends are keyed by node id and reachability rests
on a saved address per friend, both of which only mean anything if the id is
stable. iroh never forced rolling ids — the old generate() was an unrevisited
default.

New src/identity.rs: load_or_create / regenerate / save over a 0600 hex key
file (atomic tempfile+rename, perms set before rename), hand-written hex (no
new dep). A malformed file is a hard error, not a silent regenerate, so a bad
hand-edit can't orphan everyone who saved the old id. The fs logic is behind a
path-injectable seam (load_or_create_at/save_at) tested in a temp dir:
create+persist, malformed-errors, regenerate-changes-key, 0600 perms, plus hex
round-trips. Core falls back to an ephemeral key only if the file can't be
read/created, so a bad disk never blocks a call.

regenerate() exists for the Settings 'Regenerate identity' control (next
slice; needs live endpoint rebuild). 238 lib tests green (+8), clippy clean,
release builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 03:43:26 -04:00
co-authored by Claude Opus 4.8
parent c7d384c606
commit d157d78707
3 changed files with 243 additions and 1 deletions
+13 -1
View File
@@ -418,7 +418,19 @@ async fn run_core_loop(
ui_tx: mpsc::Sender<UiEvent>,
) -> 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
+229
View File
@@ -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<PathBuf> {
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<SecretKey> {
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<SecretKey> {
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<SecretKey> {
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<SecretKey> {
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<Vec<u8>> {
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<u8> = (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());
}
}
+1
View File
@@ -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;