441 lines
16 KiB
Rust
441 lines
16 KiB
Rust
//! 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<NodeIdentity> = 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<NodeIdentity> {
|
|
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<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]
|
|
#[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");
|
|
}
|
|
}
|