Compare commits

..

2 Commits

8 changed files with 934 additions and 9 deletions

View File

@ -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

View File

@ -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"

View File

@ -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<T> = Result<T, NetError>;

View File

@ -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<NodeIdentity> {
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<NodeIdentity> {
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());
}
}

View File

@ -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<T>` 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;

View File

@ -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<Self> {
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<Self> {
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<Self> {
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<Self> {
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);
}
}

View File

@ -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 | `<vault_dir>/identity.json` |
| SwarmKey | 64-char hex string (plain text) | `<vault_dir>/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<T> 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**| |

View File

@ -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