diff --git a/Cargo.lock b/Cargo.lock index 56bc752..ee01ee5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + [[package]] name = "bitcoin-io" version = "0.1.4" @@ -88,6 +94,7 @@ checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" name = "fips" version = "0.1.0" dependencies = [ + "bech32", "hex", "rand", "secp256k1", diff --git a/Cargo.toml b/Cargo.toml index 3d78b0a..1338c12 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ secp256k1 = { version = "0.30", features = ["rand", "global-context"] } sha2 = "0.10" rand = "0.8" thiserror = "2.0" +bech32 = "0.11" [dev-dependencies] hex = "0.4" diff --git a/src/identity.rs b/src/identity.rs index 7dcafda..edaa958 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -4,6 +4,7 @@ //! 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, Secp256k1, SecretKey, XOnlyPublicKey}; use sha2::{Digest, Sha256}; @@ -11,6 +12,9 @@ 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"); + /// Domain separation string for authentication challenges. const AUTH_DOMAIN: &[u8] = b"fips-auth-v1"; @@ -34,6 +38,18 @@ pub enum IdentityError { #[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), } /// 32-byte node identifier derived from SHA-256(npub). @@ -165,6 +181,78 @@ impl fmt::Display for FipsAddress { } } +/// 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, + node_id: NodeId, + address: FipsAddress, +} + +impl PeerIdentity { + /// Create a PeerIdentity from an x-only public key. + pub fn from_pubkey(pubkey: XOnlyPublicKey) -> Self { + let node_id = NodeId::from_pubkey(&pubkey); + let address = FipsAddress::from_node_id(&node_id); + Self { + pubkey, + node_id, + 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 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_id(&self) -> &NodeId { + &self.node_id + } + + /// 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_id", &self.node_id) + .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 @@ -208,11 +296,16 @@ impl Identity { Ok(Self::from_secret_key(secret_key)) } - /// Return the x-only public key (npub). + /// Return the x-only public key. pub fn pubkey(&self) -> XOnlyPublicKey { self.keypair.x_only_public_key().0 } + /// 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_id(&self) -> &NodeId { &self.node_id @@ -326,6 +419,27 @@ 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) +} + #[cfg(test)] mod tests { use super::*; @@ -492,4 +606,106 @@ mod tests { .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_id(), identity.node_id()); + 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()); + } } diff --git a/src/lib.rs b/src/lib.rs index b570ee5..8201866 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,5 +6,6 @@ pub mod identity; pub use identity::{ - AuthChallenge, AuthResponse, FipsAddress, Identity, IdentityError, NodeId, + decode_npub, encode_npub, AuthChallenge, AuthResponse, FipsAddress, Identity, IdentityError, + NodeId, PeerIdentity, }; diff --git a/src/main.rs b/src/main.rs index 9b51799..214df7f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,104 @@ +use fips::{AuthChallenge, Identity, PeerIdentity}; + fn main() { - println!("FIPS - Federated Interoperable Peering System"); + println!("FIPS Identity Module Demo"); + println!("=========================\n"); + + // Generate a new identity + println!("1. Generating a new identity..."); + let alice = Identity::generate(); + println!(" npub: {}", alice.npub()); + println!(" node_id: {}", alice.node_id()); + println!(" address: {}", alice.address()); + + // Create a peer identity from an npub + println!("\n2. Creating PeerIdentity from npub..."); + let alice_peer = PeerIdentity::from_npub(&alice.npub()).unwrap(); + println!(" Parsed: {}", alice_peer); + println!(" Match: {}", alice_peer.node_id() == alice.node_id()); + + // Sign and verify data + println!("\n3. Signing and verifying data..."); + let message = b"Hello, FIPS network!"; + let signature = alice.sign(message); + println!(" Message: {:?}", String::from_utf8_lossy(message)); + println!(" Signed by Alice"); + + let valid = alice_peer.verify(message, &signature); + println!(" Verified by peer: {}", valid); + + let tampered = alice_peer.verify(b"Tampered message", &signature); + println!(" Tampered message: {}", tampered); + + // Authentication challenge-response + // This simulates the mutual authentication that occurs when two FIPS nodes + // establish a connection. Unlike TLS which binds identity at the transport + // layer, FIPS authentication works over any transport (including radio/serial). + println!("\n4. Authentication challenge-response..."); + println!(" Scenario: Alice wants to verify that Bob controls his claimed npub"); + println!(); + + let bob = Identity::generate(); + println!(" Bob claims to be: {}", bob.npub()); + println!(" (Bob's node_id would be: {})", bob.node_id()); + println!(); + + // Step 1: Alice generates a random 32-byte challenge + // This nonce ensures Bob can't pre-compute responses + let challenge = AuthChallenge::generate(); + println!(" [Alice] Generated 32-byte random challenge"); + println!(" Challenge: {:02x}{:02x}{:02x}{:02x}...", + challenge.as_bytes()[0], challenge.as_bytes()[1], + challenge.as_bytes()[2], challenge.as_bytes()[3]); + println!(); + + // Step 2: Bob signs the challenge with his private key + // The signature covers: SHA256("fips-auth-v1" || challenge || timestamp) + // - Domain prefix prevents cross-protocol signature reuse + // - Timestamp enables replay attack detection + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + println!(" [Bob] Signing challenge with timestamp {}", timestamp); + println!(" Digest = SHA256(\"fips-auth-v1\" || challenge || timestamp)"); + + let response = bob.sign_challenge(challenge.as_bytes(), timestamp); + println!(" Signature created (64 bytes)"); + println!(); + + // Step 3: Alice verifies the response + // If valid, she now knows Bob controls the private key for his claimed npub + println!(" [Alice] Verifying Bob's response..."); + println!(" - Checking signature against claimed npub"); + println!(" - Checking timestamp is within acceptable window"); + + match challenge.verify(&response) { + Ok(node_id) => { + println!(); + println!(" [Alice] SUCCESS: Bob proved ownership of his npub"); + println!(" Verified node_id: {}", node_id); + println!(" Bob is now an authenticated peer"); + } + Err(e) => { + println!(); + println!(" [Alice] FAILED: {}", e); + println!(" Connection would be terminated"); + } + } + + // Deterministic identity from secret + println!("\n5. Deterministic identity from secret bytes..."); + let secret: [u8; 32] = [ + 0x00, 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, + ]; + let fixed = Identity::from_secret_bytes(&secret).unwrap(); + println!(" npub: {}", fixed.npub()); + + let fixed2 = Identity::from_secret_bytes(&secret).unwrap(); + println!(" Same secret produces same npub: {}", fixed.npub() == fixed2.npub()); + + println!("\nDone."); }