From dfeb78221cf1a64be688be233472a74f127e04f8 Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Sat, 22 Aug 2026 09:13:48 -0400 Subject: [PATCH] feat(chronogrid_net): complete Task 2.2 - Node Identity & Swarm Key --- DECISION_LOG.md | 15 + crates/chronogrid_net/Cargo.toml | 16 + crates/chronogrid_net/src/error.rs | 34 ++ crates/chronogrid_net/src/identity.rs | 359 ++++++++++++++++++ crates/chronogrid_net/src/lib.rs | 45 ++- crates/chronogrid_net/src/swarm_key.rs | 337 ++++++++++++++++ .../ADR/0008-net-node-identity-swarm-key.md | 135 +++++++ docs/TASKS/phase_2_networking_and_schema.md | 2 +- 8 files changed, 934 insertions(+), 9 deletions(-) create mode 100644 crates/chronogrid_net/src/error.rs create mode 100644 crates/chronogrid_net/src/identity.rs create mode 100644 crates/chronogrid_net/src/swarm_key.rs create mode 100644 docs/ARCHITECTURE/ADR/0008-net-node-identity-swarm-key.md diff --git a/DECISION_LOG.md b/DECISION_LOG.md index 8a77024..3c7ffaf 100644 --- a/DECISION_LOG.md +++ b/DECISION_LOG.md @@ -79,6 +79,21 @@ - Verification script confirmed: all 13 tables present in schema.sql (PASS), Flutter analysis clean (PASS). - Full ADR at `docs/ARCHITECTURE/ADR/0007-schema-extension-phase2-entities.md`. +## [2026-08-22] - ADL-008: chronogrid_net Crate — Node Identity & Swarm Key (Task 2.2) + +- **Status**: Accepted +- **Context**: Task 2.2 required implementing the foundational identity and cluster-authentication primitives for `chronogrid_net`: Ed25519 node keypair (`NodeIdentity`), SHA-256-derived `NodeId`, and 256-bit pre-shared `SwarmKey`. +- **Decision**: + - `ed25519-dalek v2` chosen for Ed25519 keypair generation (audited, `rand_core`-compatible, libp2p-compatible for Task 2.3 integration). + - `NodeId` = SHA-256(public key bytes) → 32-byte / 64-hex-char stable identifier; maps directly to `node_peers.id` primary key in schema. + - `SwarmKey` = 32 bytes from `OsRng`; `fingerprint()` = first 16 hex chars of SHA-256(key) → maps to `node_peers.swarm_key_fingerprint`. + - Both `NodeIdentity` and `SwarmKey` implement `ZeroizeOnDrop`; `PersistedIdentity` also zeroized to prevent secret hex strings lingering in heap. + - `SwarmKey::eq` uses constant-time XOR accumulation with `black_box` to mitigate timing side-channels. + - Persistence: `NodeIdentity` → JSON (hex-encoded keys); `SwarmKey` → 64-char hex file. Both stored inside encrypted vault directory. + - `load_or_create_identity()` and `SwarmKey::load_or_create()` provide idempotent file-backed initialization. + - 27 unit tests: generation uniqueness, determinism, hex/bytes round-trips, file I/O, error paths, debug redaction, constant-time equality. + - Full ADR at `docs/ARCHITECTURE/ADR/0008-net-node-identity-swarm-key.md`. + ## [2026-08-22] - ADL-006: Architecture Review Board Assessment & Master Development Plan v2 - **Status**: Accepted diff --git a/crates/chronogrid_net/Cargo.toml b/crates/chronogrid_net/Cargo.toml index 1f71b60..4d041c2 100644 --- a/crates/chronogrid_net/Cargo.toml +++ b/crates/chronogrid_net/Cargo.toml @@ -7,5 +7,21 @@ rust-version.workspace = true publish = false [dependencies] +# Ed25519 keypair generation and signing +ed25519-dalek = { version = "2", features = ["rand_core"] } +# Cryptographically-secure random number generation +rand = "0.8" +# SHA-256 for NodeId and SwarmKey fingerprint derivation +sha2 = "0.10" +# Hex encoding for NodeId and fingerprints +hex = "0.4" +# Serialization for keypair persistence +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# Error handling +thiserror = "2" +# Zeroize sensitive key material on drop +zeroize = { version = "1", features = ["derive"] } [dev-dependencies] +tempfile = "3" diff --git a/crates/chronogrid_net/src/error.rs b/crates/chronogrid_net/src/error.rs new file mode 100644 index 0000000..8ec6bc3 --- /dev/null +++ b/crates/chronogrid_net/src/error.rs @@ -0,0 +1,34 @@ +//! Error types for `chronogrid_net`. + +use thiserror::Error; + +/// All errors that can be produced by the `chronogrid_net` crate. +#[derive(Debug, Error)] +pub enum NetError { + /// An I/O error occurred while reading or writing identity files. + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + /// JSON serialization / deserialization failed. + #[error("serialization error: {0}")] + Serialization(#[from] serde_json::Error), + + /// The Ed25519 keypair bytes on disk are malformed or have an invalid length. + #[error("invalid keypair bytes: {0}")] + InvalidKeypair(String), + + /// The swarm key bytes are not exactly 32 bytes. + #[error("invalid swarm key: expected 32 bytes, got {0}")] + InvalidSwarmKeyLength(usize), + + /// The swarm key hex string could not be decoded. + #[error("invalid swarm key hex: {0}")] + InvalidSwarmKeyHex(String), + + /// A hex encoding / decoding error. + #[error("hex decode error: {0}")] + HexDecode(#[from] hex::FromHexError), +} + +/// Convenience `Result` alias for `chronogrid_net`. +pub type NetResult = Result; diff --git a/crates/chronogrid_net/src/identity.rs b/crates/chronogrid_net/src/identity.rs new file mode 100644 index 0000000..ffcd68f --- /dev/null +++ b/crates/chronogrid_net/src/identity.rs @@ -0,0 +1,359 @@ +//! Node identity management for ChronoGrid P2P. +//! +//! Each ChronoGrid node owns a persistent Ed25519 keypair. The public key is +//! hashed with SHA-256 to produce a 32-byte `NodeId` that uniquely identifies +//! the node across the mesh network. +//! +//! # Persistence format +//! +//! The keypair is serialized as a JSON object containing two hex-encoded fields: +//! +//! ```json +//! { +//! "secret_key_hex": "<64-char hex>", +//! "public_key_hex": "<64-char hex>" +//! } +//! ``` +//! +//! The file should be stored inside the encrypted vault directory and protected +//! by OS-level file permissions (mode 0600 on Unix). + +use std::path::Path; + +use ed25519_dalek::{SigningKey, VerifyingKey}; +use rand::rngs::OsRng; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +use crate::error::{NetError, NetResult}; + +// --------------------------------------------------------------------------- +// NodeId +// --------------------------------------------------------------------------- + +/// A 32-byte node identifier derived by SHA-256-hashing the Ed25519 public key. +/// +/// `NodeId` is the canonical, stable address of a peer in the ChronoGrid mesh. +/// It is safe to share publicly and is stored in the `node_peers` table. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct NodeId([u8; 32]); + +impl NodeId { + /// Derive a `NodeId` from an Ed25519 verifying (public) key. + pub fn from_verifying_key(vk: &VerifyingKey) -> Self { + let hash = Sha256::digest(vk.as_bytes()); + let mut bytes = [0u8; 32]; + bytes.copy_from_slice(&hash); + NodeId(bytes) + } + + /// Return the raw 32-byte representation. + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + /// Return the lowercase hex string representation (64 characters). + pub fn to_hex(&self) -> String { + hex::encode(self.0) + } +} + +impl std::fmt::Display for NodeId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.to_hex()) + } +} + +// --------------------------------------------------------------------------- +// NodeIdentity +// --------------------------------------------------------------------------- + +/// The complete Ed25519 identity of a ChronoGrid node. +/// +/// Contains the signing key (secret + public) and the derived `NodeId`. +/// The secret key bytes are zeroized on drop. +#[derive(ZeroizeOnDrop)] +pub struct NodeIdentity { + /// The Ed25519 signing key (contains both secret and public components). + #[zeroize(skip)] // SigningKey implements Zeroize internally via Drop + signing_key: SigningKey, + /// The stable node identifier derived from the public key. + #[zeroize(skip)] + node_id: NodeId, +} + +impl NodeIdentity { + /// Create a `NodeIdentity` from an existing `SigningKey`. + fn from_signing_key(signing_key: SigningKey) -> Self { + let node_id = NodeId::from_verifying_key(signing_key.verifying_key()); + NodeIdentity { signing_key, node_id } + } + + /// Return the Ed25519 verifying (public) key. + pub fn verifying_key(&self) -> &VerifyingKey { + self.signing_key.verifying_key() + } + + /// Return the raw 32-byte Ed25519 public key. + pub fn public_key_bytes(&self) -> [u8; 32] { + self.signing_key.verifying_key().to_bytes() + } + + /// Return the `NodeId` for this identity. + pub fn node_id(&self) -> &NodeId { + &self.node_id + } + + /// Return the signing key (for use in transport layer authentication). + pub fn signing_key(&self) -> &SigningKey { + &self.signing_key + } +} + +impl std::fmt::Debug for NodeIdentity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("NodeIdentity") + .field("node_id", &self.node_id.to_hex()) + .field("public_key", &hex::encode(self.public_key_bytes())) + .finish_non_exhaustive() + } +} + +// --------------------------------------------------------------------------- +// Serialization helper (for file persistence) +// --------------------------------------------------------------------------- + +/// JSON-serializable representation of a `NodeIdentity` keypair. +#[derive(Serialize, Deserialize, Zeroize, ZeroizeOnDrop)] +struct PersistedIdentity { + /// Hex-encoded 32-byte Ed25519 secret (seed) key. + secret_key_hex: String, + /// Hex-encoded 32-byte Ed25519 public key. + public_key_hex: String, +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Generate a fresh `NodeIdentity` using a cryptographically secure RNG. +/// +/// # Example +/// +/// ```rust +/// use chronogrid_net::identity::generate_node_identity; +/// +/// let identity = generate_node_identity(); +/// println!("NodeId: {}", identity.node_id()); +/// ``` +pub fn generate_node_identity() -> NodeIdentity { + let signing_key = SigningKey::generate(&mut OsRng); + NodeIdentity::from_signing_key(signing_key) +} + +/// Load a `NodeIdentity` from `path`, or generate and persist a new one if the +/// file does not exist. +/// +/// The keypair is stored as a JSON file containing hex-encoded key material. +/// The caller is responsible for ensuring the file is stored inside the +/// encrypted vault directory. +/// +/// # Errors +/// +/// Returns [`NetError::Io`] if the file cannot be read or written. +/// Returns [`NetError::Serialization`] if the JSON is malformed. +/// Returns [`NetError::InvalidKeypair`] if the stored key bytes are invalid. +pub fn load_or_create_identity(path: &Path) -> NetResult { + if path.exists() { + load_identity(path) + } else { + let identity = generate_node_identity(); + save_identity(&identity, path)?; + Ok(identity) + } +} + +/// Persist a `NodeIdentity` to `path` as a JSON file. +/// +/// Any existing file at `path` is overwritten. +pub fn save_identity(identity: &NodeIdentity, path: &Path) -> NetResult<()> { + // Create parent directories if they don't exist. + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let persisted = PersistedIdentity { + secret_key_hex: hex::encode(identity.signing_key.to_bytes()), + public_key_hex: hex::encode(identity.public_key_bytes()), + }; + + let json = serde_json::to_string_pretty(&persisted)?; + std::fs::write(path, json)?; + Ok(()) +} + +/// Load a `NodeIdentity` from a JSON file previously written by [`save_identity`]. +pub fn load_identity(path: &Path) -> NetResult { + let json = std::fs::read_to_string(path)?; + let persisted: PersistedIdentity = serde_json::from_str(&json)?; + + let secret_bytes = hex::decode(&persisted.secret_key_hex)?; + if secret_bytes.len() != 32 { + return Err(NetError::InvalidKeypair(format!( + "expected 32 secret key bytes, got {}", + secret_bytes.len() + ))); + } + + let mut seed = [0u8; 32]; + seed.copy_from_slice(&secret_bytes); + let signing_key = SigningKey::from_bytes(&seed); + + // Validate that the stored public key matches the derived one. + let expected_pub = hex::encode(signing_key.verifying_key().to_bytes()); + if expected_pub != persisted.public_key_hex { + return Err(NetError::InvalidKeypair( + "stored public key does not match derived public key".to_string(), + )); + } + + Ok(NodeIdentity::from_signing_key(signing_key)) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn test_generate_node_identity_produces_unique_ids() { + let id1 = generate_node_identity(); + let id2 = generate_node_identity(); + // Two independently generated identities must have different NodeIds. + assert_ne!(id1.node_id(), id2.node_id()); + } + + #[test] + fn test_node_id_is_deterministic_from_public_key() { + let identity = generate_node_identity(); + let node_id_a = NodeId::from_verifying_key(identity.verifying_key()); + let node_id_b = NodeId::from_verifying_key(identity.verifying_key()); + assert_eq!(node_id_a, node_id_b); + } + + #[test] + fn test_node_id_hex_is_64_chars() { + let identity = generate_node_identity(); + let hex = identity.node_id().to_hex(); + assert_eq!(hex.len(), 64, "NodeId hex must be 64 characters (32 bytes)"); + assert!(hex.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_node_id_display_matches_hex() { + let identity = generate_node_identity(); + assert_eq!( + identity.node_id().to_string(), + identity.node_id().to_hex() + ); + } + + #[test] + fn test_public_key_bytes_length() { + let identity = generate_node_identity(); + assert_eq!(identity.public_key_bytes().len(), 32); + } + + #[test] + fn test_save_and_load_identity_round_trip() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("identity.json"); + + let original = generate_node_identity(); + save_identity(&original, &path).expect("save_identity"); + + let loaded = load_identity(&path).expect("load_identity"); + + // NodeId must be identical after round-trip. + assert_eq!(original.node_id(), loaded.node_id()); + // Public key bytes must match. + assert_eq!(original.public_key_bytes(), loaded.public_key_bytes()); + } + + #[test] + fn test_load_or_create_creates_file_when_missing() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("identity.json"); + + assert!(!path.exists(), "file should not exist yet"); + let identity = load_or_create_identity(&path).expect("load_or_create_identity"); + assert!(path.exists(), "file should have been created"); + + // Loading again must return the same identity. + let loaded_again = load_or_create_identity(&path).expect("second load"); + assert_eq!(identity.node_id(), loaded_again.node_id()); + } + + #[test] + fn test_load_or_create_is_idempotent() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("identity.json"); + + let first = load_or_create_identity(&path).expect("first call"); + let second = load_or_create_identity(&path).expect("second call"); + assert_eq!(first.node_id(), second.node_id()); + } + + #[test] + fn test_load_identity_rejects_wrong_public_key() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("identity.json"); + + // Write a valid identity, then tamper with the public key field. + let identity = generate_node_identity(); + save_identity(&identity, &path).expect("save"); + + let json = std::fs::read_to_string(&path).expect("read"); + let mut obj: serde_json::Value = serde_json::from_str(&json).expect("parse"); + // Replace public_key_hex with all-zeros. + obj["public_key_hex"] = serde_json::Value::String("0".repeat(64)); + std::fs::write(&path, serde_json::to_string(&obj).expect("serialize")).expect("write"); + + let result = load_identity(&path); + assert!( + matches!(result, Err(NetError::InvalidKeypair(_))), + "expected InvalidKeypair error, got: {:?}", + result + ); + } + + #[test] + fn test_load_identity_rejects_short_secret_key() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("identity.json"); + + // Write JSON with a truncated secret key. + let bad_json = r#"{"secret_key_hex":"deadbeef","public_key_hex":"0000000000000000000000000000000000000000000000000000000000000000"}"#; + std::fs::write(&path, bad_json).expect("write"); + + let result = load_identity(&path); + assert!( + matches!(result, Err(NetError::InvalidKeypair(_))), + "expected InvalidKeypair error, got: {:?}", + result + ); + } + + #[test] + fn test_node_id_as_bytes_matches_hex() { + let identity = generate_node_identity(); + let node_id = identity.node_id(); + let from_bytes = hex::encode(node_id.as_bytes()); + assert_eq!(from_bytes, node_id.to_hex()); + } +} diff --git a/crates/chronogrid_net/src/lib.rs b/crates/chronogrid_net/src/lib.rs index 49efda3..82d8fb5 100644 --- a/crates/chronogrid_net/src/lib.rs +++ b/crates/chronogrid_net/src/lib.rs @@ -1,12 +1,41 @@ //! # chronogrid_net //! //! P2P mesh networking layer for ChronoGrid. -//! Provides libp2p-based peer discovery and CRDT sync over QUIC with Noise XK encryption. +//! +//! Provides libp2p-based peer discovery and CRDT delta synchronization over +//! QUIC with Noise XK encryption. +//! +//! ## Module Overview +//! +//! | Module | Responsibility | +//! |---------------|-------------------------------------------------------------| +//! | [`error`] | Unified error type (`NetError`) and `NetResult` alias | +//! | [`identity`] | Ed25519 node keypair, `NodeId` derivation, file persistence | +//! | [`swarm_key`] | 256-bit pre-shared `SwarmKey` for cluster authentication | +//! +//! ## Quick Start +//! +//! ```rust,no_run +//! use std::path::Path; +//! use chronogrid_net::identity::load_or_create_identity; +//! use chronogrid_net::swarm_key::SwarmKey; +//! +//! // Load or generate a persistent node identity. +//! let identity = load_or_create_identity(Path::new("/var/lib/chronogrid/identity.json")) +//! .expect("identity"); +//! println!("Node ID: {}", identity.node_id()); +//! +//! // Load or generate the cluster swarm key. +//! let swarm_key = SwarmKey::load_or_create(Path::new("/var/lib/chronogrid/swarm.key")) +//! .expect("swarm key"); +//! println!("Swarm fingerprint: {}", swarm_key.fingerprint()); +//! ``` -#[cfg(test)] -mod tests { - #[test] - fn it_works() { - assert!(true); - } -} +pub mod error; +pub mod identity; +pub mod swarm_key; + +// Re-export the most commonly used types at the crate root for convenience. +pub use error::{NetError, NetResult}; +pub use identity::{NodeId, NodeIdentity}; +pub use swarm_key::SwarmKey; diff --git a/crates/chronogrid_net/src/swarm_key.rs b/crates/chronogrid_net/src/swarm_key.rs new file mode 100644 index 0000000..7cfd583 --- /dev/null +++ b/crates/chronogrid_net/src/swarm_key.rs @@ -0,0 +1,337 @@ +//! Swarm Key management for ChronoGrid P2P. +//! +//! A **Swarm Key** is a 256-bit (32-byte) pre-shared secret that gates access +//! to a private ChronoGrid mesh cluster. Only nodes that possess the correct +//! Swarm Key can participate in the network; unauthenticated nodes cannot +//! decode handshake packets or join the gossip overlay. +//! +//! # Persistence format +//! +//! The Swarm Key is stored as a 64-character lowercase hex string in a plain +//! text file (one line, no newline required). The file must be stored inside +//! the encrypted vault directory. +//! +//! # Security note +//! +//! The raw key bytes are zeroized on drop via [`ZeroizeOnDrop`]. + +use std::path::Path; + +use rand::RngCore; +use rand::rngs::OsRng; +use sha2::{Digest, Sha256}; +use zeroize::ZeroizeOnDrop; + +use crate::error::{NetError, NetResult}; + +// --------------------------------------------------------------------------- +// SwarmKey +// --------------------------------------------------------------------------- + +/// A 256-bit pre-shared key that authenticates membership in a ChronoGrid +/// private mesh cluster. +/// +/// The key bytes are zeroized from memory when the struct is dropped. +#[derive(Clone, ZeroizeOnDrop)] +pub struct SwarmKey { + bytes: [u8; 32], +} + +impl SwarmKey { + /// Generate a fresh `SwarmKey` using a cryptographically secure RNG. + /// + /// # Example + /// + /// ```rust + /// use chronogrid_net::swarm_key::SwarmKey; + /// + /// let key = SwarmKey::generate(); + /// println!("Swarm key: {}", key.to_hex()); + /// ``` + pub fn generate() -> Self { + let mut bytes = [0u8; 32]; + OsRng.fill_bytes(&mut bytes); + SwarmKey { bytes } + } + + /// Construct a `SwarmKey` from exactly 32 raw bytes. + /// + /// # Errors + /// + /// Returns [`NetError::InvalidSwarmKeyLength`] if `bytes.len() != 32`. + pub fn from_bytes(bytes: &[u8]) -> NetResult { + if bytes.len() != 32 { + return Err(NetError::InvalidSwarmKeyLength(bytes.len())); + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(bytes); + Ok(SwarmKey { bytes: arr }) + } + + /// Decode a `SwarmKey` from a 64-character lowercase hex string. + /// + /// # Errors + /// + /// Returns [`NetError::InvalidSwarmKeyHex`] if the string is not valid hex. + /// Returns [`NetError::InvalidSwarmKeyLength`] if the decoded length is not 32. + pub fn from_hex(hex_str: &str) -> NetResult { + let decoded = hex::decode(hex_str).map_err(|e| { + NetError::InvalidSwarmKeyHex(format!("hex decode failed: {e}")) + })?; + Self::from_bytes(&decoded) + } + + /// Return the raw 32-byte key material. + pub fn as_bytes(&self) -> &[u8; 32] { + &self.bytes + } + + /// Return the lowercase hex string representation (64 characters). + pub fn to_hex(&self) -> String { + hex::encode(self.bytes) + } + + /// Compute a short fingerprint of the Swarm Key for display and storage in + /// the `node_peers` table (`swarm_key_fingerprint` column). + /// + /// The fingerprint is the first 16 hex characters of the SHA-256 hash of + /// the key bytes (8 bytes → 16 hex chars), matching the schema definition. + pub fn fingerprint(&self) -> String { + let hash = Sha256::digest(&self.bytes); + hex::encode(&hash[..8]) // first 8 bytes → 16 hex chars + } + + /// Validate that this `SwarmKey` is structurally valid (always true for a + /// correctly constructed key, but useful as an explicit assertion in tests). + pub fn is_valid(&self) -> bool { + self.bytes.len() == 32 + } + + /// Persist the Swarm Key to `path` as a 64-character hex string. + /// + /// Any existing file at `path` is overwritten. + pub fn save(&self, path: &Path) -> NetResult<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, self.to_hex())?; + Ok(()) + } + + /// Load a `SwarmKey` from a file containing a 64-character hex string. + /// + /// Leading/trailing whitespace is stripped before decoding. + pub fn load(path: &Path) -> NetResult { + let content = std::fs::read_to_string(path)?; + Self::from_hex(content.trim()) + } + + /// Load a `SwarmKey` from `path`, or generate and persist a new one if the + /// file does not exist. + pub fn load_or_create(path: &Path) -> NetResult { + if path.exists() { + Self::load(path) + } else { + let key = Self::generate(); + key.save(path)?; + Ok(key) + } + } +} + +impl std::fmt::Debug for SwarmKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Never print the raw key in debug output — show only the fingerprint. + f.debug_struct("SwarmKey") + .field("fingerprint", &self.fingerprint()) + .finish_non_exhaustive() + } +} + +impl PartialEq for SwarmKey { + fn eq(&self, other: &Self) -> bool { + // Constant-time comparison to avoid timing side-channels. + use std::hint::black_box; + let mut diff = 0u8; + for (a, b) in self.bytes.iter().zip(other.bytes.iter()) { + diff |= black_box(a ^ b); + } + diff == 0 + } +} + +impl Eq for SwarmKey {} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn test_generate_produces_32_bytes() { + let key = SwarmKey::generate(); + assert_eq!(key.as_bytes().len(), 32); + } + + #[test] + fn test_generate_is_random() { + let key1 = SwarmKey::generate(); + let key2 = SwarmKey::generate(); + // Two independently generated keys must differ (with overwhelming probability). + assert_ne!(key1, key2); + } + + #[test] + fn test_is_valid_always_true_for_generated_key() { + let key = SwarmKey::generate(); + assert!(key.is_valid()); + } + + #[test] + fn test_to_hex_is_64_chars() { + let key = SwarmKey::generate(); + let hex = key.to_hex(); + assert_eq!(hex.len(), 64); + assert!(hex.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_from_hex_round_trip() { + let key = SwarmKey::generate(); + let hex = key.to_hex(); + let restored = SwarmKey::from_hex(&hex).expect("from_hex"); + assert_eq!(key, restored); + } + + #[test] + fn test_from_bytes_round_trip() { + let key = SwarmKey::generate(); + let restored = SwarmKey::from_bytes(key.as_bytes()).expect("from_bytes"); + assert_eq!(key, restored); + } + + #[test] + fn test_from_bytes_rejects_wrong_length() { + let short = [0u8; 16]; + let result = SwarmKey::from_bytes(&short); + assert!( + matches!(result, Err(NetError::InvalidSwarmKeyLength(16))), + "expected InvalidSwarmKeyLength(16), got: {:?}", + result + ); + } + + #[test] + fn test_from_hex_rejects_invalid_hex() { + let result = SwarmKey::from_hex("not_valid_hex_at_all_zzzz"); + assert!( + matches!(result, Err(NetError::InvalidSwarmKeyHex(_))), + "expected InvalidSwarmKeyHex, got: {:?}", + result + ); + } + + #[test] + fn test_from_hex_rejects_wrong_length_hex() { + // Valid hex but only 16 bytes (32 hex chars). + let short_hex = "deadbeefdeadbeefdeadbeefdeadbeef"; + let result = SwarmKey::from_hex(short_hex); + assert!( + matches!(result, Err(NetError::InvalidSwarmKeyLength(16))), + "expected InvalidSwarmKeyLength(16), got: {:?}", + result + ); + } + + #[test] + fn test_fingerprint_is_16_chars() { + let key = SwarmKey::generate(); + let fp = key.fingerprint(); + assert_eq!(fp.len(), 16, "fingerprint must be 16 hex chars (8 bytes)"); + assert!(fp.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_fingerprint_is_deterministic() { + let key = SwarmKey::generate(); + assert_eq!(key.fingerprint(), key.fingerprint()); + } + + #[test] + fn test_different_keys_have_different_fingerprints() { + let key1 = SwarmKey::generate(); + let key2 = SwarmKey::generate(); + // With overwhelming probability two random keys have different fingerprints. + assert_ne!(key1.fingerprint(), key2.fingerprint()); + } + + #[test] + fn test_save_and_load_round_trip() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("swarm.key"); + + let key = SwarmKey::generate(); + key.save(&path).expect("save"); + + let loaded = SwarmKey::load(&path).expect("load"); + assert_eq!(key, loaded); + } + + #[test] + fn test_load_or_create_creates_file_when_missing() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("swarm.key"); + + assert!(!path.exists()); + let key = SwarmKey::load_or_create(&path).expect("load_or_create"); + assert!(path.exists()); + + let loaded = SwarmKey::load(&path).expect("load"); + assert_eq!(key, loaded); + } + + #[test] + fn test_load_or_create_is_idempotent() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("swarm.key"); + + let first = SwarmKey::load_or_create(&path).expect("first"); + let second = SwarmKey::load_or_create(&path).expect("second"); + assert_eq!(first, second); + } + + #[test] + fn test_load_strips_whitespace() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("swarm.key"); + + let key = SwarmKey::generate(); + // Write with surrounding whitespace / newline. + std::fs::write(&path, format!(" {}\n", key.to_hex())).expect("write"); + + let loaded = SwarmKey::load(&path).expect("load"); + assert_eq!(key, loaded); + } + + #[test] + fn test_debug_does_not_expose_raw_key() { + let key = SwarmKey::generate(); + let debug_str = format!("{:?}", key); + // The raw hex key must NOT appear in debug output. + assert!(!debug_str.contains(&key.to_hex())); + // The fingerprint SHOULD appear. + assert!(debug_str.contains(&key.fingerprint())); + } + + #[test] + fn test_equality_is_symmetric() { + let key = SwarmKey::generate(); + let copy = SwarmKey::from_bytes(key.as_bytes()).expect("copy"); + assert_eq!(key, copy); + assert_eq!(copy, key); + } +} diff --git a/docs/ARCHITECTURE/ADR/0008-net-node-identity-swarm-key.md b/docs/ARCHITECTURE/ADR/0008-net-node-identity-swarm-key.md new file mode 100644 index 0000000..28995dd --- /dev/null +++ b/docs/ARCHITECTURE/ADR/0008-net-node-identity-swarm-key.md @@ -0,0 +1,135 @@ +# ADR-0008: chronogrid_net — Node Identity & Swarm Key (Task 2.2) + +**Date:** 2026-08-22 +**Status:** Accepted +**Deciders:** Architecture Review Board (automated agent cycle) +**Task:** 2.2 — Implement chronogrid_net: Node Identity & Swarm Key + +--- + +## Context + +Task 2.2 requires implementing the foundational identity and cluster-authentication +primitives for the `chronogrid_net` crate. Every ChronoGrid node must have a +stable, cryptographically verifiable identity and a mechanism to restrict mesh +participation to authorized devices only. + +The security specification (`docs/ARCHITECTURE/02-security-and-crypto.md`) mandates: + +- **Node Identity:** Ed25519 keypair; public key acts as the `NodeId`. +- **Swarm Key:** 256-bit pre-shared secret; unauthenticated nodes cannot join. +- **Wire Protocol:** Noise XK (X25519 + ChaCha20-Poly1305) — requires the + Ed25519 identity for the static key role. + +--- + +## Decision + +### 1. Ed25519 Keypair via `ed25519-dalek v2` + +`ed25519-dalek` v2 is the current stable, audited Rust implementation of +Ed25519. It integrates cleanly with `rand` v0.8 via the `rand_core` feature +and produces `SigningKey` / `VerifyingKey` types that are compatible with the +libp2p identity layer (Task 2.3 will wrap these into a `libp2p::identity::Keypair`). + +**Alternative considered:** Using `libp2p::identity` directly for key generation. +**Rejected because:** Task 2.2 must not introduce the full libp2p dependency +tree yet; identity primitives should be independently testable without the +network stack. + +### 2. NodeId = SHA-256(public key bytes) + +The `NodeId` is derived by SHA-256-hashing the 32-byte Ed25519 public key, +producing a 32-byte (64 hex char) stable identifier. + +- Matches the `node_peers.id` column definition in `schema.sql` + (`NodeId: Ed25519 public key hash (hex)`). +- SHA-256 is collision-resistant and produces a fixed-length output suitable + for use as a database primary key and display string. +- The `NodeId` is distinct from the raw public key, preventing accidental + exposure of the key material in log output. + +### 3. SwarmKey = 32 random bytes (OsRng) + +The Swarm Key is generated using `OsRng` (OS-backed CSPRNG) and stored as a +64-character lowercase hex string. This matches the `node_peers.swarm_key_fingerprint` +schema column which stores the first 16 hex chars of `SHA-256(swarm_key_bytes)`. + +**Fingerprint design:** Only the first 8 bytes (16 hex chars) of the SHA-256 +hash are stored in the database, providing a non-reversible display identifier +without exposing the full key. + +### 4. Zeroize on Drop + +Both `NodeIdentity` (via `ZeroizeOnDrop` derive) and `SwarmKey` (via +`ZeroizeOnDrop` derive) scrub their secret bytes from memory when dropped. +`PersistedIdentity` (the JSON serialization helper) also derives `Zeroize + +ZeroizeOnDrop` to prevent secret key hex strings from lingering in heap memory. + +### 5. Persistence Format + +| Artifact | Format | Location (recommended) | +|--------------|---------------------------------|-------------------------------------------| +| NodeIdentity | JSON with hex-encoded key fields | `/identity.json` | +| SwarmKey | 64-char hex string (plain text) | `/swarm.key` | + +Both files must reside inside the encrypted vault directory (SQLCipher-protected +at rest). The caller (`chronogrid_daemon` / Flutter app) is responsible for +placing them in the correct location. + +### 6. Constant-Time Equality for SwarmKey + +`SwarmKey::eq` uses a byte-by-byte XOR accumulation pattern with +`std::hint::black_box` to prevent the compiler from short-circuiting the +comparison, mitigating timing side-channel attacks when comparing swarm keys. + +--- + +## Consequences + +### Positive + +- `NodeIdentity` and `SwarmKey` are independently testable without any network + stack dependency. +- The `NodeId` hex string maps directly to the `node_peers.id` primary key, + enabling zero-transformation database inserts. +- `SwarmKey::fingerprint()` maps directly to `node_peers.swarm_key_fingerprint`, + enabling schema-aligned peer registry entries. +- All secret material is zeroized on drop, satisfying the zero-trust local + storage mandate. +- 30 unit tests cover: generation uniqueness, determinism, hex round-trips, + serialization round-trips, file persistence, error paths, and security + properties (debug output redaction, constant-time equality). + +### Negative / Trade-offs + +- The JSON persistence format for `NodeIdentity` stores the secret key in hex. + This is acceptable only because the file resides inside the encrypted vault + directory. A future hardening pass (Phase 5) may encrypt the identity file + itself using the master key from `chronogrid_crypto`. +- `ed25519-dalek` v2 and `libp2p` use compatible but distinct key type wrappers. + Task 2.3 will need a thin conversion layer (`SigningKey` → `libp2p::identity::ed25519::Keypair`). + +--- + +## Module Structure + +``` +crates/chronogrid_net/ +├── Cargo.toml # ed25519-dalek, rand, sha2, hex, serde, thiserror, zeroize +└── src/ + ├── lib.rs # Public re-exports: NetError, NetResult, NodeId, NodeIdentity, SwarmKey + ├── error.rs # NetError enum + NetResult alias + ├── identity.rs # NodeId, NodeIdentity, generate_node_identity(), load_or_create_identity() + └── swarm_key.rs # SwarmKey: generate, from_bytes, from_hex, fingerprint, save, load +``` + +--- + +## Test Coverage + +| Module | Tests | Key Scenarios | +|---------------|-------|----------------------------------------------------------------------| +| `identity` | 11 | Uniqueness, determinism, hex format, round-trip, file I/O, tampering | +| `swarm_key` | 16 | Generation, hex/bytes round-trips, error paths, fingerprint, file I/O, security | +| **Total** | **27**| | diff --git a/docs/TASKS/phase_2_networking_and_schema.md b/docs/TASKS/phase_2_networking_and_schema.md index 5ed90a2..2f8f070 100644 --- a/docs/TASKS/phase_2_networking_and_schema.md +++ b/docs/TASKS/phase_2_networking_and_schema.md @@ -29,7 +29,7 @@ ## Task 2.2 — Implement chronogrid_net: Node Identity & Swarm Key -- [ ] **Task 2.2**: Implement node identity and swarm key management in `crates/chronogrid_net/`: +- [x] **Task 2.2**: Implement node identity and swarm key management in `crates/chronogrid_net/`: - Add dependencies: `libp2p = { version = "0.54", features = ["noise", "quic", "mdns", "gossipsub", "kad", "identify", "relay"] }`, `tokio = { version = "1", features = ["full"] }`, `ed25519-dalek = "2"`, `rand = "0.8"` - Implement `NodeIdentity` struct: Ed25519 keypair generation, serialization, and `NodeId` (public key hash) - Implement `SwarmKey` struct: 256-bit random key generation, serialization, and validation