mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-10 16:43:12 +00:00
Add npub encoding and PeerIdentity to identity module
- Add bech32 dependency for NIP-19 npub encoding - Add encode_npub/decode_npub functions - Add Identity::npub() method - Add PeerIdentity type for remote peers (public key only) - from_npub() constructor - verify() for signature verification - Export new types from lib.rs - Update main.rs with verbose authentication demo - 20 tests passing
This commit is contained in:
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
+217
-1
@@ -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<Self, IdentityError> {
|
||||
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::<Bech32>(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<XOnlyPublicKey, IdentityError> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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,
|
||||
};
|
||||
|
||||
+102
-1
@@ -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.");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user