//! Node identity — Ed25519 keypair foundation for distributed huskies. //! //! Each huskies node has a stable identity derived from an Ed25519 keypair //! that is generated on first run and persisted to SQLite. The public key //! hex-encodes to the node's ID (already used as the CRDT author in //! [`crate::crdt_state`]). //! //! This module adds the **challenge-response layer** needed for story 480 //! (cryptographic node auth on WebSocket connect): //! //! ```text //! Connector: Listener: //! connect() ──────────────────────► accept() //! ◄──────── challenge() ── //! sign_challenge(kp, challenge) ──► //! ◄── verify_challenge() OK/Reject //! ``` //! //! # Design decisions (spike findings) //! //! 1. **Keypair persistence**: the seed (32-byte private key) is stored in the //! `crdt_node_identity` SQLite table. On restart the same keypair is //! reconstructed deterministically, so the node's identity survives process //! restarts without any key-management ceremony. //! //! 2. **Node ID = hex(public key)**: the node ID is just the lowercase hex //! encoding of the 32-byte Ed25519 public key. This matches what //! `bft-json-crdt` uses as the CRDT author, so identity is consistent //! across the CRDT replication and the WebSocket auth layers. //! //! 3. **Challenge nonce**: 32 random bytes, hex-encoded. The connector signs //! the UTF-8 bytes of the hex string. This keeps the wire protocol simple //! (all values are printable hex strings) while providing 256 bits of //! replay protection. //! //! 4. **Signature encoding**: Ed25519 signatures (64 bytes) are hex-encoded //! for inclusion in JSON handshake messages. //! //! 5. **Trusted-key list** (not implemented here — story 480 scope): the //! verifier needs a set of allowed public keys; this module provides the //! `verify_challenge` primitive but leaves the allow-list to story 480. use bft_json_crdt::keypair::{Ed25519KeyPair, Ed25519Signature, sign}; use rand::Rng; use std::sync::OnceLock; // ── Types ───────────────────────────────────────────────────────────── /// A 32-byte random challenge nonce, hex-encoded for wire transfer. /// /// Generated by the listening side of a WebSocket connection to prove /// the connecting peer controls the private key for its advertised node ID. pub type ChallengeHex = String; /// Ed25519 signature over a challenge, hex-encoded for wire transfer. pub type SignatureHex = String; // ── Challenge generation ────────────────────────────────────────────── /// Generate a fresh 32-byte random challenge nonce (hex-encoded). /// /// The listener calls this and sends the result to the connecting peer, /// which must respond with a valid signature from its node keypair. pub fn generate_challenge() -> ChallengeHex { let mut bytes = [0u8; 32]; rand::rng().fill_bytes(&mut bytes); hex_encode(&bytes) } // ── Signing ─────────────────────────────────────────────────────────── /// Sign a challenge nonce with this node's keypair. /// /// The connector calls this with its own keypair and the challenge received /// from the listener. Returns the signature as a lowercase hex string. /// /// # How it works /// /// The `challenge` string's UTF-8 bytes are signed directly. Because the /// challenge is already a hex string (printable ASCII), this is equivalent /// to signing the raw challenge bytes but keeps the API free of extra /// encoding steps. pub fn sign_challenge(keypair: &Ed25519KeyPair, challenge: &str) -> SignatureHex { let sig: Ed25519Signature = sign(keypair, challenge.as_bytes()); hex_encode(&sig.to_bytes()) } // ── Verification ────────────────────────────────────────────────────── /// Verify that `signature_hex` is a valid Ed25519 signature over `challenge` /// produced by the private key corresponding to `pubkey_hex`. /// /// Uses [`verify_message_strict`] internally — the strict (non-malleable) /// variant from `ed25519-dalek`. Cofactor-manipulated or otherwise /// non-canonical signatures are rejected. pub fn verify_challenge(pubkey_hex: &str, challenge: &str, signature_hex: &str) -> bool { verify_message_strict(pubkey_hex, challenge.as_bytes(), signature_hex) } /// Verify an Ed25519 signature over an arbitrary `message` using /// `ed25519_dalek::VerifyingKey::verify_strict`. /// /// Returns `true` only if: /// - `pubkey_hex` decodes to a valid 32-byte Ed25519 public key. /// - `signature_hex` decodes to a valid 64-byte Ed25519 signature. /// - The signature is a strict (non-malleable) Ed25519 signature over `message`. /// /// Returns `false` on any decode error or crypto failure. pub fn verify_message_strict(pubkey_hex: &str, message: &[u8], signature_hex: &str) -> bool { let pubkey_bytes = match hex_decode(pubkey_hex) { Some(b) if b.len() == 32 => b, _ => return false, }; let sig_bytes = match hex_decode(signature_hex) { Some(b) if b.len() == 64 => b, _ => return false, }; let pubkey_arr: [u8; 32] = match pubkey_bytes.try_into() { Ok(a) => a, Err(_) => return false, }; let sig_arr: [u8; 64] = match sig_bytes.try_into() { Ok(a) => a, Err(_) => return false, }; let verifying_key = match ed25519_dalek::VerifyingKey::from_bytes(&pubkey_arr) { Ok(k) => k, Err(_) => return false, }; let sig = ed25519_dalek::Signature::from_bytes(&sig_arr); verifying_key.verify_strict(message, &sig).is_ok() } // ── Public key helpers ──────────────────────────────────────────────── /// Return the hex-encoded public key (node ID) for the given keypair. /// /// This is the same value written to the CRDT `claimed_by` and `node_id` /// registers, so it is the canonical node identity across all subsystems. pub fn public_key_hex(keypair: &Ed25519KeyPair) -> String { hex_encode(&keypair.verifying_key().to_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", ) })?; Ed25519KeyPair::from_bytes(&seed) } else { // Generate a fresh keypair and persist the seed. let mut seed = [0u8; 32]; rand::rng().fill_bytes(&mut seed); let sk = Ed25519KeyPair::from_bytes(&seed); // 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 { bytes.iter().map(|b| format!("{b:02x}")).collect() } #[allow(clippy::string_slice)] // s is hex (ASCII-only); i advances in steps of 2, always within bounds fn hex_decode(s: &str) -> Option> { if !s.len().is_multiple_of(2) { return None; } (0..s.len()) .step_by(2) .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()) .collect() } // ── Tests ───────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use bft_json_crdt::keypair::make_keypair; #[test] fn generate_challenge_is_64_hex_chars() { let ch = generate_challenge(); assert_eq!(ch.len(), 64, "32 bytes → 64 hex chars"); assert!(ch.chars().all(|c| c.is_ascii_hexdigit())); } #[test] fn generate_challenge_is_unique() { let a = generate_challenge(); let b = generate_challenge(); assert_ne!(a, b, "challenges must be unique"); } #[test] fn sign_and_verify_roundtrip() { let kp = make_keypair(); let challenge = generate_challenge(); let pubkey = public_key_hex(&kp); let sig = sign_challenge(&kp, &challenge); assert!( verify_challenge(&pubkey, &challenge, &sig), "valid signature must verify" ); } #[test] fn verify_rejects_wrong_challenge() { let kp = make_keypair(); let pubkey = public_key_hex(&kp); let challenge = generate_challenge(); let sig = sign_challenge(&kp, &challenge); let other_challenge = generate_challenge(); assert!( !verify_challenge(&pubkey, &other_challenge, &sig), "signature for different challenge must be rejected" ); } #[test] fn verify_rejects_wrong_key() { let kp1 = make_keypair(); let kp2 = make_keypair(); let challenge = generate_challenge(); let sig = sign_challenge(&kp1, &challenge); // Verify with kp2's public key — must fail. let wrong_pubkey = public_key_hex(&kp2); assert!( !verify_challenge(&wrong_pubkey, &challenge, &sig), "signature from different keypair must be rejected" ); } #[test] fn verify_rejects_invalid_pubkey_hex() { let kp = make_keypair(); let challenge = generate_challenge(); let sig = sign_challenge(&kp, &challenge); assert!(!verify_challenge("notvalidhex!!", &challenge, &sig)); } #[test] #[allow(clippy::string_slice)] // sig is hex (ASCII-only); subtracting 4 stays within bounds for any valid sig fn verify_rejects_truncated_signature() { let kp = make_keypair(); let pubkey = public_key_hex(&kp); let challenge = generate_challenge(); let sig = sign_challenge(&kp, &challenge); // Truncate the signature. let short_sig = &sig[..sig.len() - 4]; assert!(!verify_challenge(&pubkey, &challenge, short_sig)); } #[test] fn public_key_hex_is_64_chars() { let kp = make_keypair(); let hex = public_key_hex(&kp); // Ed25519 public keys are 32 bytes → 64 hex chars. assert_eq!(hex.len(), 64); assert!(hex.chars().all(|c| c.is_ascii_hexdigit())); } #[test] fn public_key_hex_is_stable() { // The same keypair must always produce the same node ID. 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"); } }