diff --git a/docs/design/fips-software-architecture.md b/docs/design/fips-software-architecture.md index 0a97529..f043db2 100644 --- a/docs/design/fips-software-architecture.md +++ b/docs/design/fips-software-architecture.md @@ -28,14 +28,14 @@ The top-level entity representing a running FIPS instance. ``` Node -├── identity: Identity // cryptographic identity (npub/nsec) +├── identity: Identity // cryptographic identity (keypair + derived IDs) ├── config: Config // loaded configuration -├── tun: TunInterface // IPv6 interface to local applications +├── tun_state: TunState // TUN device lifecycle state ├── tree_state: TreeState // local view of spanning tree ├── coord_cache: CoordCache // address → coordinates for routing -├── transports: HashMap +├── transports: HashMap ├── links: HashMap -└── peers: HashMap +└── peers: HashMap ``` ### Identity @@ -44,12 +44,14 @@ Cryptographic identity using Nostr keys (secp256k1). ``` Identity -├── npub: PublicKey // public key (bech32: npub1...) -├── nsec: SecretKey // secret key (bech32: nsec1...) -├── node_addr: NodeAddr // SHA-256(pubkey) truncated, 16 bytes -└── address: FipsAddress // IPv6 ULA derived from node_addr (fd::/8) +├── keypair: Keypair // secp256k1 keypair (secret + public) +├── node_addr: NodeAddr // SHA-256(pubkey) truncated, 16 bytes +└── address: FipsAddress // IPv6 ULA derived from node_addr (fd::/8) ``` +Accessor methods provide `pubkey()` (x-only), `pubkey_full()`, `npub()` (bech32), +`node_addr()`, and `address()`. The keypair is also exposed for Noise handshakes. + `NodeAddr` is the routing identifier, derived deterministically from `npub`. Transport addresses and FIPS identity are fully decoupled. @@ -138,26 +140,45 @@ LinkStats └── throughput_estimate: u64 // bytes/sec observed ``` -### Peer +### Peer Lifecycle (Two-Phase Model) -An authenticated remote FIPS node, reachable via a link. +Peers use a two-phase lifecycle managed by a `PeerSlot` enum: ``` -Peer -├── node_addr: NodeAddr // routing identity -├── npub: PublicKey // cryptographic identity -├── link_id: LinkId // which link reaches this peer -├── state: PeerState // lifecycle state +PeerSlot +├── Connecting(PeerConnection) // handshake in progress +└── Active(ActivePeer) // authenticated, participating +``` + +**PeerConnection** — represents a peer during Noise IK handshake: + +``` +PeerConnection +├── node_addr: NodeAddr // routing identity (if known) +├── link_id: LinkId // which link reaches this peer +├── state: HandshakeState // Initiating | ReceivedMsg1 | AwaitingMsg2 +├── handshake: NoiseHandshake // Noise IK state machine +└── created_at: Instant // for timeout enforcement +``` + +**ActivePeer** — an authenticated remote FIPS node: + +``` +ActivePeer +├── identity: PeerIdentity // cryptographic identity (verified via handshake) +├── node_addr: NodeAddr // routing identity +├── link_id: LinkId // which link reaches this peer +├── state: ConnectivityState // Active | Stale | Disconnecting │ │ // Spanning tree -├── declaration: ParentDeclaration // their latest -├── ancestry: Vec // their path to root +├── declaration: Option // their latest (None until received) +├── ancestry: Option // their path to root (None until received) │ │ // Bloom filter (inbound—what's reachable through them) -├── inbound_filter: BloomFilter +├── inbound_filter: Option // None until first received ├── filter_sequence: u64 -├── filter_received_at: Timestamp -├── pending_filter_update: bool // we owe them an update +├── filter_received_at: u64 // Unix milliseconds +├── pending_filter_update: bool // we owe them an update │ │ // Statistics └── link_stats: LinkStats @@ -235,19 +256,24 @@ Nodes do NOT know about other subtrees—only paths toward root. ``` BloomState -├── own_node_addr: NodeAddr // always included in outgoing filters -├── leaf_dependents: HashSet // leaf-only nodes we speak for -├── is_leaf_only: bool // if true, no filter processing -└── update_debounce: Duration // rate limit outgoing updates +├── own_node_addr: NodeAddr // always included in outgoing filters +├── leaf_dependents: HashSet // leaf-only nodes we speak for +├── is_leaf_only: bool // if true, no filter processing +├── update_debounce_ms: u64 // min interval between updates (ms) +├── last_update_sent: HashMap // per-peer last-sent timestamp +├── pending_updates: HashSet // peers needing filter update +├── sequence: u64 // monotonic outgoing sequence number +└── last_sent_filters: HashMap // for change detection ``` ### Per-Peer State -Stored on Peer: +Stored on ActivePeer: -- `inbound_filter`: what they advertise to us (1KB Bloom filter) -- `filter_sequence`: freshness/dedup -- `filter_received_at`: for staleness detection +- `inbound_filter: Option`: what they advertise to us (None until first received) +- `filter_sequence: u64`: freshness/dedup +- `filter_received_at: u64`: when received (Unix milliseconds), for staleness detection +- `pending_filter_update: bool`: whether we owe them an update ### Computed (On-Demand) @@ -380,7 +406,7 @@ initiated simultaneously): ``` PeerEvent -├── Discovered { link_id, transport_addr, hint: Option } +├── Discovered { transport_id, transport_addr, pubkey_hint: Option } ├── LinkConnected ├── LinkFailed { reason } ├── Msg1Received { noise_payload } @@ -524,7 +550,7 @@ handler: ``` Transport │ - ├──► DiscoveredPeer { transport_id, addr, hint } + ├──► DiscoveredPeer { transport_id, addr, pubkey_hint } ├──► InboundConnection { transport_id, addr, io } (connection-oriented) └──► PacketReceived { transport_id, addr, data } │ @@ -642,11 +668,11 @@ The upstream peer entry for a leaf-only node: ``` UpstreamPeer (leaf-only) +├── identity: PeerIdentity // cryptographic identity ├── node_addr: NodeAddr -├── npub: PublicKey ├── link_id: LinkId -├── state: PeerState // auth lifecycle only -└── link_stats: LinkStats // for keepalive/timeout +├── state: ConnectivityState // auth lifecycle only +└── link_stats: LinkStats // for keepalive/timeout // NOT present: // - declaration, ancestry (no tree participation) @@ -878,7 +904,7 @@ TransportConfig Discovery is per-transport: -- Transports emit `DiscoveredPeer { addr, hint }` events +- Transports emit `DiscoveredPeer { transport_id, addr, pubkey_hint }` events - Node matches against known peer configs or creates "unknown peer" entries - Policy (`auto_connect`, per-peer `connect_policy`) determines action diff --git a/src/identity.rs b/src/identity.rs deleted file mode 100644 index 31989df..0000000 --- a/src/identity.rs +++ /dev/null @@ -1,929 +0,0 @@ -//! FIPS Identity System -//! -//! Node identity based on Nostr keypairs (secp256k1). The node_addr is derived -//! from the public key via SHA-256, and the FIPS address uses an IPv6-compatible -//! format with the 0xfd prefix. - -use bech32::{Bech32, Hrp}; -use rand::Rng; -use secp256k1::{Keypair, Parity, PublicKey, Secp256k1, SecretKey, XOnlyPublicKey}; -use sha2::{Digest, Sha256}; -use std::fmt; -use std::net::Ipv6Addr; -use thiserror::Error; - -/// Human-readable part for npub (NIP-19). -const NPUB_HRP: Hrp = Hrp::parse_unchecked("npub"); - -/// Human-readable part for nsec (NIP-19). -const NSEC_HRP: Hrp = Hrp::parse_unchecked("nsec"); - -/// Domain separation string for authentication challenges. -const AUTH_DOMAIN: &[u8] = b"fips-auth-v1"; - -/// FIPS address prefix (IPv6 ULA range). -pub const FIPS_ADDRESS_PREFIX: u8 = 0xfd; - -/// Errors that can occur in identity operations. -#[derive(Debug, Error)] -pub enum IdentityError { - #[error("invalid secret key: {0}")] - InvalidSecretKey(#[from] secp256k1::Error), - - #[error("signature verification failed")] - SignatureVerificationFailed, - - #[error("invalid node_addr length: expected 16, got {0}")] - InvalidNodeAddrLength(usize), - - #[error("invalid address length: expected 16, got {0}")] - InvalidAddressLength(usize), - - #[error("invalid address prefix: expected 0xfd, got 0x{0:02x}")] - InvalidAddressPrefix(u8), - - #[error("bech32 encoding error: {0}")] - Bech32Encode(#[from] bech32::EncodeError), - - #[error("bech32 decoding error: {0}")] - Bech32Decode(#[from] bech32::DecodeError), - - #[error("invalid npub: expected 'npub' prefix, got '{0}'")] - InvalidNpubPrefix(String), - - #[error("invalid npub: expected 32 bytes, got {0}")] - InvalidNpubLength(usize), - - #[error("invalid nsec: expected 'nsec' prefix, got '{0}'")] - InvalidNsecPrefix(String), - - #[error("invalid nsec: expected 32 bytes, got {0}")] - InvalidNsecLength(usize), - - #[error("invalid hex encoding: {0}")] - InvalidHex(#[from] hex::FromHexError), -} - -/// 16-byte node identifier derived from truncated SHA-256(pubkey). -/// -/// The node_addr is the first 16 bytes of SHA-256(pubkey), providing 128 bits -/// of collision resistance. Hashing the public key prevents grinding attacks -/// that exploit secp256k1's algebraic structure. -#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct NodeAddr([u8; 16]); - -impl NodeAddr { - /// Create a NodeAddr from a 16-byte array. - pub fn from_bytes(bytes: [u8; 16]) -> Self { - Self(bytes) - } - - /// Create a NodeAddr from a slice. - pub fn from_slice(slice: &[u8]) -> Result { - if slice.len() != 16 { - return Err(IdentityError::InvalidNodeAddrLength(slice.len())); - } - let mut bytes = [0u8; 16]; - bytes.copy_from_slice(slice); - Ok(Self(bytes)) - } - - /// Derive a NodeAddr from an x-only public key (npub). - /// - /// Computes SHA-256(pubkey) and takes the first 16 bytes. - pub fn from_pubkey(pubkey: &XOnlyPublicKey) -> Self { - let mut hasher = Sha256::new(); - hasher.update(pubkey.serialize()); - let hash = hasher.finalize(); - let mut bytes = [0u8; 16]; - bytes.copy_from_slice(&hash[..16]); - Self(bytes) - } - - /// Return the raw bytes. - pub fn as_bytes(&self) -> &[u8; 16] { - &self.0 - } - - /// Return the bytes as a slice. - pub fn as_slice(&self) -> &[u8] { - &self.0 - } -} - -impl fmt::Debug for NodeAddr { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "NodeAddr({})", hex_encode(&self.0[..8])) - } -} - -impl fmt::Display for NodeAddr { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", hex_encode(&self.0)) - } -} - -impl AsRef<[u8]> for NodeAddr { - fn as_ref(&self) -> &[u8] { - &self.0 - } -} - -/// 128-bit FIPS address with IPv6-compatible format. -/// -/// The address uses the IPv6 Unique Local Address (ULA) prefix `fd00::/8`, -/// providing 120 bits for the node_addr hash. This format allows applications -/// designed for IP transports to bind to FIPS addresses via a TUN interface. -#[derive(Clone, Copy, PartialEq, Eq, Hash)] -pub struct FipsAddress([u8; 16]); - -impl FipsAddress { - /// Create a FipsAddress from a 16-byte array. - pub fn from_bytes(bytes: [u8; 16]) -> Result { - if bytes[0] != FIPS_ADDRESS_PREFIX { - return Err(IdentityError::InvalidAddressPrefix(bytes[0])); - } - Ok(Self(bytes)) - } - - /// Create a FipsAddress from a slice. - pub fn from_slice(slice: &[u8]) -> Result { - if slice.len() != 16 { - return Err(IdentityError::InvalidAddressLength(slice.len())); - } - let mut bytes = [0u8; 16]; - bytes.copy_from_slice(slice); - Self::from_bytes(bytes) - } - - /// Derive a FipsAddress from a NodeAddr. - /// - /// Takes the first 15 bytes of the node_addr and prepends the 0xfd prefix. - pub fn from_node_addr(node_addr: &NodeAddr) -> Self { - let mut bytes = [0u8; 16]; - bytes[0] = FIPS_ADDRESS_PREFIX; - bytes[1..16].copy_from_slice(&node_addr.0[0..15]); - Self(bytes) - } - - /// Return the raw bytes. - pub fn as_bytes(&self) -> &[u8; 16] { - &self.0 - } - - /// Convert to std::net::Ipv6Addr. - pub fn to_ipv6(&self) -> Ipv6Addr { - Ipv6Addr::from(self.0) - } -} - -impl From for Ipv6Addr { - fn from(addr: FipsAddress) -> Self { - Ipv6Addr::from(addr.0) - } -} - -impl fmt::Debug for FipsAddress { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "FipsAddress({})", self.to_ipv6()) - } -} - -impl fmt::Display for FipsAddress { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.to_ipv6()) - } -} - -/// A known peer's identity (public key only, no signing capability). -/// -/// Use this to represent remote peers whose npub you know. For a local -/// identity with signing capability, use [`Identity`] instead. -#[derive(Clone, Copy, PartialEq, Eq)] -pub struct PeerIdentity { - pubkey: XOnlyPublicKey, - /// Full public key if known (includes parity for ECDH operations). - pubkey_full: Option, - node_addr: NodeAddr, - address: FipsAddress, -} - -impl PeerIdentity { - /// Create a PeerIdentity from an x-only public key. - /// - /// Note: When only the x-only key is available, the full public key - /// will be derived assuming even parity for ECDH operations. - pub fn from_pubkey(pubkey: XOnlyPublicKey) -> Self { - let node_addr = NodeAddr::from_pubkey(&pubkey); - let address = FipsAddress::from_node_addr(&node_addr); - Self { - pubkey, - pubkey_full: None, - node_addr, - address, - } - } - - /// Create a PeerIdentity from a full public key (includes parity). - /// - /// Use this when you have the complete public key (e.g., from a Noise - /// handshake) to preserve parity information for ECDH operations. - pub fn from_pubkey_full(pubkey: PublicKey) -> Self { - let (x_only, _parity) = pubkey.x_only_public_key(); - let node_addr = NodeAddr::from_pubkey(&x_only); - let address = FipsAddress::from_node_addr(&node_addr); - Self { - pubkey: x_only, - pubkey_full: Some(pubkey), - node_addr, - address, - } - } - - /// Create a PeerIdentity from a bech32-encoded npub string. - pub fn from_npub(npub: &str) -> Result { - let pubkey = decode_npub(npub)?; - Ok(Self::from_pubkey(pubkey)) - } - - /// Return the x-only public key. - pub fn pubkey(&self) -> XOnlyPublicKey { - self.pubkey - } - - /// Return the full public key for ECDH operations. - /// - /// If the full key was provided during construction, it is returned. - /// Otherwise, the key is derived from the x-only key assuming even parity. - pub fn pubkey_full(&self) -> PublicKey { - self.pubkey_full.unwrap_or_else(|| { - // Derive full key assuming even parity - self.pubkey.public_key(Parity::Even) - }) - } - - /// Return the public key as a bech32-encoded npub string (NIP-19). - pub fn npub(&self) -> String { - encode_npub(&self.pubkey) - } - - /// Return the node ID. - pub fn node_addr(&self) -> &NodeAddr { - &self.node_addr - } - - /// Return the FIPS address. - pub fn address(&self) -> &FipsAddress { - &self.address - } - - /// Verify a signature from this peer. - pub fn verify(&self, data: &[u8], signature: &secp256k1::schnorr::Signature) -> bool { - let secp = Secp256k1::new(); - let digest = sha256(data); - secp.verify_schnorr(signature, &digest, &self.pubkey).is_ok() - } -} - -impl fmt::Debug for PeerIdentity { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("PeerIdentity") - .field("node_addr", &self.node_addr) - .field("address", &self.address) - .finish() - } -} - -impl fmt::Display for PeerIdentity { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.npub()) - } -} - -/// A FIPS node identity consisting of a keypair and derived identifiers. -/// -/// The identity holds the secp256k1 keypair and provides methods for signing -/// and verifying protocol messages. -pub struct Identity { - keypair: Keypair, - node_addr: NodeAddr, - address: FipsAddress, -} - -impl Identity { - /// Create a new random identity. - pub fn generate() -> Self { - let secp = Secp256k1::new(); - let keypair = Keypair::new(&secp, &mut rand::thread_rng()); - Self::from_keypair(keypair) - } - - /// Create an identity from an existing keypair. - pub fn from_keypair(keypair: Keypair) -> Self { - let (pubkey, _parity) = keypair.x_only_public_key(); - let node_addr = NodeAddr::from_pubkey(&pubkey); - let address = FipsAddress::from_node_addr(&node_addr); - Self { - keypair, - node_addr, - address, - } - } - - /// Create an identity from a secret key. - pub fn from_secret_key(secret_key: SecretKey) -> Self { - let secp = Secp256k1::new(); - let keypair = Keypair::from_secret_key(&secp, &secret_key); - Self::from_keypair(keypair) - } - - /// Create an identity from secret key bytes. - pub fn from_secret_bytes(bytes: &[u8; 32]) -> Result { - let secret_key = SecretKey::from_slice(bytes)?; - Ok(Self::from_secret_key(secret_key)) - } - - /// Create an identity from an nsec string (bech32) or hex-encoded secret. - pub fn from_secret_str(s: &str) -> Result { - let secret_key = decode_secret(s)?; - Ok(Self::from_secret_key(secret_key)) - } - - /// Return the underlying keypair. - /// - /// This is needed for cryptographic operations like Noise handshakes. - pub fn keypair(&self) -> Keypair { - self.keypair - } - - /// Return the x-only public key. - pub fn pubkey(&self) -> XOnlyPublicKey { - self.keypair.x_only_public_key().0 - } - - /// Return the full public key (includes parity). - pub fn pubkey_full(&self) -> PublicKey { - self.keypair.public_key() - } - - /// Return the public key as a bech32-encoded npub string (NIP-19). - pub fn npub(&self) -> String { - encode_npub(&self.pubkey()) - } - - /// Return the node ID. - pub fn node_addr(&self) -> &NodeAddr { - &self.node_addr - } - - /// Return the FIPS address. - pub fn address(&self) -> &FipsAddress { - &self.address - } - - /// Sign arbitrary data with this identity's secret key. - pub fn sign(&self, data: &[u8]) -> secp256k1::schnorr::Signature { - let secp = Secp256k1::new(); - let digest = sha256(data); - secp.sign_schnorr(&digest, &self.keypair) - } - - /// Create an authentication response for a challenge. - /// - /// The response signs: SHA256("fips-auth-v1" || challenge || timestamp) - pub fn sign_challenge(&self, challenge: &[u8; 32], timestamp: u64) -> AuthResponse { - let digest = auth_challenge_digest(challenge, timestamp); - let secp = Secp256k1::new(); - let signature = secp.sign_schnorr(&digest, &self.keypair); - AuthResponse { - pubkey: self.pubkey(), - timestamp, - signature, - } - } -} - -impl fmt::Debug for Identity { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Identity") - .field("node_addr", &self.node_addr) - .field("address", &self.address) - .finish_non_exhaustive() - } -} - -/// A 32-byte random authentication challenge. -#[derive(Clone, Copy, Debug)] -pub struct AuthChallenge([u8; 32]); - -impl AuthChallenge { - /// Generate a new random challenge. - pub fn generate() -> Self { - let mut bytes = [0u8; 32]; - rand::thread_rng().fill(&mut bytes); - Self(bytes) - } - - /// Create a challenge from bytes. - pub fn from_bytes(bytes: [u8; 32]) -> Self { - Self(bytes) - } - - /// Return the challenge bytes. - pub fn as_bytes(&self) -> &[u8; 32] { - &self.0 - } - - /// Verify a response to this challenge. - pub fn verify(&self, response: &AuthResponse) -> Result { - let digest = auth_challenge_digest(&self.0, response.timestamp); - let secp = Secp256k1::new(); - - secp.verify_schnorr(&response.signature, &digest, &response.pubkey) - .map_err(|_| IdentityError::SignatureVerificationFailed)?; - - Ok(NodeAddr::from_pubkey(&response.pubkey)) - } -} - -/// Response to an authentication challenge. -#[derive(Clone, Debug)] -pub struct AuthResponse { - /// The responder's public key. - pub pubkey: XOnlyPublicKey, - /// Timestamp included in the signed message. - pub timestamp: u64, - /// Schnorr signature over the challenge digest. - pub signature: secp256k1::schnorr::Signature, -} - -/// Compute the digest for an authentication challenge. -fn auth_challenge_digest(challenge: &[u8; 32], timestamp: u64) -> [u8; 32] { - let mut hasher = Sha256::new(); - hasher.update(AUTH_DOMAIN); - hasher.update(challenge); - hasher.update(timestamp.to_be_bytes()); - let result = hasher.finalize(); - let mut digest = [0u8; 32]; - digest.copy_from_slice(&result); - digest -} - -/// Compute SHA-256 hash of data. -fn sha256(data: &[u8]) -> [u8; 32] { - let mut hasher = Sha256::new(); - hasher.update(data); - let result = hasher.finalize(); - let mut hash = [0u8; 32]; - hash.copy_from_slice(&result); - hash -} - -/// Encode bytes as lowercase hex string. -fn hex_encode(bytes: &[u8]) -> String { - bytes.iter().map(|b| format!("{:02x}", b)).collect() -} - -/// Encode an x-only public key as a bech32 npub string (NIP-19). -pub fn encode_npub(pubkey: &XOnlyPublicKey) -> String { - bech32::encode::(NPUB_HRP, &pubkey.serialize()).expect("npub encoding cannot fail") -} - -/// Decode an npub string to an x-only public key. -pub fn decode_npub(npub: &str) -> Result { - let (hrp, data) = bech32::decode(npub)?; - - if hrp != NPUB_HRP { - return Err(IdentityError::InvalidNpubPrefix(hrp.to_string())); - } - - if data.len() != 32 { - return Err(IdentityError::InvalidNpubLength(data.len())); - } - - let pubkey = XOnlyPublicKey::from_slice(&data)?; - Ok(pubkey) -} - -/// Encode a secret key as a bech32 nsec string (NIP-19). -pub fn encode_nsec(secret_key: &SecretKey) -> String { - bech32::encode::(NSEC_HRP, &secret_key.secret_bytes()) - .expect("nsec encoding cannot fail") -} - -/// Decode an nsec string to a secret key. -pub fn decode_nsec(nsec: &str) -> Result { - let (hrp, data) = bech32::decode(nsec)?; - - if hrp != NSEC_HRP { - return Err(IdentityError::InvalidNsecPrefix(hrp.to_string())); - } - - if data.len() != 32 { - return Err(IdentityError::InvalidNsecLength(data.len())); - } - - let secret_key = SecretKey::from_slice(&data)?; - Ok(secret_key) -} - -/// Decode a secret key from either nsec (bech32) or hex format. -pub fn decode_secret(s: &str) -> Result { - if s.starts_with("nsec1") { - decode_nsec(s) - } else { - let bytes = hex::decode(s)?; - if bytes.len() != 32 { - return Err(IdentityError::InvalidNsecLength(bytes.len())); - } - let secret_key = SecretKey::from_slice(&bytes)?; - Ok(secret_key) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_identity_generation() { - let identity = Identity::generate(); - - // NodeAddr should be 16 bytes - assert_eq!(identity.node_addr().as_bytes().len(), 16); - - // Address should start with 0xfd - assert_eq!(identity.address().as_bytes()[0], 0xfd); - - // Address bytes 1-15 should match node_addr bytes 0-14 - assert_eq!( - &identity.address().as_bytes()[1..16], - &identity.node_addr().as_bytes()[0..15] - ); - } - - #[test] - fn test_node_addr_from_pubkey_deterministic() { - let identity = Identity::generate(); - let pubkey = identity.pubkey(); - - let node_addr1 = NodeAddr::from_pubkey(&pubkey); - let node_addr2 = NodeAddr::from_pubkey(&pubkey); - - assert_eq!(node_addr1, node_addr2); - } - - #[test] - fn test_fips_address_ipv6_format() { - let identity = Identity::generate(); - let ipv6 = identity.address().to_ipv6(); - let addr_str = ipv6.to_string(); - - // Should start with fd (ULA prefix) - assert!(addr_str.starts_with("fd")); - - // Conversion should be lossless - let octets = ipv6.octets(); - assert_eq!(&octets, identity.address().as_bytes()); - } - - #[test] - fn test_auth_challenge_verify_success() { - let identity = Identity::generate(); - let challenge = AuthChallenge::generate(); - let timestamp = 1234567890u64; - - let response = identity.sign_challenge(challenge.as_bytes(), timestamp); - let result = challenge.verify(&response); - - assert!(result.is_ok()); - assert_eq!(result.unwrap(), *identity.node_addr()); - } - - #[test] - fn test_auth_challenge_verify_wrong_challenge() { - let identity = Identity::generate(); - let challenge1 = AuthChallenge::generate(); - let challenge2 = AuthChallenge::generate(); - let timestamp = 1234567890u64; - - let response = identity.sign_challenge(challenge1.as_bytes(), timestamp); - let result = challenge2.verify(&response); - - assert!(matches!( - result, - Err(IdentityError::SignatureVerificationFailed) - )); - } - - #[test] - fn test_auth_challenge_verify_wrong_timestamp() { - let identity = Identity::generate(); - let challenge = AuthChallenge::generate(); - - let response = identity.sign_challenge(challenge.as_bytes(), 1234567890); - - // Modify the timestamp in the response - let bad_response = AuthResponse { - pubkey: response.pubkey, - timestamp: 9999999999, - signature: response.signature, - }; - - let result = challenge.verify(&bad_response); - assert!(matches!( - result, - Err(IdentityError::SignatureVerificationFailed) - )); - } - - #[test] - fn test_node_addr_ordering() { - let id1 = Identity::generate(); - let id2 = Identity::generate(); - - // NodeAddrs should be comparable for root election - let _cmp = id1.node_addr().cmp(id2.node_addr()); - } - - #[test] - fn test_identity_from_secret_bytes() { - // A known secret key (32 bytes) - let secret_bytes: [u8; 32] = [ - 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, - 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, - 0x1d, 0x1e, 0x1f, 0x20, - ]; - - let identity1 = Identity::from_secret_bytes(&secret_bytes).unwrap(); - let identity2 = Identity::from_secret_bytes(&secret_bytes).unwrap(); - - // Same secret key should produce same node_addr - assert_eq!(identity1.node_addr(), identity2.node_addr()); - assert_eq!(identity1.address(), identity2.address()); - } - - #[test] - fn test_node_addr_from_slice() { - let bytes = [0u8; 16]; - let node_addr = NodeAddr::from_slice(&bytes).unwrap(); - assert_eq!(node_addr.as_bytes(), &bytes); - - // Wrong length should fail - let short = [0u8; 8]; - assert!(matches!( - NodeAddr::from_slice(&short), - Err(IdentityError::InvalidNodeAddrLength(8)) - )); - } - - #[test] - fn test_fips_address_validation() { - // Valid address with fd prefix - let mut valid = [0u8; 16]; - valid[0] = 0xfd; - assert!(FipsAddress::from_bytes(valid).is_ok()); - - // Invalid prefix - let mut invalid = [0u8; 16]; - invalid[0] = 0xfe; - assert!(matches!( - FipsAddress::from_bytes(invalid), - Err(IdentityError::InvalidAddressPrefix(0xfe)) - )); - } - - #[test] - fn test_identity_sign() { - let identity = Identity::generate(); - let data = b"test message"; - - let sig = identity.sign(data); - - // Verify the signature manually - let secp = Secp256k1::new(); - let digest = sha256(data); - assert!(secp - .verify_schnorr(&sig, &digest, &identity.pubkey()) - .is_ok()); - } - - #[test] - fn test_npub_encoding() { - let identity = Identity::generate(); - let npub = identity.npub(); - - // Should start with "npub1" - assert!(npub.starts_with("npub1")); - - // Should be 63 characters (npub1 + 58 chars of bech32 data) - assert_eq!(npub.len(), 63); - } - - #[test] - fn test_npub_roundtrip() { - let identity = Identity::generate(); - let npub = identity.npub(); - - let decoded = decode_npub(&npub).unwrap(); - assert_eq!(decoded, identity.pubkey()); - } - - #[test] - fn test_npub_known_vector() { - // Test against a known npub (from NIP-19 test vectors or generated externally) - let secret_bytes: [u8; 32] = [ - 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, - 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, - 0x1d, 0x1e, 0x1f, 0x20, - ]; - - let identity = Identity::from_secret_bytes(&secret_bytes).unwrap(); - let npub = identity.npub(); - - // Decode and verify it matches the original pubkey - let decoded = decode_npub(&npub).unwrap(); - assert_eq!(decoded, identity.pubkey()); - - // npub should be deterministic - let npub2 = encode_npub(&identity.pubkey()); - assert_eq!(npub, npub2); - } - - #[test] - fn test_decode_npub_invalid_prefix() { - // nsec instead of npub - let nsec = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"; - let result = decode_npub(nsec); - assert!(matches!(result, Err(IdentityError::InvalidNpubPrefix(_)))); - } - - #[test] - fn test_decode_npub_invalid_checksum() { - // Valid npub with corrupted checksum - let bad_npub = "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq"; - let result = decode_npub(bad_npub); - assert!(result.is_err()); - } - - #[test] - fn test_peer_identity_from_npub() { - let identity = Identity::generate(); - let npub = identity.npub(); - - let peer = PeerIdentity::from_npub(&npub).unwrap(); - - assert_eq!(peer.pubkey(), identity.pubkey()); - assert_eq!(peer.node_addr(), identity.node_addr()); - assert_eq!(peer.address(), identity.address()); - assert_eq!(peer.npub(), npub); - } - - #[test] - fn test_peer_identity_verify_signature() { - let identity = Identity::generate(); - let peer = PeerIdentity::from_pubkey(identity.pubkey()); - - let data = b"hello world"; - let signature = identity.sign(data); - - assert!(peer.verify(data, &signature)); - assert!(!peer.verify(b"wrong data", &signature)); - } - - #[test] - fn test_peer_identity_from_invalid_npub() { - let result = PeerIdentity::from_npub("npub1invalid"); - assert!(result.is_err()); - - let result = PeerIdentity::from_npub("nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"); - assert!(matches!(result, Err(IdentityError::InvalidNpubPrefix(_)))); - } - - #[test] - fn test_peer_identity_display() { - let identity = Identity::generate(); - let peer = PeerIdentity::from_pubkey(identity.pubkey()); - - let display = format!("{}", peer); - assert!(display.starts_with("npub1")); - assert_eq!(display, identity.npub()); - } - - #[test] - fn test_nsec_roundtrip() { - let secret_bytes: [u8; 32] = [ - 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, - 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, - 0x1d, 0x1e, 0x1f, 0x20, - ]; - - let secret_key = SecretKey::from_slice(&secret_bytes).unwrap(); - let nsec = encode_nsec(&secret_key); - - assert!(nsec.starts_with("nsec1")); - assert_eq!(nsec.len(), 63); - - let decoded = decode_nsec(&nsec).unwrap(); - assert_eq!(decoded.secret_bytes(), secret_bytes); - } - - #[test] - fn test_decode_nsec_invalid_prefix() { - // Use a valid npub (from a generated identity) to test prefix rejection - let identity = Identity::generate(); - let npub = identity.npub(); - let result = decode_nsec(&npub); - assert!(matches!(result, Err(IdentityError::InvalidNsecPrefix(_)))); - } - - #[test] - fn test_decode_secret_nsec() { - let secret_bytes: [u8; 32] = [ - 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, - 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, - 0x1d, 0x1e, 0x1f, 0x20, - ]; - - let secret_key = SecretKey::from_slice(&secret_bytes).unwrap(); - let nsec = encode_nsec(&secret_key); - - let decoded = decode_secret(&nsec).unwrap(); - assert_eq!(decoded.secret_bytes(), secret_bytes); - } - - #[test] - fn test_decode_secret_hex() { - let hex_str = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; - let decoded = decode_secret(hex_str).unwrap(); - - let expected: [u8; 32] = [ - 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, - 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, - 0x1d, 0x1e, 0x1f, 0x20, - ]; - assert_eq!(decoded.secret_bytes(), expected); - } - - #[test] - fn test_identity_from_secret_str_nsec() { - let secret_bytes: [u8; 32] = [ - 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, - 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, - 0x1d, 0x1e, 0x1f, 0x20, - ]; - - let secret_key = SecretKey::from_slice(&secret_bytes).unwrap(); - let nsec = encode_nsec(&secret_key); - - let identity = Identity::from_secret_str(&nsec).unwrap(); - let identity_from_bytes = Identity::from_secret_bytes(&secret_bytes).unwrap(); - - assert_eq!(identity.node_addr(), identity_from_bytes.node_addr()); - } - - #[test] - fn test_identity_from_secret_str_hex() { - let hex_str = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; - let secret_bytes: [u8; 32] = [ - 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, - 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, - 0x1d, 0x1e, 0x1f, 0x20, - ]; - - let identity = Identity::from_secret_str(hex_str).unwrap(); - let identity_from_bytes = Identity::from_secret_bytes(&secret_bytes).unwrap(); - - assert_eq!(identity.node_addr(), identity_from_bytes.node_addr()); - } -} - -#[cfg(test)] -mod conversion_tests { - use super::*; - - #[test] - fn test_hex_conversion_case1() { - let hex_str = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; - let identity = Identity::from_secret_str(hex_str).unwrap(); - let npub = identity.npub(); - println!("Hex: {}", hex_str); - println!("NPub: {}", npub); - println!("NodeAddr: {}", identity.node_addr()); - println!("FipsAddress: {}", identity.address()); - assert!(npub.starts_with("npub1")); - } - - #[test] - fn test_hex_conversion_case2() { - let hex_str = "b102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fb0"; - let identity = Identity::from_secret_str(hex_str).unwrap(); - let npub = identity.npub(); - println!("Hex: {}", hex_str); - println!("NPub: {}", npub); - println!("NodeAddr: {}", identity.node_addr()); - println!("FipsAddress: {}", identity.address()); - assert!(npub.starts_with("npub1")); - } - -} diff --git a/src/identity/address.rs b/src/identity/address.rs new file mode 100644 index 0000000..669b312 --- /dev/null +++ b/src/identity/address.rs @@ -0,0 +1,72 @@ +//! 128-bit FIPS address with IPv6-compatible format. + +use std::fmt; +use std::net::Ipv6Addr; + +use super::{IdentityError, NodeAddr, FIPS_ADDRESS_PREFIX}; + +/// 128-bit FIPS address with IPv6-compatible format. +/// +/// The address uses the IPv6 Unique Local Address (ULA) prefix `fd00::/8`, +/// providing 120 bits for the node_addr hash. This format allows applications +/// designed for IP transports to bind to FIPS addresses via a TUN interface. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct FipsAddress([u8; 16]); + +impl FipsAddress { + /// Create a FipsAddress from a 16-byte array. + pub fn from_bytes(bytes: [u8; 16]) -> Result { + if bytes[0] != FIPS_ADDRESS_PREFIX { + return Err(IdentityError::InvalidAddressPrefix(bytes[0])); + } + Ok(Self(bytes)) + } + + /// Create a FipsAddress from a slice. + pub fn from_slice(slice: &[u8]) -> Result { + if slice.len() != 16 { + return Err(IdentityError::InvalidAddressLength(slice.len())); + } + let mut bytes = [0u8; 16]; + bytes.copy_from_slice(slice); + Self::from_bytes(bytes) + } + + /// Derive a FipsAddress from a NodeAddr. + /// + /// Takes the first 15 bytes of the node_addr and prepends the 0xfd prefix. + pub fn from_node_addr(node_addr: &NodeAddr) -> Self { + let mut bytes = [0u8; 16]; + bytes[0] = FIPS_ADDRESS_PREFIX; + bytes[1..16].copy_from_slice(&node_addr.as_bytes()[0..15]); + Self(bytes) + } + + /// Return the raw bytes. + pub fn as_bytes(&self) -> &[u8; 16] { + &self.0 + } + + /// Convert to std::net::Ipv6Addr. + pub fn to_ipv6(&self) -> Ipv6Addr { + Ipv6Addr::from(self.0) + } +} + +impl From for Ipv6Addr { + fn from(addr: FipsAddress) -> Self { + Ipv6Addr::from(addr.0) + } +} + +impl fmt::Debug for FipsAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "FipsAddress({})", self.to_ipv6()) + } +} + +impl fmt::Display for FipsAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.to_ipv6()) + } +} diff --git a/src/identity/auth.rs b/src/identity/auth.rs new file mode 100644 index 0000000..7936234 --- /dev/null +++ b/src/identity/auth.rs @@ -0,0 +1,67 @@ +//! Authentication challenge-response protocol. + +use rand::Rng; +use secp256k1::{Secp256k1, XOnlyPublicKey}; +use sha2::{Digest, Sha256}; + +use super::{IdentityError, NodeAddr}; + +/// Domain separation string for authentication challenges. +const AUTH_DOMAIN: &[u8] = b"fips-auth-v1"; + +/// A 32-byte random authentication challenge. +#[derive(Clone, Copy, Debug)] +pub struct AuthChallenge([u8; 32]); + +impl AuthChallenge { + /// Generate a new random challenge. + pub fn generate() -> Self { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill(&mut bytes); + Self(bytes) + } + + /// Create a challenge from bytes. + pub fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + /// Return the challenge bytes. + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + /// Verify a response to this challenge. + pub fn verify(&self, response: &AuthResponse) -> Result { + let digest = auth_challenge_digest(&self.0, response.timestamp); + let secp = Secp256k1::new(); + + secp.verify_schnorr(&response.signature, &digest, &response.pubkey) + .map_err(|_| IdentityError::SignatureVerificationFailed)?; + + Ok(NodeAddr::from_pubkey(&response.pubkey)) + } +} + +/// Response to an authentication challenge. +#[derive(Clone, Debug)] +pub struct AuthResponse { + /// The responder's public key. + pub pubkey: XOnlyPublicKey, + /// Timestamp included in the signed message. + pub timestamp: u64, + /// Schnorr signature over the challenge digest. + pub signature: secp256k1::schnorr::Signature, +} + +/// Compute the digest for an authentication challenge. +pub(super) fn auth_challenge_digest(challenge: &[u8; 32], timestamp: u64) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(AUTH_DOMAIN); + hasher.update(challenge); + hasher.update(timestamp.to_be_bytes()); + let result = hasher.finalize(); + let mut digest = [0u8; 32]; + digest.copy_from_slice(&result); + digest +} diff --git a/src/identity/encoding.rs b/src/identity/encoding.rs new file mode 100644 index 0000000..d81f9aa --- /dev/null +++ b/src/identity/encoding.rs @@ -0,0 +1,69 @@ +//! NIP-19 bech32 encoding for Nostr keys. + +use bech32::{Bech32, Hrp}; +use secp256k1::{SecretKey, XOnlyPublicKey}; + +use super::IdentityError; + +/// Human-readable part for npub (NIP-19). +const NPUB_HRP: Hrp = Hrp::parse_unchecked("npub"); + +/// Human-readable part for nsec (NIP-19). +const NSEC_HRP: Hrp = Hrp::parse_unchecked("nsec"); + +/// Encode an x-only public key as a bech32 npub string (NIP-19). +pub fn encode_npub(pubkey: &XOnlyPublicKey) -> String { + bech32::encode::(NPUB_HRP, &pubkey.serialize()).expect("npub encoding cannot fail") +} + +/// Decode an npub string to an x-only public key. +pub fn decode_npub(npub: &str) -> Result { + let (hrp, data) = bech32::decode(npub)?; + + if hrp != NPUB_HRP { + return Err(IdentityError::InvalidNpubPrefix(hrp.to_string())); + } + + if data.len() != 32 { + return Err(IdentityError::InvalidNpubLength(data.len())); + } + + let pubkey = XOnlyPublicKey::from_slice(&data)?; + Ok(pubkey) +} + +/// Encode a secret key as a bech32 nsec string (NIP-19). +pub fn encode_nsec(secret_key: &SecretKey) -> String { + bech32::encode::(NSEC_HRP, &secret_key.secret_bytes()) + .expect("nsec encoding cannot fail") +} + +/// Decode an nsec string to a secret key. +pub fn decode_nsec(nsec: &str) -> Result { + let (hrp, data) = bech32::decode(nsec)?; + + if hrp != NSEC_HRP { + return Err(IdentityError::InvalidNsecPrefix(hrp.to_string())); + } + + if data.len() != 32 { + return Err(IdentityError::InvalidNsecLength(data.len())); + } + + let secret_key = SecretKey::from_slice(&data)?; + Ok(secret_key) +} + +/// Decode a secret key from either nsec (bech32) or hex format. +pub fn decode_secret(s: &str) -> Result { + if s.starts_with("nsec1") { + decode_nsec(s) + } else { + let bytes = hex::decode(s)?; + if bytes.len() != 32 { + return Err(IdentityError::InvalidNsecLength(bytes.len())); + } + let secret_key = SecretKey::from_slice(&bytes)?; + Ok(secret_key) + } +} diff --git a/src/identity/local.rs b/src/identity/local.rs new file mode 100644 index 0000000..fe5e1f8 --- /dev/null +++ b/src/identity/local.rs @@ -0,0 +1,120 @@ +//! Local node identity with signing capability. + +use secp256k1::{Keypair, PublicKey, Secp256k1, SecretKey, XOnlyPublicKey}; +use std::fmt; + +use super::auth::{auth_challenge_digest, AuthResponse}; +use super::encoding::{decode_secret, encode_npub}; +use super::{sha256, FipsAddress, IdentityError, NodeAddr}; + +/// A FIPS node identity consisting of a keypair and derived identifiers. +/// +/// The identity holds the secp256k1 keypair and provides methods for signing +/// and verifying protocol messages. +pub struct Identity { + keypair: Keypair, + node_addr: NodeAddr, + address: FipsAddress, +} + +impl Identity { + /// Create a new random identity. + pub fn generate() -> Self { + let secp = Secp256k1::new(); + let keypair = Keypair::new(&secp, &mut rand::thread_rng()); + Self::from_keypair(keypair) + } + + /// Create an identity from an existing keypair. + pub fn from_keypair(keypair: Keypair) -> Self { + let (pubkey, _parity) = keypair.x_only_public_key(); + let node_addr = NodeAddr::from_pubkey(&pubkey); + let address = FipsAddress::from_node_addr(&node_addr); + Self { + keypair, + node_addr, + address, + } + } + + /// Create an identity from a secret key. + pub fn from_secret_key(secret_key: SecretKey) -> Self { + let secp = Secp256k1::new(); + let keypair = Keypair::from_secret_key(&secp, &secret_key); + Self::from_keypair(keypair) + } + + /// Create an identity from secret key bytes. + pub fn from_secret_bytes(bytes: &[u8; 32]) -> Result { + let secret_key = SecretKey::from_slice(bytes)?; + Ok(Self::from_secret_key(secret_key)) + } + + /// Create an identity from an nsec string (bech32) or hex-encoded secret. + pub fn from_secret_str(s: &str) -> Result { + let secret_key = decode_secret(s)?; + Ok(Self::from_secret_key(secret_key)) + } + + /// Return the underlying keypair. + /// + /// This is needed for cryptographic operations like Noise handshakes. + pub fn keypair(&self) -> Keypair { + self.keypair + } + + /// Return the x-only public key. + pub fn pubkey(&self) -> XOnlyPublicKey { + self.keypair.x_only_public_key().0 + } + + /// Return the full public key (includes parity). + pub fn pubkey_full(&self) -> PublicKey { + self.keypair.public_key() + } + + /// Return the public key as a bech32-encoded npub string (NIP-19). + pub fn npub(&self) -> String { + encode_npub(&self.pubkey()) + } + + /// Return the node ID. + pub fn node_addr(&self) -> &NodeAddr { + &self.node_addr + } + + /// Return the FIPS address. + pub fn address(&self) -> &FipsAddress { + &self.address + } + + /// Sign arbitrary data with this identity's secret key. + pub fn sign(&self, data: &[u8]) -> secp256k1::schnorr::Signature { + let secp = Secp256k1::new(); + let digest = sha256(data); + secp.sign_schnorr(&digest, &self.keypair) + } + + /// Create an authentication response for a challenge. + /// + /// The response signs: SHA256("fips-auth-v1" || challenge || timestamp) + pub fn sign_challenge(&self, challenge: &[u8; 32], timestamp: u64) -> AuthResponse { + let digest = auth_challenge_digest(challenge, timestamp); + let secp = Secp256k1::new(); + let signature = secp.sign_schnorr(&digest, &self.keypair); + AuthResponse { + pubkey: self.pubkey(), + timestamp, + signature, + } + } +} + +impl fmt::Debug for Identity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Identity") + .field("node_addr", &self.node_addr) + .field("address", &self.address) + .finish_non_exhaustive() + } +} diff --git a/src/identity/mod.rs b/src/identity/mod.rs new file mode 100644 index 0000000..e5f7899 --- /dev/null +++ b/src/identity/mod.rs @@ -0,0 +1,83 @@ +//! FIPS Identity System +//! +//! Node identity based on Nostr keypairs (secp256k1). The node_addr is derived +//! from the public key via SHA-256, and the FIPS address uses an IPv6-compatible +//! format with the 0xfd prefix. + +mod address; +mod auth; +mod encoding; +mod local; +mod node_addr; +mod peer; + +use sha2::{Digest, Sha256}; +use thiserror::Error; + +pub use address::FipsAddress; +pub use auth::{AuthChallenge, AuthResponse}; +pub use encoding::{decode_npub, decode_nsec, decode_secret, encode_npub, encode_nsec}; +pub use local::Identity; +pub use node_addr::NodeAddr; +pub use peer::PeerIdentity; + +/// FIPS address prefix (IPv6 ULA range). +pub const FIPS_ADDRESS_PREFIX: u8 = 0xfd; + +/// Errors that can occur in identity operations. +#[derive(Debug, Error)] +pub enum IdentityError { + #[error("invalid secret key: {0}")] + InvalidSecretKey(#[from] secp256k1::Error), + + #[error("signature verification failed")] + SignatureVerificationFailed, + + #[error("invalid node_addr length: expected 16, got {0}")] + InvalidNodeAddrLength(usize), + + #[error("invalid address length: expected 16, got {0}")] + InvalidAddressLength(usize), + + #[error("invalid address prefix: expected 0xfd, got 0x{0:02x}")] + InvalidAddressPrefix(u8), + + #[error("bech32 encoding error: {0}")] + Bech32Encode(#[from] bech32::EncodeError), + + #[error("bech32 decoding error: {0}")] + Bech32Decode(#[from] bech32::DecodeError), + + #[error("invalid npub: expected 'npub' prefix, got '{0}'")] + InvalidNpubPrefix(String), + + #[error("invalid npub: expected 32 bytes, got {0}")] + InvalidNpubLength(usize), + + #[error("invalid nsec: expected 'nsec' prefix, got '{0}'")] + InvalidNsecPrefix(String), + + #[error("invalid nsec: expected 32 bytes, got {0}")] + InvalidNsecLength(usize), + + #[error("invalid hex encoding: {0}")] + InvalidHex(#[from] hex::FromHexError), +} + +/// Compute SHA-256 hash of data. +fn sha256(data: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(data); + let result = hasher.finalize(); + let mut hash = [0u8; 32]; + hash.copy_from_slice(&result); + hash +} + +/// Encode bytes as lowercase hex string. +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} + +#[cfg(test)] +mod tests; diff --git a/src/identity/node_addr.rs b/src/identity/node_addr.rs new file mode 100644 index 0000000..e5f8f48 --- /dev/null +++ b/src/identity/node_addr.rs @@ -0,0 +1,72 @@ +//! 16-byte node identifier derived from truncated SHA-256(pubkey). + +use secp256k1::XOnlyPublicKey; +use sha2::{Digest, Sha256}; +use std::fmt; + +use super::{hex_encode, IdentityError}; + +/// 16-byte node identifier derived from truncated SHA-256(pubkey). +/// +/// The node_addr is the first 16 bytes of SHA-256(pubkey), providing 128 bits +/// of collision resistance. Hashing the public key prevents grinding attacks +/// that exploit secp256k1's algebraic structure. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct NodeAddr([u8; 16]); + +impl NodeAddr { + /// Create a NodeAddr from a 16-byte array. + pub fn from_bytes(bytes: [u8; 16]) -> Self { + Self(bytes) + } + + /// Create a NodeAddr from a slice. + pub fn from_slice(slice: &[u8]) -> Result { + if slice.len() != 16 { + return Err(IdentityError::InvalidNodeAddrLength(slice.len())); + } + let mut bytes = [0u8; 16]; + bytes.copy_from_slice(slice); + Ok(Self(bytes)) + } + + /// Derive a NodeAddr from an x-only public key (npub). + /// + /// Computes SHA-256(pubkey) and takes the first 16 bytes. + pub fn from_pubkey(pubkey: &XOnlyPublicKey) -> Self { + let mut hasher = Sha256::new(); + hasher.update(pubkey.serialize()); + let hash = hasher.finalize(); + let mut bytes = [0u8; 16]; + bytes.copy_from_slice(&hash[..16]); + Self(bytes) + } + + /// Return the raw bytes. + pub fn as_bytes(&self) -> &[u8; 16] { + &self.0 + } + + /// Return the bytes as a slice. + pub fn as_slice(&self) -> &[u8] { + &self.0 + } +} + +impl fmt::Debug for NodeAddr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "NodeAddr({})", hex_encode(&self.0[..8])) + } +} + +impl fmt::Display for NodeAddr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", hex_encode(&self.0)) + } +} + +impl AsRef<[u8]> for NodeAddr { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} diff --git a/src/identity/peer.rs b/src/identity/peer.rs new file mode 100644 index 0000000..ed8c82f --- /dev/null +++ b/src/identity/peer.rs @@ -0,0 +1,112 @@ +//! Remote peer identity (public key only, no signing capability). + +use secp256k1::{Parity, PublicKey, Secp256k1, XOnlyPublicKey}; +use std::fmt; + +use super::encoding::{decode_npub, encode_npub}; +use super::{sha256, FipsAddress, IdentityError, NodeAddr}; + +/// A known peer's identity (public key only, no signing capability). +/// +/// Use this to represent remote peers whose npub you know. For a local +/// identity with signing capability, use [`Identity`] instead. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct PeerIdentity { + pubkey: XOnlyPublicKey, + /// Full public key if known (includes parity for ECDH operations). + pubkey_full: Option, + node_addr: NodeAddr, + address: FipsAddress, +} + +impl PeerIdentity { + /// Create a PeerIdentity from an x-only public key. + /// + /// Note: When only the x-only key is available, the full public key + /// will be derived assuming even parity for ECDH operations. + pub fn from_pubkey(pubkey: XOnlyPublicKey) -> Self { + let node_addr = NodeAddr::from_pubkey(&pubkey); + let address = FipsAddress::from_node_addr(&node_addr); + Self { + pubkey, + pubkey_full: None, + node_addr, + address, + } + } + + /// Create a PeerIdentity from a full public key (includes parity). + /// + /// Use this when you have the complete public key (e.g., from a Noise + /// handshake) to preserve parity information for ECDH operations. + pub fn from_pubkey_full(pubkey: PublicKey) -> Self { + let (x_only, _parity) = pubkey.x_only_public_key(); + let node_addr = NodeAddr::from_pubkey(&x_only); + let address = FipsAddress::from_node_addr(&node_addr); + Self { + pubkey: x_only, + pubkey_full: Some(pubkey), + node_addr, + address, + } + } + + /// Create a PeerIdentity from a bech32-encoded npub string. + pub fn from_npub(npub: &str) -> Result { + let pubkey = decode_npub(npub)?; + Ok(Self::from_pubkey(pubkey)) + } + + /// Return the x-only public key. + pub fn pubkey(&self) -> XOnlyPublicKey { + self.pubkey + } + + /// Return the full public key for ECDH operations. + /// + /// If the full key was provided during construction, it is returned. + /// Otherwise, the key is derived from the x-only key assuming even parity. + pub fn pubkey_full(&self) -> PublicKey { + self.pubkey_full.unwrap_or_else(|| { + // Derive full key assuming even parity + self.pubkey.public_key(Parity::Even) + }) + } + + /// Return the public key as a bech32-encoded npub string (NIP-19). + pub fn npub(&self) -> String { + encode_npub(&self.pubkey) + } + + /// Return the node ID. + pub fn node_addr(&self) -> &NodeAddr { + &self.node_addr + } + + /// Return the FIPS address. + pub fn address(&self) -> &FipsAddress { + &self.address + } + + /// Verify a signature from this peer. + pub fn verify(&self, data: &[u8], signature: &secp256k1::schnorr::Signature) -> bool { + let secp = Secp256k1::new(); + let digest = sha256(data); + secp.verify_schnorr(signature, &digest, &self.pubkey).is_ok() + } +} + +impl fmt::Debug for PeerIdentity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PeerIdentity") + .field("node_addr", &self.node_addr) + .field("address", &self.address) + .finish() + } +} + +impl fmt::Display for PeerIdentity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.npub()) + } +} diff --git a/src/identity/tests.rs b/src/identity/tests.rs new file mode 100644 index 0000000..37d72c5 --- /dev/null +++ b/src/identity/tests.rs @@ -0,0 +1,642 @@ +use std::collections::HashSet; +use std::net::Ipv6Addr; + +use secp256k1::{Keypair, Secp256k1, SecretKey}; + +use super::*; + +#[test] +fn test_identity_generation() { + let identity = Identity::generate(); + + // NodeAddr should be 16 bytes + assert_eq!(identity.node_addr().as_bytes().len(), 16); + + // Address should start with 0xfd + assert_eq!(identity.address().as_bytes()[0], 0xfd); + + // Address bytes 1-15 should match node_addr bytes 0-14 + assert_eq!( + &identity.address().as_bytes()[1..16], + &identity.node_addr().as_bytes()[0..15] + ); +} + +#[test] +fn test_node_addr_from_pubkey_deterministic() { + let identity = Identity::generate(); + let pubkey = identity.pubkey(); + + let node_addr1 = NodeAddr::from_pubkey(&pubkey); + let node_addr2 = NodeAddr::from_pubkey(&pubkey); + + assert_eq!(node_addr1, node_addr2); +} + +#[test] +fn test_fips_address_ipv6_format() { + let identity = Identity::generate(); + let ipv6 = identity.address().to_ipv6(); + let addr_str = ipv6.to_string(); + + // Should start with fd (ULA prefix) + assert!(addr_str.starts_with("fd")); + + // Conversion should be lossless + let octets = ipv6.octets(); + assert_eq!(&octets, identity.address().as_bytes()); +} + +#[test] +fn test_auth_challenge_verify_success() { + let identity = Identity::generate(); + let challenge = AuthChallenge::generate(); + let timestamp = 1234567890u64; + + let response = identity.sign_challenge(challenge.as_bytes(), timestamp); + let result = challenge.verify(&response); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), *identity.node_addr()); +} + +#[test] +fn test_auth_challenge_verify_wrong_challenge() { + let identity = Identity::generate(); + let challenge1 = AuthChallenge::generate(); + let challenge2 = AuthChallenge::generate(); + let timestamp = 1234567890u64; + + let response = identity.sign_challenge(challenge1.as_bytes(), timestamp); + let result = challenge2.verify(&response); + + assert!(matches!( + result, + Err(IdentityError::SignatureVerificationFailed) + )); +} + +#[test] +fn test_auth_challenge_verify_wrong_timestamp() { + let identity = Identity::generate(); + let challenge = AuthChallenge::generate(); + + let response = identity.sign_challenge(challenge.as_bytes(), 1234567890); + + // Modify the timestamp in the response + let bad_response = AuthResponse { + pubkey: response.pubkey, + timestamp: 9999999999, + signature: response.signature, + }; + + let result = challenge.verify(&bad_response); + assert!(matches!( + result, + Err(IdentityError::SignatureVerificationFailed) + )); +} + +#[test] +fn test_node_addr_ordering() { + let id1 = Identity::generate(); + let id2 = Identity::generate(); + + // NodeAddrs should be comparable for root election + let _cmp = id1.node_addr().cmp(id2.node_addr()); +} + +#[test] +fn test_identity_from_secret_bytes() { + // A known secret key (32 bytes) + let secret_bytes: [u8; 32] = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, + 0x1d, 0x1e, 0x1f, 0x20, + ]; + + let identity1 = Identity::from_secret_bytes(&secret_bytes).unwrap(); + let identity2 = Identity::from_secret_bytes(&secret_bytes).unwrap(); + + // Same secret key should produce same node_addr + assert_eq!(identity1.node_addr(), identity2.node_addr()); + assert_eq!(identity1.address(), identity2.address()); +} + +#[test] +fn test_node_addr_from_slice() { + let bytes = [0u8; 16]; + let node_addr = NodeAddr::from_slice(&bytes).unwrap(); + assert_eq!(node_addr.as_bytes(), &bytes); + + // Wrong length should fail + let short = [0u8; 8]; + assert!(matches!( + NodeAddr::from_slice(&short), + Err(IdentityError::InvalidNodeAddrLength(8)) + )); +} + +#[test] +fn test_fips_address_validation() { + // Valid address with fd prefix + let mut valid = [0u8; 16]; + valid[0] = 0xfd; + assert!(FipsAddress::from_bytes(valid).is_ok()); + + // Invalid prefix + let mut invalid = [0u8; 16]; + invalid[0] = 0xfe; + assert!(matches!( + FipsAddress::from_bytes(invalid), + Err(IdentityError::InvalidAddressPrefix(0xfe)) + )); +} + +#[test] +fn test_identity_sign() { + let identity = Identity::generate(); + let data = b"test message"; + + let sig = identity.sign(data); + + // Verify the signature manually + let secp = secp256k1::Secp256k1::new(); + let digest = super::sha256(data); + assert!(secp + .verify_schnorr(&sig, &digest, &identity.pubkey()) + .is_ok()); +} + +#[test] +fn test_npub_encoding() { + let identity = Identity::generate(); + let npub = identity.npub(); + + // Should start with "npub1" + assert!(npub.starts_with("npub1")); + + // Should be 63 characters (npub1 + 58 chars of bech32 data) + assert_eq!(npub.len(), 63); +} + +#[test] +fn test_npub_roundtrip() { + let identity = Identity::generate(); + let npub = identity.npub(); + + let decoded = decode_npub(&npub).unwrap(); + assert_eq!(decoded, identity.pubkey()); +} + +#[test] +fn test_npub_known_vector() { + // Test against a known npub (from NIP-19 test vectors or generated externally) + let secret_bytes: [u8; 32] = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, + 0x1d, 0x1e, 0x1f, 0x20, + ]; + + let identity = Identity::from_secret_bytes(&secret_bytes).unwrap(); + let npub = identity.npub(); + + // Decode and verify it matches the original pubkey + let decoded = decode_npub(&npub).unwrap(); + assert_eq!(decoded, identity.pubkey()); + + // npub should be deterministic + let npub2 = encode_npub(&identity.pubkey()); + assert_eq!(npub, npub2); +} + +#[test] +fn test_decode_npub_invalid_prefix() { + // nsec instead of npub + let nsec = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"; + let result = decode_npub(nsec); + assert!(matches!(result, Err(IdentityError::InvalidNpubPrefix(_)))); +} + +#[test] +fn test_decode_npub_invalid_checksum() { + // Valid npub with corrupted checksum + let bad_npub = "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq"; + let result = decode_npub(bad_npub); + assert!(result.is_err()); +} + +#[test] +fn test_peer_identity_from_npub() { + let identity = Identity::generate(); + let npub = identity.npub(); + + let peer = PeerIdentity::from_npub(&npub).unwrap(); + + assert_eq!(peer.pubkey(), identity.pubkey()); + assert_eq!(peer.node_addr(), identity.node_addr()); + assert_eq!(peer.address(), identity.address()); + assert_eq!(peer.npub(), npub); +} + +#[test] +fn test_peer_identity_verify_signature() { + let identity = Identity::generate(); + let peer = PeerIdentity::from_pubkey(identity.pubkey()); + + let data = b"hello world"; + let signature = identity.sign(data); + + assert!(peer.verify(data, &signature)); + assert!(!peer.verify(b"wrong data", &signature)); +} + +#[test] +fn test_peer_identity_from_invalid_npub() { + let result = PeerIdentity::from_npub("npub1invalid"); + assert!(result.is_err()); + + let result = + PeerIdentity::from_npub("nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"); + assert!(matches!(result, Err(IdentityError::InvalidNpubPrefix(_)))); +} + +#[test] +fn test_peer_identity_display() { + let identity = Identity::generate(); + let peer = PeerIdentity::from_pubkey(identity.pubkey()); + + let display = format!("{}", peer); + assert!(display.starts_with("npub1")); + assert_eq!(display, identity.npub()); +} + +#[test] +fn test_nsec_roundtrip() { + let secret_bytes: [u8; 32] = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, + 0x1d, 0x1e, 0x1f, 0x20, + ]; + + let secret_key = SecretKey::from_slice(&secret_bytes).unwrap(); + let nsec = encode_nsec(&secret_key); + + assert!(nsec.starts_with("nsec1")); + assert_eq!(nsec.len(), 63); + + let decoded = decode_nsec(&nsec).unwrap(); + assert_eq!(decoded.secret_bytes(), secret_bytes); +} + +#[test] +fn test_decode_nsec_invalid_prefix() { + // Use a valid npub (from a generated identity) to test prefix rejection + let identity = Identity::generate(); + let npub = identity.npub(); + let result = decode_nsec(&npub); + assert!(matches!(result, Err(IdentityError::InvalidNsecPrefix(_)))); +} + +#[test] +fn test_decode_secret_nsec() { + let secret_bytes: [u8; 32] = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, + 0x1d, 0x1e, 0x1f, 0x20, + ]; + + let secret_key = SecretKey::from_slice(&secret_bytes).unwrap(); + let nsec = encode_nsec(&secret_key); + + let decoded = decode_secret(&nsec).unwrap(); + assert_eq!(decoded.secret_bytes(), secret_bytes); +} + +#[test] +fn test_decode_secret_hex() { + let hex_str = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; + let decoded = decode_secret(hex_str).unwrap(); + + let expected: [u8; 32] = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, + 0x1d, 0x1e, 0x1f, 0x20, + ]; + assert_eq!(decoded.secret_bytes(), expected); +} + +#[test] +fn test_identity_from_secret_str_nsec() { + let secret_bytes: [u8; 32] = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, + 0x1d, 0x1e, 0x1f, 0x20, + ]; + + let secret_key = SecretKey::from_slice(&secret_bytes).unwrap(); + let nsec = encode_nsec(&secret_key); + + let identity = Identity::from_secret_str(&nsec).unwrap(); + let identity_from_bytes = Identity::from_secret_bytes(&secret_bytes).unwrap(); + + assert_eq!(identity.node_addr(), identity_from_bytes.node_addr()); +} + +#[test] +fn test_identity_from_secret_str_hex() { + let hex_str = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; + let secret_bytes: [u8; 32] = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, + 0x1d, 0x1e, 0x1f, 0x20, + ]; + + let identity = Identity::from_secret_str(hex_str).unwrap(); + let identity_from_bytes = Identity::from_secret_bytes(&secret_bytes).unwrap(); + + assert_eq!(identity.node_addr(), identity_from_bytes.node_addr()); +} + +#[test] +fn test_hex_conversion_case1() { + let hex_str = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; + let identity = Identity::from_secret_str(hex_str).unwrap(); + let npub = identity.npub(); + assert!(npub.starts_with("npub1")); +} + +#[test] +fn test_hex_conversion_case2() { + let hex_str = "b102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fb0"; + let identity = Identity::from_secret_str(hex_str).unwrap(); + let npub = identity.npub(); + assert!(npub.starts_with("npub1")); +} + +// ===== encoding.rs error path tests ===== + +#[test] +fn test_decode_npub_invalid_length() { + // Encode 16 bytes (too short) as bech32 with npub prefix + let short = bech32::encode::( + bech32::Hrp::parse_unchecked("npub"), + &[0u8; 16], + ) + .unwrap(); + let result = decode_npub(&short); + assert!(matches!(result, Err(IdentityError::InvalidNpubLength(16)))); +} + +#[test] +fn test_decode_nsec_invalid_length() { + // Encode 16 bytes (too short) as bech32 with nsec prefix + let short = bech32::encode::( + bech32::Hrp::parse_unchecked("nsec"), + &[0u8; 16], + ) + .unwrap(); + let result = decode_nsec(&short); + assert!(matches!(result, Err(IdentityError::InvalidNsecLength(16)))); +} + +#[test] +fn test_decode_secret_hex_wrong_length() { + // 16 hex bytes (too short for a secret key) + let result = decode_secret("0102030405060708090a0b0c0d0e0f10"); + assert!(matches!(result, Err(IdentityError::InvalidNsecLength(16)))); +} + +#[test] +fn test_decode_secret_hex_invalid_chars() { + let result = decode_secret("zzzz"); + assert!(matches!(result, Err(IdentityError::InvalidHex(_)))); +} + +// ===== node_addr.rs tests ===== + +#[test] +fn test_node_addr_debug() { + let bytes = [ + 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, + ]; + let node_addr = NodeAddr::from_bytes(bytes); + let debug = format!("{:?}", node_addr); + assert_eq!(debug, "NodeAddr(0123456789abcdef)"); +} + +#[test] +fn test_node_addr_display() { + let bytes = [ + 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, + 0x32, 0x10, + ]; + let node_addr = NodeAddr::from_bytes(bytes); + let display = format!("{}", node_addr); + assert_eq!(display, "0123456789abcdeffedcba9876543210"); +} + +#[test] +fn test_node_addr_as_slice() { + let bytes = [0xaa; 16]; + let node_addr = NodeAddr::from_bytes(bytes); + assert_eq!(node_addr.as_slice(), &bytes[..]); + assert_eq!(node_addr.as_slice().len(), 16); +} + +#[test] +fn test_node_addr_as_ref() { + let bytes = [0xbb; 16]; + let node_addr = NodeAddr::from_bytes(bytes); + let r: &[u8] = node_addr.as_ref(); + assert_eq!(r, &bytes[..]); +} + +#[test] +fn test_node_addr_hash() { + let id1 = Identity::generate(); + let id2 = Identity::generate(); + let mut set = HashSet::new(); + set.insert(*id1.node_addr()); + set.insert(*id2.node_addr()); + assert_eq!(set.len(), 2); + // Re-inserting the same address doesn't grow the set + set.insert(*id1.node_addr()); + assert_eq!(set.len(), 2); +} + +// ===== address.rs tests ===== + +#[test] +fn test_fips_address_from_slice_success() { + let mut bytes = [0u8; 16]; + bytes[0] = 0xfd; + bytes[1] = 0x42; + let addr = FipsAddress::from_slice(&bytes).unwrap(); + assert_eq!(addr.as_bytes(), &bytes); +} + +#[test] +fn test_fips_address_from_slice_wrong_length() { + let short = [0xfd; 8]; + assert!(matches!( + FipsAddress::from_slice(&short), + Err(IdentityError::InvalidAddressLength(8)) + )); + + let long = [0xfd; 20]; + assert!(matches!( + FipsAddress::from_slice(&long), + Err(IdentityError::InvalidAddressLength(20)) + )); +} + +#[test] +fn test_fips_address_from_slice_wrong_prefix() { + let mut bytes = [0u8; 16]; + bytes[0] = 0xfe; + assert!(matches!( + FipsAddress::from_slice(&bytes), + Err(IdentityError::InvalidAddressPrefix(0xfe)) + )); +} + +#[test] +fn test_fips_address_into_ipv6() { + let identity = Identity::generate(); + let addr = *identity.address(); + // Test the From trait (not to_ipv6 method) + let ipv6: Ipv6Addr = addr.into(); + assert_eq!(ipv6.octets(), *addr.as_bytes()); +} + +#[test] +fn test_fips_address_debug() { + let mut bytes = [0u8; 16]; + bytes[0] = 0xfd; + let addr = FipsAddress::from_bytes(bytes).unwrap(); + let debug = format!("{:?}", addr); + assert!(debug.starts_with("FipsAddress(")); + assert!(debug.contains("fd")); +} + +#[test] +fn test_fips_address_display() { + let mut bytes = [0u8; 16]; + bytes[0] = 0xfd; + let addr = FipsAddress::from_bytes(bytes).unwrap(); + let display = format!("{}", addr); + // Display is the IPv6 representation + assert!(display.starts_with("fd")); +} + +#[test] +fn test_fips_address_eq_hash() { + let identity = Identity::generate(); + let addr1 = *identity.address(); + let addr2 = FipsAddress::from_node_addr(identity.node_addr()); + assert_eq!(addr1, addr2); + + let mut set = HashSet::new(); + set.insert(addr1); + set.insert(addr2); + assert_eq!(set.len(), 1); +} + +// ===== auth.rs tests ===== + +#[test] +fn test_auth_challenge_from_bytes() { + let bytes = [0x42u8; 32]; + let challenge = AuthChallenge::from_bytes(bytes); + assert_eq!(challenge.as_bytes(), &bytes); +} + +// ===== peer.rs tests ===== + +#[test] +fn test_peer_identity_from_pubkey_full() { + let identity = Identity::generate(); + let full_pubkey = identity.pubkey_full(); + + let peer = PeerIdentity::from_pubkey_full(full_pubkey); + + // x-only key should match + assert_eq!(peer.pubkey(), identity.pubkey()); + // Full key should be preserved (not derived) + assert_eq!(peer.pubkey_full(), full_pubkey); + // Derived identifiers should match + assert_eq!(peer.node_addr(), identity.node_addr()); + assert_eq!(peer.address(), identity.address()); +} + +#[test] +fn test_peer_identity_pubkey_full_even_parity_fallback() { + let identity = Identity::generate(); + // from_pubkey only stores x-only, so pubkey_full must derive with even parity + let peer = PeerIdentity::from_pubkey(identity.pubkey()); + let full = peer.pubkey_full(); + // The derived full key's x-only component should match + let (x_only, _parity) = full.x_only_public_key(); + assert_eq!(x_only, identity.pubkey()); +} + +#[test] +fn test_peer_identity_pubkey_full_preserved_parity() { + // Create two identities and find one with odd parity to make this test meaningful + let secp = Secp256k1::new(); + let secret_bytes: [u8; 32] = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, + 0x1d, 0x1e, 0x1f, 0x20, + ]; + let keypair = Keypair::from_seckey_slice(&secp, &secret_bytes).unwrap(); + let full_pubkey = keypair.public_key(); + + let peer = PeerIdentity::from_pubkey_full(full_pubkey); + // pubkey_full should return the exact key provided, preserving parity + assert_eq!(peer.pubkey_full(), full_pubkey); +} + +#[test] +fn test_peer_identity_debug() { + let identity = Identity::generate(); + let peer = PeerIdentity::from_pubkey(identity.pubkey()); + let debug = format!("{:?}", peer); + assert!(debug.starts_with("PeerIdentity {")); + assert!(debug.contains("node_addr")); + assert!(debug.contains("address")); +} + +// ===== local.rs tests ===== + +#[test] +fn test_identity_keypair() { + let identity = Identity::generate(); + let keypair = identity.keypair(); + // keypair's public key should match identity's pubkey + let (x_only, _) = keypair.x_only_public_key(); + assert_eq!(x_only, identity.pubkey()); +} + +#[test] +fn test_identity_pubkey_full() { + let identity = Identity::generate(); + let full = identity.pubkey_full(); + // Full key's x-only component should match pubkey + let (x_only, _) = full.x_only_public_key(); + assert_eq!(x_only, identity.pubkey()); +} + +#[test] +fn test_identity_debug() { + let identity = Identity::generate(); + let debug = format!("{:?}", identity); + assert!(debug.starts_with("Identity {")); + assert!(debug.contains("node_addr")); + assert!(debug.contains("address")); + // Should NOT contain the secret key + assert!(!debug.contains("keypair")); + assert!(debug.contains("..")); +} diff --git a/src/lib.rs b/src/lib.rs index ce720b7..3a0effd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,7 +6,6 @@ pub mod bloom; pub mod cache; pub mod config; -pub mod icmp; pub mod identity; pub mod index; pub mod noise; @@ -15,7 +14,7 @@ pub mod peer; pub mod protocol; pub mod transport; pub mod tree; -pub mod tun; +pub mod upper; // Re-export identity types pub use identity::{ diff --git a/src/node/handlers/session.rs b/src/node/handlers/session.rs index f690626..500a475 100644 --- a/src/node/handlers/session.rs +++ b/src/node/handlers/session.rs @@ -569,7 +569,7 @@ impl Node { /// Send ICMPv6 Destination Unreachable back through TUN. pub(in crate::node) fn send_icmpv6_dest_unreachable(&self, original_packet: &[u8]) { - use crate::icmp::{build_dest_unreachable, should_send_icmp_error, DestUnreachableCode}; + use crate::upper::icmp::{build_dest_unreachable, should_send_icmp_error, DestUnreachableCode}; use crate::FipsAddress; if !should_send_icmp_error(original_packet) { diff --git a/src/node/lifecycle.rs b/src/node/lifecycle.rs index 8204e6d..2ef1913 100644 --- a/src/node/lifecycle.rs +++ b/src/node/lifecycle.rs @@ -4,7 +4,7 @@ use super::{Node, NodeError, NodeState}; use crate::peer::PeerConnection; use crate::protocol::{Disconnect, DisconnectReason}; use crate::transport::{packet_channel, Link, LinkDirection, TransportAddr}; -use crate::tun::{run_tun_reader, shutdown_tun_interface, TunDevice, TunState}; +use crate::upper::tun::{run_tun_reader, shutdown_tun_interface, TunDevice, TunState}; use crate::node::wire::build_msg1; use crate::{NodeAddr, PeerIdentity}; use std::thread; @@ -319,7 +319,7 @@ impl Node { let dns_channel_size = self.config.node.buffers.dns_channel; let (identity_tx, identity_rx) = tokio::sync::mpsc::channel(dns_channel_size); let dns_ttl = self.config.dns.ttl(); - let handle = tokio::spawn(crate::node::dns::run_dns_responder(socket, identity_tx, dns_ttl)); + let handle = tokio::spawn(crate::upper::dns::run_dns_responder(socket, identity_tx, dns_ttl)); self.dns_identity_rx = Some(identity_rx); self.dns_task = Some(handle); info!(bind = %bind, "DNS responder started for .fips domain"); diff --git a/src/node/mod.rs b/src/node/mod.rs index 42c286f..a86c9c8 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -5,7 +5,6 @@ //! Bloom filters, coordinate caches, transports, links, and peers. mod bloom; -pub(crate) mod dns; mod handlers; mod lifecycle; mod retry; @@ -27,7 +26,7 @@ use crate::transport::{ }; use crate::transport::udp::UdpTransport; use crate::tree::TreeState; -use crate::tun::{TunError, TunOutboundRx, TunState, TunTx}; +use crate::upper::tun::{TunError, TunOutboundRx, TunState, TunTx}; use self::wire::build_encrypted; use crate::{Config, ConfigError, Identity, IdentityError, NodeAddr}; use std::collections::{HashMap, VecDeque}; @@ -297,7 +296,7 @@ pub struct Node { // === DNS Responder === /// Receiver for resolved identities from the DNS responder. - dns_identity_rx: Option, + dns_identity_rx: Option, /// DNS responder task handle. dns_task: Option>, diff --git a/src/node/dns.rs b/src/upper/dns.rs similarity index 100% rename from src/node/dns.rs rename to src/upper/dns.rs diff --git a/src/icmp.rs b/src/upper/icmp.rs similarity index 100% rename from src/icmp.rs rename to src/upper/icmp.rs diff --git a/src/upper/mod.rs b/src/upper/mod.rs new file mode 100644 index 0000000..91752ee --- /dev/null +++ b/src/upper/mod.rs @@ -0,0 +1,10 @@ +//! IPv6 Upper Layer Adaptation +//! +//! This module groups the components that bridge between the FIPS routing +//! layer and IPv6 applications: the TUN interface (packet I/O), DNS +//! responder (.fips domain resolution), and ICMPv6 handling (error +//! signaling and neighbor discovery). + +pub mod dns; +pub mod icmp; +pub mod tun; diff --git a/src/tun.rs b/src/upper/tun.rs similarity index 99% rename from src/tun.rs rename to src/upper/tun.rs index 68dc786..c54b39e 100644 --- a/src/tun.rs +++ b/src/upper/tun.rs @@ -243,7 +243,7 @@ pub fn run_tun_reader( tun_tx: TunTx, outbound_tx: TunOutboundTx, ) { - use crate::icmp::{build_dest_unreachable, should_send_icmp_error, DestUnreachableCode}; + use super::icmp::{build_dest_unreachable, should_send_icmp_error, DestUnreachableCode}; let name = device.name().to_string(); let mut buf = vec![0u8; mtu as usize + 100]; // Extra space for headers