huskies: merge 602_spike_node_identity_keypair_foundation_for_distributed_huskies
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user