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>
230 lines
8.8 KiB
Rust
230 lines
8.8 KiB
Rust
//! 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());
|
|
}
|
|
}
|