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:
+13
-1
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user