diff --git a/Cargo.lock b/Cargo.lock index 38a97f54..c4d128b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2313,6 +2313,7 @@ dependencies = [ "poem-openapi", "portable-pty", "pulldown-cmark", + "rand 0.8.6", "regex", "reqwest 0.13.2", "rust-embed", diff --git a/server/Cargo.toml b/server/Cargo.toml index 051af686..2d39999b 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -42,6 +42,7 @@ sqlx = { workspace = true } wait-timeout = "0.2.1" bft-json-crdt = { path = "../crates/bft-json-crdt", default-features = false, features = ["bft"] } fastcrypto = "0.1.8" +rand = "0.8" indexmap = { version = "2.2.6", features = ["serde"] } [target.'cfg(unix)'.dependencies] diff --git a/server/src/main.rs b/server/src/main.rs index 459fee03..2a0b9b97 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -18,6 +18,7 @@ mod http; mod io; mod llm; pub mod log_buffer; +pub mod node_identity; pub(crate) mod pipeline_state; pub mod rebuild; mod service; diff --git a/server/src/node_identity.rs b/server/src/node_identity.rs new file mode 100644 index 00000000..d0e7dec5 --- /dev/null +++ b/server/src/node_identity.rs @@ -0,0 +1,250 @@ +//! 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::{ + ED25519_PUBLIC_KEY_LENGTH, ED25519_SIGNATURE_LENGTH, Ed25519KeyPair, Ed25519PublicKey, + Ed25519Signature, sign, verify, +}; +use fastcrypto::traits::{KeyPair, ToFromBytes}; +use rand::RngCore; + +// ── 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::thread_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.as_bytes()) +} + +// ── Verification ────────────────────────────────────────────────────── + +/// Verify that `signature_hex` is a valid Ed25519 signature over `challenge` +/// produced by the private key corresponding to `pubkey_hex`. +/// +/// 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 cryptographically valid for `challenge`. +/// +/// Returns `false` on any decode error or crypto failure — callers should +/// treat `false` as an auth rejection and close the connection. +pub fn verify_challenge(pubkey_hex: &str, challenge: &str, signature_hex: &str) -> bool { + let pubkey_bytes = match hex_decode(pubkey_hex) { + Some(b) if b.len() == ED25519_PUBLIC_KEY_LENGTH => b, + _ => return false, + }; + let sig_bytes = match hex_decode(signature_hex) { + Some(b) if b.len() == ED25519_SIGNATURE_LENGTH => b, + _ => return false, + }; + + let pubkey = match Ed25519PublicKey::from_bytes(&pubkey_bytes) { + Ok(k) => k, + Err(_) => return false, + }; + + let sig = match Ed25519Signature::from_bytes(&sig_bytes) { + Ok(s) => s, + Err(_) => return false, + }; + + verify(pubkey, challenge.as_bytes(), sig) +} + +// ── 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.public().as_bytes()) +} + +// ── Internal helpers ────────────────────────────────────────────────── + +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +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] + 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)); + } +}