f5d0333366
Stand up the friends control plane: a persistent-identity iroh endpoint that's online for the whole GUI session, separate from the ephemeral video sessions, ready to carry friend requests and pushed share-codes. Identity split by plane (common/endpoint.rs): the video plane (host/ viewer) goes back to ephemeral per-session keypairs, while the new bind_control() binds with the machine's persistent identity. They must differ — the GUI's control endpoint and a host's video endpoint can be live at once, and iroh routes by EndpointId, so a shared id would make relay delivery ambiguous. Bonus: a screen-share now leaks no stable id. common/control.rs — the protocol: a ControlMsg enum (Hello / Friend Request / FriendAccept / FriendDecline / ShareCode) with one-message- per-connection framing (EOF-delimited JSON) and a one-byte ACK the receiver returns only after a successful parse, so send() gets a real delivered/failed signal (the basis for the later code-push queue). The sender id is taken from the connection's verified remote key, never the payload. send() takes impl Into<EndpointAddr> so production dials a bare EndpointId (discovery resolves it) while tests use a full addr. gui/presence.rs — the service: a dedicated thread + current-thread tokio runtime (mirroring the tray) binds the control endpoint and runs the accept loop, bridging inbound messages to a std mpsc the UI drains each tick and pinging the Waker so they land even while hidden to the tray. The whole friends stack (identity, control, CONTROL_ALPN, bind_control) is gated behind the `gui` feature — a headless CLI host runs no presence service — keeping the headless build lean and warning-free. Verified: loopback test delivers a FriendRequest across two real iroh endpoints with the correct authenticated sender id; the live GUI binds its control endpoint on launch under the persistent identity. fmt + clippy clean on both feature sets; headless and gui test suites pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
142 lines
5.0 KiB
Rust
142 lines
5.0 KiB
Rust
//! 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<PathBuf> {
|
|
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<SecretKey> {
|
|
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<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))
|
|
}
|
|
|
|
/// 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<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() {
|
|
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());
|
|
}
|
|
}
|