diff --git a/Cargo.lock b/Cargo.lock index b74d877..9a1b6f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -385,7 +385,7 @@ dependencies = [ [[package]] name = "event-signer" -version = "0.0.3" +version = "0.1.0" dependencies = [ "nostr-core", "nostr-nips", @@ -868,7 +868,7 @@ dependencies = [ [[package]] name = "integration-tests" -version = "0.0.3" +version = "0.1.0" dependencies = [ "nostr-core", "nostr-nips", @@ -910,7 +910,7 @@ dependencies = [ [[package]] name = "keypair-generator" -version = "0.0.3" +version = "0.1.0" dependencies = [ "nostr-core", ] @@ -1041,7 +1041,7 @@ dependencies = [ [[package]] name = "nostr-core" -version = "0.0.3" +version = "0.1.0" dependencies = [ "aes", "base64", @@ -1063,7 +1063,7 @@ dependencies = [ [[package]] name = "nostr-core-umbrella" -version = "0.0.3" +version = "0.1.0" dependencies = [ "nostr-core", "nostr-nips", @@ -1074,7 +1074,7 @@ dependencies = [ [[package]] name = "nostr-nips" -version = "0.0.3" +version = "0.1.0" dependencies = [ "aes", "block-modes", @@ -1096,7 +1096,7 @@ dependencies = [ [[package]] name = "nostr-relay" -version = "0.0.3" +version = "0.1.0" dependencies = [ "futures-util", "nostr-core", @@ -1114,7 +1114,7 @@ dependencies = [ [[package]] name = "nostr-services" -version = "0.0.3" +version = "0.1.0" dependencies = [ "nostr-core", "nostr-relay", @@ -1129,7 +1129,7 @@ dependencies = [ [[package]] name = "nostr-signer" -version = "0.0.3" +version = "0.1.0" dependencies = [ "aes", "cbc", diff --git a/core/src/crypto/keys.rs b/core/src/crypto/keys.rs index 54205b7..2569b00 100644 --- a/core/src/crypto/keys.rs +++ b/core/src/crypto/keys.rs @@ -105,6 +105,37 @@ pub fn ecdh_shared_secret( Ok(result_even) } +/// Compute the raw X coordinate of the ECDH shared point, unhashed. +/// +/// This is the key derivation required by NIP-04 (legacy encrypted direct +/// messages): the AES-256 key is the X coordinate of `secret_key * +/// public_key` as a curve point, with no hashing applied. Note that +/// [`ecdh_shared_secret`] and [`ecdh_shared_secret_both`] apply libsecp256k1's +/// default SHA256 hash over the compressed point and are used by NIP-44; do +/// not mix the two derivations. +pub fn ecdh_shared_secret_raw_x( + secret_key: &SecretKey, + public_key: &PublicKey, +) -> NostrResult<[u8; 32]> { + let sk = SecpSecretKey::from_slice(secret_key.as_bytes()) + .map_err(|_| NostrError::InvalidInput)?; + + let xonly = XOnlyPublicKey::from_slice(public_key.as_bytes()) + .map_err(|_| NostrError::InvalidInput)?; + + // NIP-04 does not specify a parity convention; the shared point is + // computed against the full public key reconstructed with even parity + // (the standard x-only reconstruction used throughout this crate). + let pk = secp256k1::PublicKey::from_x_only_public_key(xonly, secp256k1::Parity::Even); + + // Returns the raw (x, y) coordinates of the shared point, 32 bytes each, + // with no hashing. The X coordinate is the first 32 bytes. + let xy = secp256k1::ecdh::shared_secret_point(&pk, &sk); + let mut result = [0u8; 32]; + result.copy_from_slice(&xy[..32]); + Ok(result) +} + /// Compute both possible ECDH shared secrets (even and odd parity). /// Returns (even_parity_result, odd_parity_result). pub fn ecdh_shared_secret_both( diff --git a/nips/src/nip004.rs b/nips/src/nip004.rs index 2aa4fbd..72f54f8 100644 --- a/nips/src/nip004.rs +++ b/nips/src/nip004.rs @@ -1,14 +1,24 @@ //! NIP-04: Legacy Encrypted Direct Messages (AES-256-CBC). //! -//! Provides encryption and decryption of direct messages using the legacy -//! NIP-04 scheme (AES-256-CBC with base64 encoding). +//! Implements the standard NIP-04 scheme as deployed by normal Nostr +//! clients: +//! +//! * The AES-256 key is the **raw X coordinate** of the ECDH shared point +//! (`secret_key * recipient_public_key`), unhashed. +//! * The wire format is `base64(ciphertext)?iv=base64(iv)` — the ciphertext +//! first, then a `?iv=` separator, then the base64-encoded IV. +//! +//! Any deviation from this (e.g. hashing the shared secret, or packing the +//! IV in front of the ciphertext) produces messages that only the deviating +//! implementation can read. See the known-answer tests below, generated with +//! `nak`, for pinned interop vectors. use cbc::{Decryptor, Encryptor}; use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit}; use rand::rngs::OsRng; use rand::RngCore; -use nostr_core::crypto::keys::ecdh_shared_secret_both; +use nostr_core::crypto::keys::ecdh_shared_secret_raw_x; use nostr_core::error::NostrError; use nostr_core::types::{PublicKey, SecretKey}; use nostr_core::NostrResult; @@ -17,25 +27,21 @@ type Aes256CbcEnc = Encryptor; type Aes256CbcDec = Decryptor; const IV_SIZE: usize = 16; -const KEY_SIZE: usize = 32; -/// Derive a NIP-04 encryption key from an ECDH shared secret. -fn derive_nip04_key(shared_secret: &[u8; 32]) -> [u8; KEY_SIZE] { - use sha2::Digest; - let hash = sha2::Sha256::digest(shared_secret); - let mut key = [0u8; KEY_SIZE]; - key.copy_from_slice(&hash); - key -} +/// Separator between the base64 ciphertext and the base64 IV, per NIP-04. +const IV_SEPARATOR: &str = "?iv="; -/// Encrypt a plaintext using NIP-04 scheme. +/// Encrypt a plaintext using the standard NIP-04 scheme. +/// +/// Returns `base64(ciphertext)?iv=base64(iv)`. pub fn nip04_encrypt( sender_sk: &SecretKey, recipient_pk: &PublicKey, plaintext: &str, ) -> NostrResult { - let (shared_even, _) = ecdh_shared_secret_both(sender_sk, recipient_pk)?; - let key = derive_nip04_key(&shared_even); + // Raw X coordinate of the shared point, unhashed — the NIP-04 AES key. + // The X coordinate is parity-independent, so no parity handling needed. + let key = ecdh_shared_secret_raw_x(sender_sk, recipient_pk)?; let mut iv = [0u8; IV_SIZE]; OsRng.fill_bytes(&mut iv); @@ -50,46 +56,48 @@ pub fn nip04_encrypt( .encrypt_padded_mut::(&mut buf, plaintext.len()) .map_err(|_| NostrError::Nip04InvalidFormat)?; - let mut result = Vec::with_capacity(IV_SIZE + encrypted.len()); - result.extend_from_slice(&iv); - result.extend_from_slice(encrypted); - - Ok(nostr_core::util::base64_encode(&result)) + Ok(format!( + "{}{}{}", + nostr_core::util::base64_encode(encrypted), + IV_SEPARATOR, + nostr_core::util::base64_encode(&iv) + )) } -/// Decrypt a NIP-04 encrypted message. +/// Decrypt a standard NIP-04 encrypted message. +/// +/// Expects `base64(ciphertext)?iv=base64(iv)`. Returns +/// [`NostrError::Nip04InvalidFormat`] if the `?iv=` separator is missing or +/// either part fails to base64-decode. pub fn nip04_decrypt( recipient_sk: &SecretKey, sender_pk: &PublicKey, ciphertext_b64: &str, ) -> NostrResult { - let (shared_even, shared_odd) = ecdh_shared_secret_both(recipient_sk, sender_pk)?; + let (ct_b64, iv_b64) = ciphertext_b64 + .split_once(IV_SEPARATOR) + .ok_or(NostrError::Nip04InvalidFormat)?; - let decoded = nostr_core::util::base64_decode(ciphertext_b64)?; + let encrypted = nostr_core::util::base64_decode(ct_b64)?; + let iv = nostr_core::util::base64_decode(iv_b64)?; - if decoded.len() < IV_SIZE + 16 { + if iv.len() != IV_SIZE || encrypted.len() < 16 { return Err(NostrError::Nip04InvalidFormat); } - let iv = &decoded[..IV_SIZE]; - let encrypted = &decoded[IV_SIZE..]; + // Raw X coordinate of the shared point, unhashed — the NIP-04 AES key. + // Parity-independent, so a single key derivation suffices. + let key = ecdh_shared_secret_raw_x(recipient_sk, sender_pk)?; - // Try both parities - for shared_secret in [shared_even, shared_odd] { - let key = derive_nip04_key(&shared_secret); - let cipher = match Aes256CbcDec::new_from_slices(&key, iv) { - Ok(c) => c, - Err(_) => continue, - }; - let mut buf = encrypted.to_vec(); - if let Ok(decrypted) = cipher.decrypt_padded_mut::(&mut buf) { - if let Ok(text) = String::from_utf8(decrypted.to_vec()) { - return Ok(text); - } - } - } + let cipher = Aes256CbcDec::new_from_slices(&key, &iv) + .map_err(|_| NostrError::Nip04InvalidFormat)?; - Err(NostrError::Nip04DecryptFailed) + let mut buf = encrypted; + let decrypted = cipher + .decrypt_padded_mut::(&mut buf) + .map_err(|_| NostrError::Nip04DecryptFailed)?; + + String::from_utf8(decrypted.to_vec()).map_err(|_| NostrError::Nip04DecryptFailed) } #[cfg(test)] @@ -110,12 +118,143 @@ mod tests { #[test] fn test_nip04_wrong_key() { - let (sk_a, pk_a) = generate_keypair(); - let (sk_b, pk_b) = generate_keypair(); + let (sk_a, _pk_a) = generate_keypair(); + let (_sk_b, pk_b) = generate_keypair(); let (sk_c, _) = generate_keypair(); let plaintext = "secret"; let encrypted = nip04_encrypt(&sk_a, &pk_b, plaintext).unwrap(); assert!(nip04_decrypt(&sk_c, &pk_b, &encrypted).is_err()); } + + #[test] + fn test_nip04_standard_format() { + let (sk_a, pk_a) = generate_keypair(); + let (sk_b, pk_b) = generate_keypair(); + + let encrypted = nip04_encrypt(&sk_a, &pk_b, "format check").unwrap(); + + // Standard NIP-04 wire format: base64(ct)?iv=base64(iv) + let (ct_b64, iv_b64) = encrypted + .split_once(IV_SEPARATOR) + .expect("output must contain the ?iv= separator"); + let iv = nostr_core::util::base64_decode(iv_b64).unwrap(); + assert_eq!(iv.len(), IV_SIZE); + // Ciphertext must be PKCS7-padded AES blocks (multiple of 16). + let ct = nostr_core::util::base64_decode(ct_b64).unwrap(); + assert_eq!(ct.len() % 16, 0); + assert!(!ct.is_empty()); + + // The reverse direction must decrypt it (ECDH is symmetric). + let decrypted = nip04_decrypt(&sk_b, &pk_a, &encrypted).unwrap(); + assert_eq!(decrypted, "format check"); + let _ = pk_a; + } + + #[test] + fn test_nip04_rejects_missing_iv_separator() { + let (_sk_a, pk_a) = generate_keypair(); + let (sk_b, _pk_b) = generate_keypair(); + + // Legacy non-standard layout base64(IV || ct) — must be rejected. + let legacy = nostr_core::util::base64_encode(&[0u8; 48]); + assert_eq!( + nip04_decrypt(&sk_b, &pk_a, &legacy), + Err(NostrError::Nip04InvalidFormat) + ); + } + + // ── Known-answer vectors generated with `nak` (authoritative) ─────────── + // + // Generated with: + // nak key public 1111...11 + // -> 4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa + // nak encrypt --nip04 --sec 1111...11 \ + // -p 4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa \ + // "nak vector" + // -> H3hKxnfd4MX/a9Rl0cvmFg==?iv=y8Ngg9dcK8XinwERJpzNsA== + // + // The decrypt vector pins the raw-X (unhashed) key derivation and the + // `base64(ct)?iv=base64(iv)` wire format against an independent + // implementation. The encrypt test relies on the same shared key path, + // so a regression to a hashed key or a packed IV layout fails here. + + #[test] + fn test_nip04_nak_known_answer_decrypt() { + let sk = SecretKey::from_bytes(TEST_SK); + let peer = PublicKey::from_bytes(TEST_PEER_PK); + let decrypted = nip04_decrypt(&sk, &peer, NAK_CIPHERTEXT).unwrap(); + assert_eq!(decrypted, NAK_PLAINTEXT); + } + + #[test] + fn test_nip04_nak_known_answer_encrypt() { + use aes::cipher::KeyIvInit; + + let sk = SecretKey::from_bytes(TEST_SK); + let peer = PublicKey::from_bytes(TEST_PEER_PK); + + // The IV is random per encryption, so a byte-for-byte comparison of + // nip04_encrypt output against nak's output is impossible. Instead, + // pin the key derivation: derive the raw-X key, encrypt the plaintext + // with nak's IV, and the result must equal nak's ciphertext exactly. + let key = ecdh_shared_secret_raw_x(&sk, &peer).unwrap(); + let iv = nostr_core::util::base64_decode(NAK_IV_B64).unwrap(); + + let mut buf = vec![0u8; NAK_PLAINTEXT.len() + 16 + 16]; + buf[..NAK_PLAINTEXT.len()].copy_from_slice(NAK_PLAINTEXT.as_bytes()); + let ct = Aes256CbcEnc::new_from_slices(&key, &iv) + .unwrap() + .encrypt_padded_mut::(&mut buf, NAK_PLAINTEXT.len()) + .unwrap(); + assert_eq!( + nostr_core::util::base64_encode(ct), + NAK_CT_B64, + "raw-X key + nak IV must reproduce nak's ciphertext byte-for-byte" + ); + + // And the full nip04_encrypt round-trip must hold. + let encrypted = nip04_encrypt(&sk, &peer, NAK_PLAINTEXT).unwrap(); + assert!(encrypted.contains(IV_SEPARATOR)); + let roundtrip = nip04_decrypt(&sk, &peer, &encrypted).unwrap(); + assert_eq!(roundtrip, NAK_PLAINTEXT); + } + + // Fixed test keys (NOT used for anything real). + const TEST_SK: [u8; 32] = [ + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, + ]; + // `nak key public 1111...11` + const TEST_PEER_PK: [u8; 32] = [ + 0x4f, 0x35, 0x5b, 0xdc, 0xb7, 0xcc, 0x0a, 0xf7, 0x28, 0xef, 0x3c, 0xce, 0xb9, 0x61, 0x5d, + 0x90, 0x68, 0x4b, 0xb5, 0xb2, 0xca, 0x5f, 0x85, 0x9a, 0xb0, 0xf0, 0xb7, 0x04, 0x07, 0x58, + 0x71, 0xaa, + ]; + const NAK_PLAINTEXT: &str = "nak vector"; + // Full nak output: base64(ct)?iv=base64(iv) + const NAK_CIPHERTEXT: &str = "H3hKxnfd4MX/a9Rl0cvmFg==?iv=y8Ngg9dcK8XinwERJpzNsA=="; + // Just the ciphertext part, for the same-IV comparison in the encrypt test. + const NAK_CT_B64: &str = "H3hKxnfd4MX/a9Rl0cvmFg=="; + // Just the IV part, for the same-IV comparison in the encrypt test. + const NAK_IV_B64: &str = "y8Ngg9dcK8XinwERJpzNsA=="; + + #[test] + fn test_nip04_raw_x_key_symmetric_and_unhashed() { + use nostr_core::crypto::keys::{ecdh_shared_secret, ecdh_shared_secret_raw_x}; + + let (sk_a, pk_a) = generate_keypair(); + let (sk_b, pk_b) = generate_keypair(); + + // ECDH is symmetric: both parties must derive the same raw-X key. + let key_ab = ecdh_shared_secret_raw_x(&sk_a, &pk_b).unwrap(); + let key_ba = ecdh_shared_secret_raw_x(&sk_b, &pk_a).unwrap(); + assert_eq!(key_ab, key_ba); + + // Regression guard: the raw-X key must NOT equal the hashed ECDH + // secret used by NIP-44 (the old bug double-hashed this value). + let hashed = ecdh_shared_secret(&sk_a, &pk_b).unwrap(); + assert_ne!(key_ab, hashed); + } } diff --git a/nips/src/nip060.rs b/nips/src/nip060.rs index 468d101..d753460 100644 --- a/nips/src/nip060.rs +++ b/nips/src/nip060.rs @@ -8,6 +8,9 @@ use nostr_core::types::{Event, Kind, PublicKey}; use nostr_core::NostrResult; /// A Cashu proof. +/// +/// Field names `C`/`B` are uppercase to match the Cashu protocol JSON. +#[allow(non_snake_case)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct CashuProof { pub id: String, diff --git a/nips/src/nip061.rs b/nips/src/nip061.rs index c320a64..3af419d 100644 --- a/nips/src/nip061.rs +++ b/nips/src/nip061.rs @@ -23,6 +23,9 @@ pub struct Nutzap { } /// A Nutzap proof. +/// +/// Field name `C` is uppercase to match the Cashu protocol JSON. +#[allow(non_snake_case)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct NutzapProof { pub id: String, diff --git a/relay/src/pool.rs b/relay/src/pool.rs index b7a8774..2ba6b98 100644 --- a/relay/src/pool.rs +++ b/relay/src/pool.rs @@ -20,10 +20,7 @@ use crate::ws::{NostrWsClient, WsState}; // ── Constants ─────────────────────────────────────────────────────────────── const MAX_RELAYS: usize = 32; -const MAX_SUBSCRIPTIONS: usize = 64; const MAX_SEEN_EVENTS: usize = 1000; -const DEFAULT_TIMEOUT_MS: u64 = 5000; -const PING_INTERVAL_SECS: u64 = 59; // ── Types ─────────────────────────────────────────────────────────────────── @@ -142,15 +139,12 @@ impl RelayEntry { /// A pool subscription. pub struct PoolSubscription { id: SubscriptionId, - filters: Vec, relay_urls: Vec, on_event: Option, on_eose: Option, close_on_eose: bool, enable_deduplication: bool, result_mode: EoseResultMode, - relay_timeout_seconds: u64, - eose_timeout_seconds: u64, /// Track which relays have sent EOSE. eose_received: Arc>>, /// Track received event IDs for deduplication. @@ -164,27 +158,21 @@ pub struct PoolSubscription { impl PoolSubscription { fn new( id: SubscriptionId, - filters: Vec, relay_urls: Vec, on_event: Option, on_eose: Option, close_on_eose: bool, enable_deduplication: bool, result_mode: EoseResultMode, - relay_timeout_seconds: u64, - eose_timeout_seconds: u64, ) -> Self { PoolSubscription { id, - filters, relay_urls, on_event, on_eose, close_on_eose, enable_deduplication, result_mode, - relay_timeout_seconds, - eose_timeout_seconds, eose_received: Arc::new(Mutex::new(Vec::new())), seen_events: Arc::new(Mutex::new(Vec::new())), events: Arc::new(Mutex::new(Vec::new())), @@ -407,22 +395,17 @@ impl RelayPool { close_on_eose: bool, enable_deduplication: bool, result_mode: EoseResultMode, - relay_timeout_seconds: u64, - eose_timeout_seconds: u64, ) -> NostrResult> { let id = SubscriptionId::new(uuid::Uuid::new_v4().to_string()); let subscription = Arc::new(PoolSubscription::new( id.clone(), - filters.clone(), relay_urls.to_vec(), on_event, on_eose, close_on_eose, enable_deduplication, result_mode, - relay_timeout_seconds, - eose_timeout_seconds, )); // Register the subscription @@ -903,8 +886,6 @@ impl RelayPool { true, // close_on_eose true, // enable_deduplication EoseResultMode::FullSet, - 5, // relay_timeout_seconds - 10, // eose_timeout_seconds ) .await?; @@ -1053,8 +1034,6 @@ mod tests { true, true, EoseResultMode::FullSet, - 5, - 10, ) .await; diff --git a/services/src/cashu.rs b/services/src/cashu.rs index 42f3c01..3b9efb7 100644 --- a/services/src/cashu.rs +++ b/services/src/cashu.rs @@ -63,6 +63,9 @@ pub struct CashuMeltQuote { } /// A blinded message submitted to the mint for minting or swapping. +/// +/// Field name `B` is uppercase to match the Cashu protocol JSON. +#[allow(non_snake_case)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct BlindedMessage { pub amount: u64, @@ -71,6 +74,9 @@ pub struct BlindedMessage { } /// A blind signature returned by the mint. +/// +/// Field name `C` is uppercase to match the Cashu protocol JSON. +#[allow(non_snake_case)] #[derive(Debug, Clone, serde::Deserialize)] pub struct BlindSignature { pub amount: u64, @@ -85,6 +91,9 @@ pub struct MintResponse { } /// A Cashu proof (token) that can be spent or swapped. +/// +/// Field name `C` is uppercase to match the Cashu protocol JSON. +#[allow(non_snake_case)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Proof { pub amount: u64,