mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-22 07:48:26 +00:00
Add identity module with NodeId, FipsAddress, and auth challenge
Implements FIPS Identity System (Section 1 of design doc): - NodeId: 32-byte SHA-256 hash of npub, with Ord for root election - FipsAddress: 128-bit IPv6-compatible address (0xfd prefix) - Identity: keypair holder with sign/verify methods - AuthChallenge/AuthResponse: challenge-response authentication All 11 tests passing.
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
307
Cargo.lock
generated
Normal file
307
Cargo.lock
generated
Normal file
@@ -0,0 +1,307 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "bitcoin-io"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953"
|
||||
|
||||
[[package]]
|
||||
name = "bitcoin_hashes"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b"
|
||||
dependencies = [
|
||||
"bitcoin-io",
|
||||
"hex-conservative",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db"
|
||||
|
||||
[[package]]
|
||||
name = "fips"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"hex",
|
||||
"rand",
|
||||
"secp256k1",
|
||||
"sha2",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hex-conservative"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.180"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secp256k1"
|
||||
version = "0.30.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252"
|
||||
dependencies = [
|
||||
"bitcoin_hashes",
|
||||
"rand",
|
||||
"secp256k1-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secp256k1-sys"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
@@ -4,3 +4,10 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
secp256k1 = { version = "0.30", features = ["rand", "global-context"] }
|
||||
sha2 = "0.10"
|
||||
rand = "0.8"
|
||||
thiserror = "2.0"
|
||||
|
||||
[dev-dependencies]
|
||||
hex = "0.4"
|
||||
|
||||
497
src/identity.rs
Normal file
497
src/identity.rs
Normal file
@@ -0,0 +1,497 @@
|
||||
//! FIPS Identity System
|
||||
//!
|
||||
//! Node identity based on Nostr keypairs (secp256k1). The node_id is derived
|
||||
//! from the public key via SHA-256, and the FIPS address uses an IPv6-compatible
|
||||
//! format with the 0xfd prefix.
|
||||
|
||||
use rand::Rng;
|
||||
use secp256k1::{Keypair, Secp256k1, SecretKey, XOnlyPublicKey};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fmt;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Domain separation string for authentication challenges.
|
||||
const AUTH_DOMAIN: &[u8] = b"fips-auth-v1";
|
||||
|
||||
/// FIPS address prefix (IPv6 ULA range).
|
||||
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_id length: expected 32, got {0}")]
|
||||
InvalidNodeIdLength(usize),
|
||||
|
||||
#[error("invalid address length: expected 16, got {0}")]
|
||||
InvalidAddressLength(usize),
|
||||
|
||||
#[error("invalid address prefix: expected 0xfd, got 0x{0:02x}")]
|
||||
InvalidAddressPrefix(u8),
|
||||
}
|
||||
|
||||
/// 32-byte node identifier derived from SHA-256(npub).
|
||||
///
|
||||
/// The node_id is used in protocol messages and bloom filters. Hashing the
|
||||
/// public key prevents grinding attacks that exploit secp256k1's algebraic
|
||||
/// structure.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct NodeId([u8; 32]);
|
||||
|
||||
impl NodeId {
|
||||
/// Create a NodeId from a 32-byte array.
|
||||
pub fn from_bytes(bytes: [u8; 32]) -> Self {
|
||||
Self(bytes)
|
||||
}
|
||||
|
||||
/// Create a NodeId from a slice.
|
||||
pub fn from_slice(slice: &[u8]) -> Result<Self, IdentityError> {
|
||||
if slice.len() != 32 {
|
||||
return Err(IdentityError::InvalidNodeIdLength(slice.len()));
|
||||
}
|
||||
let mut bytes = [0u8; 32];
|
||||
bytes.copy_from_slice(slice);
|
||||
Ok(Self(bytes))
|
||||
}
|
||||
|
||||
/// Derive a NodeId from an x-only public key (npub).
|
||||
pub fn from_pubkey(pubkey: &XOnlyPublicKey) -> Self {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(pubkey.serialize());
|
||||
let hash = hasher.finalize();
|
||||
let mut bytes = [0u8; 32];
|
||||
bytes.copy_from_slice(&hash);
|
||||
Self(bytes)
|
||||
}
|
||||
|
||||
/// Return the raw bytes.
|
||||
pub fn as_bytes(&self) -> &[u8; 32] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Return the bytes as a slice.
|
||||
pub fn as_slice(&self) -> &[u8] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for NodeId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "NodeId({})", hex_encode(&self.0[..8]))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for NodeId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", hex_encode(&self.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<[u8]> for NodeId {
|
||||
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_id 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<Self, IdentityError> {
|
||||
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<Self, IdentityError> {
|
||||
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 NodeId.
|
||||
///
|
||||
/// Takes the first 15 bytes of the node_id and prepends the 0xfd prefix.
|
||||
pub fn from_node_id(node_id: &NodeId) -> Self {
|
||||
let mut bytes = [0u8; 16];
|
||||
bytes[0] = FIPS_ADDRESS_PREFIX;
|
||||
bytes[1..16].copy_from_slice(&node_id.0[0..15]);
|
||||
Self(bytes)
|
||||
}
|
||||
|
||||
/// Return the raw bytes.
|
||||
pub fn as_bytes(&self) -> &[u8; 16] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Format as an IPv6 address string.
|
||||
pub fn to_ipv6_string(&self) -> String {
|
||||
let segments: Vec<String> = (0..8)
|
||||
.map(|i| {
|
||||
let high = self.0[i * 2];
|
||||
let low = self.0[i * 2 + 1];
|
||||
format!("{:02x}{:02x}", high, low)
|
||||
})
|
||||
.collect();
|
||||
segments.join(":")
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for FipsAddress {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "FipsAddress({})", self.to_ipv6_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for FipsAddress {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.to_ipv6_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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_id: NodeId,
|
||||
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_id = NodeId::from_pubkey(&pubkey);
|
||||
let address = FipsAddress::from_node_id(&node_id);
|
||||
Self {
|
||||
keypair,
|
||||
node_id,
|
||||
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<Self, IdentityError> {
|
||||
let secret_key = SecretKey::from_slice(bytes)?;
|
||||
Ok(Self::from_secret_key(secret_key))
|
||||
}
|
||||
|
||||
/// Return the x-only public key (npub).
|
||||
pub fn pubkey(&self) -> XOnlyPublicKey {
|
||||
self.keypair.x_only_public_key().0
|
||||
}
|
||||
|
||||
/// Return the node ID.
|
||||
pub fn node_id(&self) -> &NodeId {
|
||||
&self.node_id
|
||||
}
|
||||
|
||||
/// 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_id", &self.node_id)
|
||||
.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<NodeId, IdentityError> {
|
||||
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(NodeId::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()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_identity_generation() {
|
||||
let identity = Identity::generate();
|
||||
|
||||
// NodeId should be 32 bytes
|
||||
assert_eq!(identity.node_id().as_bytes().len(), 32);
|
||||
|
||||
// Address should start with 0xfd
|
||||
assert_eq!(identity.address().as_bytes()[0], 0xfd);
|
||||
|
||||
// Address bytes 1-15 should match node_id bytes 0-14
|
||||
assert_eq!(
|
||||
&identity.address().as_bytes()[1..16],
|
||||
&identity.node_id().as_bytes()[0..15]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_node_id_from_pubkey_deterministic() {
|
||||
let identity = Identity::generate();
|
||||
let pubkey = identity.pubkey();
|
||||
|
||||
let node_id1 = NodeId::from_pubkey(&pubkey);
|
||||
let node_id2 = NodeId::from_pubkey(&pubkey);
|
||||
|
||||
assert_eq!(node_id1, node_id2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fips_address_ipv6_format() {
|
||||
let identity = Identity::generate();
|
||||
let addr_str = identity.address().to_ipv6_string();
|
||||
|
||||
// Should be 8 groups of 4 hex chars separated by colons
|
||||
let parts: Vec<&str> = addr_str.split(':').collect();
|
||||
assert_eq!(parts.len(), 8);
|
||||
for part in parts {
|
||||
assert_eq!(part.len(), 4);
|
||||
}
|
||||
|
||||
// First byte should be fd
|
||||
assert!(addr_str.starts_with("fd"));
|
||||
}
|
||||
|
||||
#[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_id());
|
||||
}
|
||||
|
||||
#[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_id_ordering() {
|
||||
let id1 = Identity::generate();
|
||||
let id2 = Identity::generate();
|
||||
|
||||
// NodeIds should be comparable for root election
|
||||
let _cmp = id1.node_id().cmp(id2.node_id());
|
||||
}
|
||||
|
||||
#[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_id
|
||||
assert_eq!(identity1.node_id(), identity2.node_id());
|
||||
assert_eq!(identity1.address(), identity2.address());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_node_id_from_slice() {
|
||||
let bytes = [0u8; 32];
|
||||
let node_id = NodeId::from_slice(&bytes).unwrap();
|
||||
assert_eq!(node_id.as_bytes(), &bytes);
|
||||
|
||||
// Wrong length should fail
|
||||
let short = [0u8; 16];
|
||||
assert!(matches!(
|
||||
NodeId::from_slice(&short),
|
||||
Err(IdentityError::InvalidNodeIdLength(16))
|
||||
));
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
10
src/lib.rs
Normal file
10
src/lib.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
//! FIPS: Federated Interoperable Peering System
|
||||
//!
|
||||
//! A distributed, decentralized network routing protocol for mesh nodes
|
||||
//! connecting over arbitrary transports.
|
||||
|
||||
pub mod identity;
|
||||
|
||||
pub use identity::{
|
||||
AuthChallenge, AuthResponse, FipsAddress, Identity, IdentityError, NodeId,
|
||||
};
|
||||
@@ -1,3 +1,3 @@
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
println!("FIPS - Federated Interoperable Peering System");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user