diff --git a/Cargo.lock b/Cargo.lock index c4d128b9..5559880d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2296,6 +2296,7 @@ dependencies = [ "bytes", "chrono", "chrono-tz", + "ed25519-dalek", "eventsource-stream", "fastcrypto", "filetime", diff --git a/server/Cargo.toml b/server/Cargo.toml index 2d39999b..fc20a428 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -41,6 +41,7 @@ libsqlite3-sys = { version = "0.35.0", features = ["bundled"] } sqlx = { workspace = true } wait-timeout = "0.2.1" bft-json-crdt = { path = "../crates/bft-json-crdt", default-features = false, features = ["bft"] } +ed25519-dalek = { version = "2", features = ["rand_core"] } fastcrypto = "0.1.8" rand = "0.8" indexmap = { version = "2.2.6", features = ["serde"] } diff --git a/server/src/http/identity.rs b/server/src/http/identity.rs new file mode 100644 index 00000000..c35c99e1 --- /dev/null +++ b/server/src/http/identity.rs @@ -0,0 +1,62 @@ +//! Node identity endpoint — exposes this node's Ed25519 public key. +//! +//! `GET /identity` returns the node's ID and public key as JSON. No +//! authentication is required; only the public half of the keypair is +//! disclosed. + +use poem::handler; +use poem::web::Json; +use serde::Serialize; + +/// JSON response body for `GET /identity`. +#[derive(Serialize)] +pub struct IdentityResponse { + /// Node ID: lowercase hex-encoding of the 32-byte Ed25519 public key. + pub node_id: String, + /// Lowercase hex-encoding of the 32-byte Ed25519 public key. + pub pubkey: String, +} + +/// `GET /identity` — return this node's Ed25519 public key. +/// +/// Returns `{"node_id": "<64-hex>", "pubkey": "<64-hex>"}`. +/// No authentication required; the private key is never exposed. +#[handler] +pub fn identity_handler() -> Json { + match crate::node_identity::get_identity() { + Some(id) => Json(IdentityResponse { + node_id: id.node_id.clone(), + pubkey: id.pubkey_hex.clone(), + }), + None => Json(IdentityResponse { + node_id: "uninitialized".to_string(), + pubkey: "uninitialized".to_string(), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use poem::{Route, get, test::TestClient}; + + #[tokio::test] + async fn identity_endpoint_returns_json() { + // Initialise a temporary key file so get_identity() returns Some. + let tmp = tempfile::tempdir().unwrap(); + let key_path = tmp.path().join("node_identity.key"); + crate::node_identity::init_identity(&key_path).unwrap(); + + let app = Route::new().at("/identity", get(identity_handler)); + let cli = TestClient::new(app); + let resp = cli.get("/identity").send().await; + resp.assert_status_is_ok(); + + let body: serde_json::Value = resp.json().await.value().deserialize(); + let node_id = body["node_id"].as_str().unwrap(); + let pubkey = body["pubkey"].as_str().unwrap(); + assert_eq!(node_id.len(), 64); + assert!(node_id.chars().all(|c| c.is_ascii_hexdigit())); + assert_eq!(node_id, pubkey); + } +} diff --git a/server/src/http/mod.rs b/server/src/http/mod.rs index 565fa7d4..93c14cf3 100644 --- a/server/src/http/mod.rs +++ b/server/src/http/mod.rs @@ -9,6 +9,7 @@ pub mod chat; pub mod context; pub mod events; pub mod health; +pub mod identity; pub mod io; pub mod mcp; pub mod model; @@ -92,6 +93,7 @@ pub fn build_routes( post(mcp::mcp_post_handler).get(mcp::mcp_get_handler), ) .at("/health", get(health::health)) + .at("/identity", get(identity::identity_handler)) .at( "/oauth/authorize", get(oauth::oauth_authorize).data(oauth_state.clone()), diff --git a/server/src/main.rs b/server/src/main.rs index e2c8f7c0..486d540e 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -234,6 +234,24 @@ async fn main() -> Result<(), std::io::Error> { log_buffer::global().set_log_file(log_dir.join("server.log")); } + // Initialise the node's Ed25519 identity keypair (file-based, mode 0600). + // The key is stored at .huskies/node_identity.key and persisted across + // restarts. The public key is exposed via GET /identity. + { + let key_path = app_state + .project_root + .lock() + .unwrap() + .as_ref() + .map(|root| root.join(".huskies").join("node_identity.key")) + .unwrap_or_else(|| cwd.join(".huskies").join("node_identity.key")); + if let Err(e) = node_identity::init_identity(&key_path) { + slog!("[identity] Failed to initialise node identity keypair: {e}"); + } else if let Some(id) = node_identity::get_identity() { + slog!("[identity] Node ID: {}", id.node_id); + } + } + // Initialise the SQLite pipeline shadow-write database and CRDT state layer. // Clone the path out before the await so we don't hold the MutexGuard across // an await point. diff --git a/server/src/node_identity.rs b/server/src/node_identity.rs index d0e7dec5..a6ffcc40 100644 --- a/server/src/node_identity.rs +++ b/server/src/node_identity.rs @@ -44,8 +44,10 @@ use bft_json_crdt::keypair::{ ED25519_PUBLIC_KEY_LENGTH, ED25519_SIGNATURE_LENGTH, Ed25519KeyPair, Ed25519PublicKey, Ed25519Signature, sign, verify, }; +use ed25519_dalek::SigningKey; use fastcrypto::traits::{KeyPair, ToFromBytes}; use rand::RngCore; +use std::sync::OnceLock; // ── Types ───────────────────────────────────────────────────────────── @@ -133,6 +135,102 @@ pub fn public_key_hex(keypair: &Ed25519KeyPair) -> String { hex_encode(keypair.public().as_bytes()) } +// ── File-based keypair persistence (ed25519-dalek) ──────────────────────── + +/// Node identity loaded from (or freshly generated into) a `0600` key file. +/// +/// The `node_id` is the lowercase hex-encoding of the 32-byte Ed25519 public +/// key — the same value used as the CRDT author across all subsystems. +#[derive(Clone, Debug)] +pub struct NodeIdentity { + /// Node ID: lowercase hex-encoding of the 32-byte Ed25519 public key. + pub node_id: String, + /// Lowercase hex-encoding of the 32-byte Ed25519 public key. + pub pubkey_hex: String, +} + +/// Global node identity, initialised once at server startup. +static IDENTITY: OnceLock = OnceLock::new(); + +/// Load or create the node's Ed25519 keypair, storing it in a `0600` file. +/// +/// - **First boot**: generates a new keypair with `ed25519-dalek`, writes the +/// 32-byte signing-key seed to `path` with Unix mode `0600`, then returns +/// the derived `NodeIdentity`. +/// - **Subsequent boots**: reads the 32-byte seed from `path`, reconstructs +/// the keypair deterministically, and returns the same `NodeIdentity`. +/// +/// The file stores the raw 32-byte seed only. No headers, no PEM, no base64. +pub fn load_or_create_keypair_file(path: &std::path::Path) -> std::io::Result { + let signing_key = if path.exists() { + let bytes = std::fs::read(path)?; + let seed: [u8; 32] = bytes.try_into().map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "node identity key file must contain exactly 32 bytes", + ) + })?; + SigningKey::from_bytes(&seed) + } else { + // Generate a fresh keypair and persist the seed. + let sk = SigningKey::generate(&mut rand::rngs::OsRng); + let seed = sk.to_bytes(); + + // Create the file with mode 0600 at creation time (Unix) so the seed + // is never visible to other users even transiently. + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path)?; + file.write_all(&seed)?; + } + + // Non-Unix fallback: write first, then set permissions. + #[cfg(not(unix))] + { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, &seed)?; + } + + sk + }; + + let pubkey_bytes = signing_key.verifying_key().to_bytes(); + let pubkey_hex = hex_encode(&pubkey_bytes); + Ok(NodeIdentity { + node_id: pubkey_hex.clone(), + pubkey_hex, + }) +} + +/// Initialise the global node identity from a key file. +/// +/// Should be called once at server startup. Subsequent calls are no-ops. +pub fn init_identity(path: &std::path::Path) -> std::io::Result<()> { + if IDENTITY.get().is_none() { + let identity = load_or_create_keypair_file(path)?; + let _ = IDENTITY.set(identity); + } + Ok(()) +} + +/// Return a reference to the global node identity, or `None` if +/// [`init_identity`] has not yet been called. +pub fn get_identity() -> Option<&'static NodeIdentity> { + IDENTITY.get() +} + // ── Internal helpers ────────────────────────────────────────────────── fn hex_encode(bytes: &[u8]) -> String { @@ -247,4 +345,84 @@ mod tests { let kp = make_keypair(); assert_eq!(public_key_hex(&kp), public_key_hex(&kp)); } + + // ── File-based persistence tests ────────────────────────────────── + + #[test] + fn keypair_file_creates_on_first_boot() { + let tmp = tempfile::tempdir().unwrap(); + let key_path = tmp.path().join("node_identity.key"); + + assert!(!key_path.exists(), "key file should not exist yet"); + let identity = load_or_create_keypair_file(&key_path).unwrap(); + assert!(key_path.exists(), "key file should be created"); + assert_eq!(identity.node_id.len(), 64); + assert!(identity.node_id.chars().all(|c| c.is_ascii_hexdigit())); + assert_eq!(identity.node_id, identity.pubkey_hex); + } + + #[test] + fn keypair_file_persists_across_restarts() { + let tmp = tempfile::tempdir().unwrap(); + let key_path = tmp.path().join("node_identity.key"); + + // Simulate first boot. + let id1 = load_or_create_keypair_file(&key_path).unwrap(); + + // Simulate restart: load the same key file again. + let id2 = load_or_create_keypair_file(&key_path).unwrap(); + + assert_eq!( + id1.pubkey_hex, id2.pubkey_hex, + "pubkey must be unchanged after restart" + ); + assert_eq!( + id1.node_id, id2.node_id, + "node_id must be unchanged after restart" + ); + } + + #[test] + fn keypair_file_generates_unique_keys() { + let tmp1 = tempfile::tempdir().unwrap(); + let tmp2 = tempfile::tempdir().unwrap(); + + let id1 = load_or_create_keypair_file(&tmp1.path().join("id.key")).unwrap(); + let id2 = load_or_create_keypair_file(&tmp2.path().join("id.key")).unwrap(); + + assert_ne!( + id1.pubkey_hex, id2.pubkey_hex, + "two independent nodes must have different keys" + ); + } + + #[cfg(unix)] + #[test] + fn keypair_file_has_mode_0600() { + use std::os::unix::fs::PermissionsExt; + + let tmp = tempfile::tempdir().unwrap(); + let key_path = tmp.path().join("node_identity.key"); + + load_or_create_keypair_file(&key_path).unwrap(); + + let metadata = std::fs::metadata(&key_path).unwrap(); + let mode = metadata.permissions().mode(); + // The last 9 bits: owner=rw (0o600), group=--- (0o000), other=--- (0o000). + assert_eq!( + mode & 0o777, + 0o600, + "key file must have mode 0600, got {mode:#o}" + ); + } + + #[test] + fn keypair_file_rejects_wrong_size() { + let tmp = tempfile::tempdir().unwrap(); + let key_path = tmp.path().join("bad.key"); + std::fs::write(&key_path, b"tooshort").unwrap(); + + let result = load_or_create_keypair_file(&key_path); + assert!(result.is_err(), "should error on wrong-size key file"); + } }