Compare commits
2 Commits
65c1f560e4
...
f3967e2b16
| Author | SHA1 | Date |
|---|---|---|
|
|
f3967e2b16 | |
|
|
dfeb78221c |
|
|
@ -79,6 +79,21 @@
|
||||||
- Verification script confirmed: all 13 tables present in schema.sql (PASS), Flutter analysis clean (PASS).
|
- 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`.
|
- 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
|
## [2026-08-22] - ADL-006: Architecture Review Board Assessment & Master Development Plan v2
|
||||||
|
|
||||||
- **Status**: Accepted
|
- **Status**: Accepted
|
||||||
|
|
|
||||||
|
|
@ -7,5 +7,21 @@ rust-version.workspace = true
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[dependencies]
|
[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]
|
[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
|
//! # chronogrid_net
|
||||||
//!
|
//!
|
||||||
//! P2P mesh networking layer for ChronoGrid.
|
//! 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)]
|
pub mod error;
|
||||||
mod tests {
|
pub mod identity;
|
||||||
#[test]
|
pub mod swarm_key;
|
||||||
fn it_works() {
|
|
||||||
assert!(true);
|
// 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 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"`
|
- 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 `NodeIdentity` struct: Ed25519 keypair generation, serialization, and `NodeId` (public key hash)
|
||||||
- Implement `SwarmKey` struct: 256-bit random key generation, serialization, and validation
|
- Implement `SwarmKey` struct: 256-bit random key generation, serialization, and validation
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue