feat(chronogrid_net): complete Task 2.2 - Node Identity & Swarm Key
This commit is contained in:
parent
65c1f560e4
commit
dfeb78221c
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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**| |
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue