diff --git a/CHANGELOG.md b/CHANGELOG.md index eb2cd5c..0e2c872 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **Signer** (CRITICAL): `LocalSigner::nip04_encrypt/decrypt` still used a + private copy of the pre-0.1.1 NIP-04 code (SHA-256-hashed ECDH key, + `base64(iv||ct)` layout), so it could neither read nor produce standard + NIP-04 DMs. NIP-04 now lives in one place (`nostr_core::crypto::nip04`); + `nostr_nips::nip004` re-exports it and `LocalSigner` delegates to it. +- **NIP-44**: Empty plaintext is now rejected on encrypt and on decrypt + (length prefix 0), per spec. Previously we emitted payloads `nak` rejects + with "invalid padding". Payload length is now bounded to the spec range + (99..=65603 bytes). +- **NIP-42** (security): `verify_auth_event` now recomputes the event id. + Previously tags/content could be altered after signing and the event still + verified, because only the signature over the *claimed* id was checked. + +### Tests +- Added `nak`-generated known-answer vectors for NIP-04, NIP-44 and the + `LocalSigner`; the integration NIP-04 "known_vectors" test (which only + round-tripped) now checks real ciphertext byte-for-byte. +- Added official NIP-06 spec vectors, RFC 5869 HKDF and real HMAC-SHA512 / + PBKDF2 values (these previously asserted only output length); fixed + mistyped BIP-32 chain codes in test comments. +- Added negative tests: tampered MAC/ciphertext, malformed NIP-04 input, + NIP-42 tampered body / bad sig / wrong relay / stale timestamp. + ## [0.1.2] - 2026-09-27 ## [0.1.1] - 2026-09-24 diff --git a/Cargo.lock b/Cargo.lock index cd667fe..8a84bc5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -385,7 +385,7 @@ dependencies = [ [[package]] name = "event-signer" -version = "0.1.1" +version = "0.1.2" dependencies = [ "nostr-core", "nostr-nips", @@ -868,7 +868,7 @@ dependencies = [ [[package]] name = "integration-tests" -version = "0.1.1" +version = "0.1.2" dependencies = [ "nostr-core", "nostr-nips", @@ -910,7 +910,7 @@ dependencies = [ [[package]] name = "keypair-generator" -version = "0.1.1" +version = "0.1.2" dependencies = [ "nostr-core", ] @@ -1041,12 +1041,13 @@ dependencies = [ [[package]] name = "nostr-core" -version = "0.1.1" +version = "0.1.2" dependencies = [ "aes", "base64", "bech32", "block-modes", + "cbc", "chacha20", "chacha20poly1305", "chrono", @@ -1064,7 +1065,7 @@ dependencies = [ [[package]] name = "nostr-core-umbrella" -version = "0.1.1" +version = "0.1.2" dependencies = [ "nostr-core", "nostr-nips", @@ -1075,7 +1076,7 @@ dependencies = [ [[package]] name = "nostr-nips" -version = "0.1.1" +version = "0.1.2" dependencies = [ "aes", "block-modes", @@ -1097,7 +1098,7 @@ dependencies = [ [[package]] name = "nostr-relay" -version = "0.1.1" +version = "0.1.2" dependencies = [ "futures-util", "nostr-core", @@ -1115,7 +1116,7 @@ dependencies = [ [[package]] name = "nostr-services" -version = "0.1.1" +version = "0.1.2" dependencies = [ "nostr-core", "nostr-relay", @@ -1130,10 +1131,8 @@ dependencies = [ [[package]] name = "nostr-signer" -version = "0.1.1" +version = "0.1.2" dependencies = [ - "aes", - "cbc", "hex", "libc", "nostr-core", @@ -1141,7 +1140,6 @@ dependencies = [ "serde", "serde_json", "serialport", - "sha2", "thiserror 2.0.20", "tokio", "tracing", diff --git a/core/Cargo.toml b/core/Cargo.toml index d2abd8c..f303223 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -12,6 +12,7 @@ hex.workspace = true chacha20poly1305.workspace = true chacha20.workspace = true aes.workspace = true +cbc = "0.1" block-modes.workspace = true rand.workspace = true zeroize.workspace = true diff --git a/core/src/crypto/hmac.rs b/core/src/crypto/hmac.rs index 9977cdb..a9c7a7b 100644 --- a/core/src/crypto/hmac.rs +++ b/core/src/crypto/hmac.rs @@ -124,31 +124,45 @@ mod tests { assert_eq!(hex::encode(result), expected); } + // NOTE: these previously asserted only output *length*, and the HMAC-512 + // "expected" constant was fabricated and never compared. Values below are + // cross-checked against Python's hashlib/hmac. + #[test] fn test_hmac_sha512() { - let key = b"key"; - let data = b"The quick brown fox jumps over the lazy dog"; - let result = hmac_sha512(key, data); - let expected = "b42af09057bac1e2d41708e48a902e3b9967c217f3f7b3e3c5f9d9b0c8c7d5e6\ - 8c3c7d5e6b42af09057bac1e2d41708e48a902e3b9967c217f3f7b3e3c5f9d9b0"; - // Just verify it's 64 bytes - assert_eq!(result.len(), 64); + let result = hmac_sha512(b"key", b"The quick brown fox jumps over the lazy dog"); + assert_eq!( + hex::encode(result), + "b42af09057bac1e2d41708e48a902e09b5ff7f12ab428a4fe86653c73dd248fb\ + 82f948a549f7b791a5b41915ee4d1ec3935357e4e2317250d0372afa2ebeeb3a" + ); + } + + /// RFC 5869, Test Case 1. + #[test] + fn test_hkdf_rfc5869_case1() { + let ikm = [0x0bu8; 22]; + let salt: Vec = (0x00..=0x0c).collect(); + let info: Vec = (0xf0..=0xf9).collect(); + let prk = hkdf_extract(&salt, &ikm); + assert_eq!( + hex::encode(prk), + "077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5" + ); + let okm = hkdf(&salt, &ikm, &info, 42); + assert_eq!( + hex::encode(okm), + "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865" + ); } #[test] - fn test_hkdf() { - let salt = b"salt"; - let ikm = b"input key material"; - let info = b"some context"; - let okm = hkdf(salt, ikm, info, 42); - assert_eq!(okm.len(), 42); - } - - #[test] - fn test_pbkdf2() { - let password = b"password"; - let salt = b"salt"; - let result = pbkdf2_hmac_sha512(password, salt, 1, 64); - assert_eq!(result.len(), 64); + fn test_pbkdf2_hmac_sha512_known_answer() { + let result = pbkdf2_hmac_sha512(b"password", b"salt", 1, 64); + assert_eq!( + hex::encode(result), + "867f70cf1ade02cff3752599a3a53dc4af34c7a669815ae5d513554e1c8cf252\ + c02d470a285a0501bad999bfe943c08f050235d7d68b1da55e63f73b60a57fce" + ); } } diff --git a/core/src/crypto/keys.rs b/core/src/crypto/keys.rs index 2569b00..d246f7a 100644 --- a/core/src/crypto/keys.rs +++ b/core/src/crypto/keys.rs @@ -73,11 +73,16 @@ pub fn schnorr_verify( } } -/// Compute ECDH shared secret between a secret key and a public key. +/// libsecp256k1's default ECDH: `SHA256(compressed(secret_key * public_key))`. /// -/// Returns both possible shared secrets (one for even parity, one for odd). -/// The caller should try both to determine which one works for their use case. -/// In Nostr, both parties must agree on the same parity for ECDH to work. +/// **This is NOT the key used by NIP-04 or NIP-44.** Both of those use the +/// raw, unhashed X coordinate — see [`ecdh_shared_secret_raw_x`]. Using this +/// function for either NIP produces ciphertext no other client can read +/// (this was the root cause of the 0.1.1 and 0.1.2 fixes). +/// +/// The x-only public key is lifted with even parity. Note that the hashed +/// output *does* depend on parity (the compressed prefix byte is hashed), so +/// the two sides of a conversation may disagree unless both use even-Y keys. pub fn ecdh_shared_secret( secret_key: &SecretKey, public_key: &PublicKey, @@ -94,25 +99,18 @@ pub fn ecdh_shared_secret( let mut result_even = [0u8; 32]; result_even.copy_from_slice(&shared_even.secret_bytes()); - // Try odd parity - let pk_odd = secp256k1::PublicKey::from_x_only_public_key(xonly, secp256k1::Parity::Odd); - let shared_odd = secp256k1::ecdh::SharedSecret::new(&pk_odd, &sk); - let mut result_odd = [0u8; 32]; - result_odd.copy_from_slice(&shared_odd.secret_bytes()); - - // Return even parity by default (most common in Nostr) - // The caller can use ecdh_shared_secret_both() if they need to try both 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. +/// This is the shared-secret derivation for **both** NIP-04 (used directly +/// as the AES-256 key) and NIP-44 (used as HKDF input keying material). +/// The X coordinate of `k * P` is identical for `P` and `-P`, so this is +/// parity-independent and always symmetric between the two parties. +/// +/// Do not confuse with [`ecdh_shared_secret`] / [`ecdh_shared_secret_both`], +/// which return libsecp256k1's *hashed* output and are not used by any NIP. pub fn ecdh_shared_secret_raw_x( secret_key: &SecretKey, public_key: &PublicKey, @@ -123,9 +121,7 @@ pub fn ecdh_shared_secret_raw_x( 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). + // Parity choice is irrelevant: x(k*P) == x(k*(-P)). 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, @@ -136,8 +132,10 @@ pub fn ecdh_shared_secret_raw_x( Ok(result) } -/// Compute both possible ECDH shared secrets (even and odd parity). +/// libsecp256k1 hashed ECDH for both parity liftings of an x-only key. /// Returns (even_parity_result, odd_parity_result). +/// +/// Not used by NIP-04/NIP-44 — see [`ecdh_shared_secret_raw_x`]. pub fn ecdh_shared_secret_both( secret_key: &SecretKey, public_key: &PublicKey, @@ -184,6 +182,45 @@ mod tests { assert!(!schnorr_verify(&pk, &wrong_msg, &sig).unwrap()); } + /// Pins the raw-X derivation against the official NIP-44 v2 + /// `get_conversation_key` vector (sec1=…01, pub2=pub(…02)): raw-X must + /// equal x(2·G·1) = x(2G), a well-known constant. + #[test] + fn test_ecdh_raw_x_known_answer() { + let sk = SecretKey::from_bytes({ + let mut b = [0u8; 32]; + b[31] = 1; + b + }); + let mut two = [0u8; 32]; + two[31] = 2; + let pk2 = public_key_from_secret_key(&SecretKey::from_bytes(two)).unwrap(); + let x = ecdh_shared_secret_raw_x(&sk, &pk2).unwrap(); + // x-coordinate of 2G on secp256k1. + assert_eq!( + hex::encode(x), + "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5" + ); + assert_eq!(pk2.to_hex(), hex::encode(x), "x-only pubkey of 2 is x(2G)"); + } + + /// Raw-X ECDH must agree for both parties, including peers whose real + /// public key has odd Y (sk=3 → 3G has odd Y). + #[test] + fn test_ecdh_raw_x_symmetric_with_odd_y_peer() { + let mut three = [0u8; 32]; + three[31] = 3; + let sk_odd = SecretKey::from_bytes(three); + let pk_odd = public_key_from_secret_key(&sk_odd).unwrap(); + for _ in 0..8 { + let (sk, pk) = generate_keypair(); + assert_eq!( + ecdh_shared_secret_raw_x(&sk, &pk_odd).unwrap(), + ecdh_shared_secret_raw_x(&sk_odd, &pk).unwrap() + ); + } + } + #[test] fn test_ecdh() { let (sk_a, pk_a) = generate_keypair(); diff --git a/core/src/crypto/mod.rs b/core/src/crypto/mod.rs index 9a28566..eca00b4 100644 --- a/core/src/crypto/mod.rs +++ b/core/src/crypto/mod.rs @@ -3,13 +3,14 @@ //! This module provides: //! - Key generation, Schnorr signing/verification (secp256k1) //! - SHA-256 hashing (streaming and single-shot) -//! - HMAC-SHA256/512 -//! - ChaCha20-Poly1305 AEAD (NIP-44) -//! - AES-256-CBC (NIP-04 legacy) +//! - HMAC-SHA256/512, HKDF, PBKDF2 +//! - NIP-44 v2 (ChaCha20 + HMAC-SHA256) +//! - NIP-04 legacy DMs (AES-256-CBC) pub mod keys; pub mod sha256; pub mod hmac; +pub mod nip04; pub mod nip44; pub use keys::*; diff --git a/core/src/crypto/nip04.rs b/core/src/crypto/nip04.rs new file mode 100644 index 0000000..4174096 --- /dev/null +++ b/core/src/crypto/nip04.rs @@ -0,0 +1,234 @@ +//! NIP-04: Legacy Encrypted Direct Messages (AES-256-CBC). +//! +//! This is the **single** NIP-04 implementation in the workspace. Both +//! `nostr_nips::nip004` and `nostr_signer::local::LocalSigner` delegate here; +//! do not re-implement the scheme elsewhere (a stale duplicate in the local +//! signer is exactly how the pre-0.1.1 bug survived the 0.1.1 fix). +//! +//! 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 aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit}; +use cbc::{Decryptor, Encryptor}; +use rand::rngs::OsRng; +use rand::RngCore; + +use crate::crypto::keys::ecdh_shared_secret_raw_x; +use crate::error::NostrError; +use crate::types::{PublicKey, SecretKey}; +use crate::NostrResult; + +type Aes256CbcEnc = Encryptor; +type Aes256CbcDec = Decryptor; + +const IV_SIZE: usize = 16; + +/// Separator between the base64 ciphertext and the base64 IV, per NIP-04. +const IV_SEPARATOR: &str = "?iv="; + +/// 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 mut iv = [0u8; IV_SIZE]; + OsRng.fill_bytes(&mut iv); + nip04_encrypt_with_iv(sender_sk, recipient_pk, plaintext, &iv) +} + +/// Encrypt with a caller-supplied IV. Intended for reproducible known-answer +/// tests only — production callers must use [`nip04_encrypt`]. +pub fn nip04_encrypt_with_iv( + sender_sk: &SecretKey, + recipient_pk: &PublicKey, + plaintext: &str, + iv: &[u8; IV_SIZE], +) -> NostrResult { + // 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 buf = vec![0u8; plaintext.len() + 16]; + buf[..plaintext.len()].copy_from_slice(plaintext.as_bytes()); + + let cipher = + Aes256CbcEnc::new_from_slices(&key, iv).map_err(|_| NostrError::Nip04InvalidFormat)?; + + let encrypted = cipher + .encrypt_padded_mut::(&mut buf, plaintext.len()) + .map_err(|_| NostrError::Nip04InvalidFormat)?; + + Ok(format!( + "{}{}{}", + crate::util::base64_encode(encrypted), + IV_SEPARATOR, + crate::util::base64_encode(iv) + )) +} + +/// 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 (ct_b64, iv_b64) = ciphertext_b64 + .split_once(IV_SEPARATOR) + .ok_or(NostrError::Nip04InvalidFormat)?; + + let encrypted = + crate::util::base64_decode(ct_b64).map_err(|_| NostrError::Nip04InvalidFormat)?; + let iv = crate::util::base64_decode(iv_b64).map_err(|_| NostrError::Nip04InvalidFormat)?; + + if iv.len() != IV_SIZE || encrypted.is_empty() || encrypted.len() % 16 != 0 { + return Err(NostrError::Nip04InvalidFormat); + } + + // Raw X coordinate of the shared point, unhashed — the NIP-04 AES key. + let key = ecdh_shared_secret_raw_x(recipient_sk, sender_pk)?; + + let cipher = + Aes256CbcDec::new_from_slices(&key, &iv).map_err(|_| NostrError::Nip04InvalidFormat)?; + + 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)] +mod tests { + use super::*; + use crate::crypto::keys::{ecdh_shared_secret, generate_keypair}; + + // Fixed test keys (NOT used for anything real). + const TEST_SK: [u8; 32] = [0x11; 32]; + // `nak key public 1111...11` + const TEST_PEER_PK: &str = "4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"; + + // Generated with: + // nak encrypt --nip04 --sec 1111...11 -p 4f355bdc...71aa "nak vector" + const NAK_PLAINTEXT: &str = "nak vector"; + const NAK_CT_B64: &str = "H3hKxnfd4MX/a9Rl0cvmFg=="; + const NAK_IV_B64: &str = "y8Ngg9dcK8XinwERJpzNsA=="; + const NAK_CIPHERTEXT: &str = "H3hKxnfd4MX/a9Rl0cvmFg==?iv=y8Ngg9dcK8XinwERJpzNsA=="; + + fn test_keys() -> (SecretKey, PublicKey) { + (SecretKey::from_bytes(TEST_SK), TEST_PEER_PK.parse().unwrap()) + } + + #[test] + fn test_nip04_encrypt_decrypt() { + let (sk_a, pk_a) = generate_keypair(); + let (sk_b, pk_b) = generate_keypair(); + + let plaintext = "Hello, Nostr! This is a secret DM."; + let encrypted = nip04_encrypt(&sk_a, &pk_b, plaintext).unwrap(); + let decrypted = nip04_decrypt(&sk_b, &pk_a, &encrypted).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn test_nip04_wrong_key() { + let (sk_a, pk_a) = generate_keypair(); + let (_sk_b, pk_b) = generate_keypair(); + let (sk_c, _) = generate_keypair(); + + let encrypted = nip04_encrypt(&sk_a, &pk_b, "secret").unwrap(); + // Third party using the correct sender pubkey must not be able to read it. + assert!(nip04_decrypt(&sk_c, &pk_a, &encrypted).is_err()); + } + + #[test] + fn test_nip04_standard_format() { + let (sk_a, _) = generate_keypair(); + let (_, pk_b) = generate_keypair(); + + let encrypted = nip04_encrypt(&sk_a, &pk_b, "format check").unwrap(); + let (ct_b64, iv_b64) = encrypted + .split_once(IV_SEPARATOR) + .expect("output must contain the ?iv= separator"); + assert_eq!(crate::util::base64_decode(iv_b64).unwrap().len(), IV_SIZE); + let ct = crate::util::base64_decode(ct_b64).unwrap(); + assert_eq!(ct.len(), 16, "12-byte plaintext pads to exactly one block"); + } + + #[test] + fn test_nip04_rejects_legacy_packed_layout() { + let (sk, pk) = test_keys(); + // Legacy non-standard layout base64(IV || ct) — must be rejected. + let legacy = crate::util::base64_encode(&[0u8; 48]); + assert_eq!(nip04_decrypt(&sk, &pk, &legacy), Err(NostrError::Nip04InvalidFormat)); + } + + #[test] + fn test_nip04_rejects_malformed() { + let (sk, pk) = test_keys(); + for bad in [ + "?iv=y8Ngg9dcK8XinwERJpzNsA==", // empty ciphertext + "H3hKxnfd4MX/a9Rl0cvmFg==?iv=", // empty iv + "H3hKxnfd4MX/a9Rl0cvmFg==?iv=AAAA", // short iv + "H3hKxnfd4MX/a9Rl0cvm?iv=y8Ngg9dcK8XinwERJpzNsA==", // not a block multiple + "!!!!?iv=y8Ngg9dcK8XinwERJpzNsA==", // bad base64 + ] { + assert_eq!( + nip04_decrypt(&sk, &pk, bad), + Err(NostrError::Nip04InvalidFormat), + "input {:?}", + bad + ); + } + } + + // ── Known-answer vectors generated with `nak` (authoritative) ─────────── + + #[test] + fn test_nip04_nak_known_answer_decrypt() { + let (sk, peer) = test_keys(); + assert_eq!(nip04_decrypt(&sk, &peer, NAK_CIPHERTEXT).unwrap(), NAK_PLAINTEXT); + } + + #[test] + fn test_nip04_nak_known_answer_encrypt() { + // Pin the full public encrypt path byte-for-byte: with nak's IV, our + // output must be exactly nak's output. + let (sk, peer) = test_keys(); + let iv: [u8; 16] = crate::util::base64_decode(NAK_IV_B64).unwrap().try_into().unwrap(); + let out = nip04_encrypt_with_iv(&sk, &peer, NAK_PLAINTEXT, &iv).unwrap(); + assert_eq!(out, NAK_CIPHERTEXT); + assert!(out.starts_with(NAK_CT_B64)); + } + + #[test] + fn test_nip04_raw_x_key_symmetric_and_unhashed() { + let (sk_a, pk_a) = generate_keypair(); + let (sk_b, pk_b) = generate_keypair(); + + 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 libsecp256k1's + // hashed ECDH output (the pre-0.1.1 bug used the hashed value). + assert_ne!(key_ab, ecdh_shared_secret(&sk_a, &pk_b).unwrap()); + } +} diff --git a/core/src/crypto/nip44.rs b/core/src/crypto/nip44.rs index f37e548..f339dd2 100644 --- a/core/src/crypto/nip44.rs +++ b/core/src/crypto/nip44.rs @@ -38,8 +38,16 @@ const NONCE_SIZE: usize = 32; /// MAC size (32 bytes). const MAC_SIZE: usize = 32; -/// Minimum payload size: version(1) + nonce(32) + mac(32) + 1 byte ciphertext. -const MIN_PAYLOAD_SIZE: usize = 1 + NONCE_SIZE + MAC_SIZE + 1; +/// Minimum raw payload size per spec: version(1) + nonce(32) + +/// padded(2 + 32) + mac(32) = 99. +const MIN_PAYLOAD_SIZE: usize = 1 + NONCE_SIZE + 2 + 32 + MAC_SIZE; + +/// Maximum raw payload size per spec: version(1) + nonce(32) + +/// padded(2 + 65536) + mac(32) = 65603. +const MAX_PAYLOAD_SIZE: usize = 1 + NONCE_SIZE + 2 + 65536 + MAC_SIZE; + +/// Minimum plaintext size for NIP-44 (the spec forbids empty messages). +pub const MIN_PLAINTEXT_SIZE: usize = 1; /// Maximum plaintext size for NIP-44 (64 KiB - 1). pub const MAX_PLAINTEXT_SIZE: usize = 65535; @@ -64,7 +72,13 @@ pub fn calc_padded_len(unpadded_len: usize) -> usize { } /// Pad plaintext per NIP-44: `u16_be(len) || plaintext || zero padding`. +/// +/// The spec requires `1 <= len <= 65535`; empty plaintexts are rejected +/// (reference implementations refuse to encrypt or decrypt them). fn pad_plaintext(plaintext: &[u8]) -> NostrResult> { + if plaintext.len() < MIN_PLAINTEXT_SIZE { + return Err(NostrError::Nip44InvalidFormat); + } if plaintext.len() > MAX_PLAINTEXT_SIZE { return Err(NostrError::Nip44BufferTooSmall); } @@ -82,6 +96,9 @@ fn unpad_plaintext(padded: &[u8]) -> NostrResult> { return Err(NostrError::Nip44InvalidFormat); } let unpadded_len = ((padded[0] as usize) << 8) | (padded[1] as usize); + if unpadded_len < MIN_PLAINTEXT_SIZE { + return Err(NostrError::Nip44InvalidFormat); + } let expected = calc_padded_len(unpadded_len); if padded.len() != expected + 2 { return Err(NostrError::Nip44InvalidFormat); @@ -163,9 +180,6 @@ pub fn nip44_encrypt( recipient_pk: &PublicKey, plaintext: &[u8], ) -> NostrResult> { - if plaintext.len() > MAX_PLAINTEXT_SIZE { - return Err(NostrError::Nip44BufferTooSmall); - } let mut nonce = [0u8; NONCE_SIZE]; OsRng.fill_bytes(&mut nonce); nip44_encrypt_with_nonce(sender_sk, recipient_pk, plaintext, &nonce) @@ -179,7 +193,7 @@ pub fn nip44_decrypt( sender_pk: &PublicKey, payload: &[u8], ) -> NostrResult> { - if payload.len() < MIN_PAYLOAD_SIZE { + if payload.len() < MIN_PAYLOAD_SIZE || payload.len() > MAX_PAYLOAD_SIZE { return Err(NostrError::Nip44InvalidFormat); } if payload[0] != VERSION { @@ -381,14 +395,78 @@ mod tests { assert_eq!(decrypted, plaintext); } + /// Regression: the spec forbids empty plaintext. We used to encrypt it, + /// producing payloads that `nak` rejects with "invalid padding" — and our + /// own round-trip test asserted the non-compliant behaviour as correct. #[test] - fn test_roundtrip_empty() { + fn test_empty_plaintext_rejected() { + let (sk_a, _) = generate_keypair(); + let (_, pk_b) = generate_keypair(); + assert_eq!(nip44_encrypt(&sk_a, &pk_b, b""), Err(NostrError::Nip44InvalidFormat)); + } + + /// A correctly MAC'd payload whose padded length prefix is 0 must be + /// rejected on decrypt (spec: "invalid padding"). + #[test] + fn test_decrypt_rejects_zero_length_prefix() { + let sk1 = SecretKey::from_bytes([0x11; 32]); + let sk2 = SecretKey::from_bytes([0x22; 32]); + let pk1 = crate::crypto::keys::public_key_from_secret_key(&sk1).unwrap(); + let pk2 = crate::crypto::keys::public_key_from_secret_key(&sk2).unwrap(); + let nonce = [0x42u8; NONCE_SIZE]; + + // Hand-build a payload bypassing pad_plaintext: u16(0) || 32 zeros. + let ck = get_conversation_key(&sk1, &pk2).unwrap(); + let (ck_key, ck_nonce, hmac_key) = get_message_keys(&ck, &nonce); + let mut ct = vec![0u8; 2 + 32]; + ChaCha20::new(&ck_key.into(), &ck_nonce.into()).apply_keystream(&mut ct); + let mut aad = nonce.to_vec(); + aad.extend_from_slice(&ct); + let mac = hmac_sha256(&hmac_key, &aad); + let mut payload = vec![VERSION]; + payload.extend_from_slice(&nonce); + payload.extend_from_slice(&ct); + payload.extend_from_slice(&mac); + + assert_eq!(nip44_decrypt(&sk2, &pk1, &payload), Err(NostrError::Nip44InvalidFormat)); + } + + #[test] + fn test_decrypt_rejects_bad_lengths() { + let (sk, pk) = generate_keypair(); + assert!(nip44_decrypt(&sk, &pk, &[VERSION; MIN_PAYLOAD_SIZE - 1]).is_err()); + assert!(nip44_decrypt(&sk, &pk, &vec![VERSION; MAX_PAYLOAD_SIZE + 1]).is_err()); + } + + #[test] + fn test_decrypt_rejects_tampered_mac_and_ciphertext() { let (sk_a, pk_a) = generate_keypair(); let (sk_b, pk_b) = generate_keypair(); + let enc = nip44_encrypt(&sk_a, &pk_b, b"authenticated").unwrap(); + for idx in [1, 1 + NONCE_SIZE, enc.len() - 1] { + let mut t = enc.clone(); + t[idx] ^= 0x01; + assert_eq!( + nip44_decrypt(&sk_b, &pk_a, &t), + Err(NostrError::Nip44DecryptFailed), + "flip at {idx}" + ); + } + } - let encrypted = nip44_encrypt(&sk_a, &pk_b, b"").unwrap(); - let decrypted = nip44_decrypt(&sk_b, &pk_a, &encrypted).unwrap(); - assert_eq!(decrypted, b""); + // ── Interop vector generated with `nak` (independent implementation) ── + // nak encrypt --sec 2222...22 -p pub(1111...11) "hello from nak nip44" + #[test] + fn test_nak_known_answer_decrypt() { + let sk_a = SecretKey::from_bytes([0x11; 32]); + let pk_b: PublicKey = "466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f27" + .parse() + .unwrap(); + let raw = crate::util::base64_decode( + "AgKFHl+hGEe6OVzGumsGpkArIDajp6E/ovG8V62YHM1V5HMJUB4D+wYBvZq0ghOs0iDtZWCrWsny86xAKCMnc/pXI5lqk2P2x/Tu/FDSF/M9FyzuLSVYBSkjQ1FhvqeHisRl", + ) + .unwrap(); + assert_eq!(nip44_decrypt(&sk_a, &pk_b, &raw).unwrap(), b"hello from nak nip44"); } #[test] diff --git a/nips/src/nip004.rs b/nips/src/nip004.rs index 72f54f8..7875ffc 100644 --- a/nips/src/nip004.rs +++ b/nips/src/nip004.rs @@ -1,260 +1,26 @@ //! NIP-04: Legacy Encrypted Direct Messages (AES-256-CBC). //! -//! 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. +//! Re-exports the single workspace implementation in +//! [`nostr_core::crypto::nip04`], which carries the `nak` known-answer tests. +//! Keeping one implementation guarantees `nostr_nips` and +//! `nostr_signer::LocalSigner` can never drift apart again. -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_raw_x; -use nostr_core::error::NostrError; -use nostr_core::types::{PublicKey, SecretKey}; -use nostr_core::NostrResult; - -type Aes256CbcEnc = Encryptor; -type Aes256CbcDec = Decryptor; - -const IV_SIZE: usize = 16; - -/// Separator between the base64 ciphertext and the base64 IV, per NIP-04. -const IV_SEPARATOR: &str = "?iv="; - -/// 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 { - // 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); - - let mut buf = vec![0u8; plaintext.len() + 16 + 16]; - buf[..plaintext.len()].copy_from_slice(plaintext.as_bytes()); - - let cipher = Aes256CbcEnc::new_from_slices(&key, &iv) - .map_err(|_| NostrError::Nip04InvalidFormat)?; - - let encrypted = cipher - .encrypt_padded_mut::(&mut buf, plaintext.len()) - .map_err(|_| NostrError::Nip04InvalidFormat)?; - - Ok(format!( - "{}{}{}", - nostr_core::util::base64_encode(encrypted), - IV_SEPARATOR, - nostr_core::util::base64_encode(&iv) - )) -} - -/// 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 (ct_b64, iv_b64) = ciphertext_b64 - .split_once(IV_SEPARATOR) - .ok_or(NostrError::Nip04InvalidFormat)?; - - let encrypted = nostr_core::util::base64_decode(ct_b64)?; - let iv = nostr_core::util::base64_decode(iv_b64)?; - - if iv.len() != IV_SIZE || encrypted.len() < 16 { - return Err(NostrError::Nip04InvalidFormat); - } - - // 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)?; - - let cipher = Aes256CbcDec::new_from_slices(&key, &iv) - .map_err(|_| NostrError::Nip04InvalidFormat)?; - - 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) -} +pub use nostr_core::crypto::nip04::{nip04_decrypt, nip04_encrypt, nip04_encrypt_with_iv}; #[cfg(test)] mod tests { use super::*; - use nostr_core::crypto::keys::generate_keypair; + use nostr_core::types::{PublicKey, SecretKey}; + // Smoke test that the re-export is the interoperable implementation. + // `nak encrypt --nip04 --sec 1111...11 -p 4f355bdc...71aa "nak vector"` #[test] - fn test_nip04_encrypt_decrypt() { - let (sk_a, pk_a) = generate_keypair(); - let (sk_b, pk_b) = generate_keypair(); - - let plaintext = "Hello, Nostr! This is a secret DM."; - let encrypted = nip04_encrypt(&sk_a, &pk_b, plaintext).unwrap(); - let decrypted = nip04_decrypt(&sk_b, &pk_a, &encrypted).unwrap(); - assert_eq!(decrypted, plaintext); - } - - #[test] - fn test_nip04_wrong_key() { - 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()) + fn test_reexport_decrypts_nak_vector() { + let sk = SecretKey::from_bytes([0x11; 32]); + let pk: PublicKey = "4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa" + .parse() .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); + let out = nip04_decrypt(&sk, &pk, "H3hKxnfd4MX/a9Rl0cvmFg==?iv=y8Ngg9dcK8XinwERJpzNsA=="); + assert_eq!(out.unwrap(), "nak vector"); } } diff --git a/nips/src/nip006.rs b/nips/src/nip006.rs index bef75ce..8cbd64f 100644 --- a/nips/src/nip006.rs +++ b/nips/src/nip006.rs @@ -617,7 +617,7 @@ mod tests { // master private key = e8f32e723decf4051aefac8e2c93c9c5b214313817cdb01a1494b917c8436b35 // master chain code = 873dff81c02f525623fd1fe5167eac3a55a049de3d314bb42ee227ffed37d508 // m/0' private key = edb2e14f9ee77d26dd93b4ecede8d16ed408ce149b6cd80b0715a2d911a0afea - // m/0' chain code = 47fdacbd0f1097043b78c63e20c34ef4ed9a111d980047ad16282c7ae6236141 + // m/0' chain code = 47fdacbd0f1097043b78c63c20c34ef4ed9a111d980047ad16282c7ae6236141 #[test] fn test_bip32_derive_child_hardened_vector() { let mut parent_key = [0u8; 32]; @@ -647,9 +647,9 @@ mod tests { // // BIP-32 test vector 1, deriving m/0'/1 from m/0': // m/0' private key = edb2e14f9ee77d26dd93b4ecede8d16ed408ce149b6cd80b0715a2d911a0afea - // m/0' chain code = 47fdacbd0f1097043b78c63e20c34ef4ed9a111d980047ad16282c7ae6236141 + // m/0' chain code = 47fdacbd0f1097043b78c63c20c34ef4ed9a111d980047ad16282c7ae6236141 // m/0'/1 private key= 3c6cb8d0f6a264c91ea8b5030fadaa8e538b020f0a387421a12de9319dc93368 - // m/0'/1 chain code = 2a7857631386ba23dacac3412ddca3f0da4b55a1d6e0231fcb4d8290c9421327 + // m/0'/1 chain code = 2a7857631386ba23dacac34180dd1983734e444fdbf774041578e9b6adb37c19 #[test] fn test_bip32_derive_child_nonhardened_vector() { let mut parent_key = [0u8; 32]; @@ -764,6 +764,32 @@ mod tests { ); } + /// Official NIP-06 specification test vectors + /// (https://github.com/nostr-protocol/nips/blob/master/06.md). Unlike the + /// self-generated pinned vector above, these come from the spec itself. + #[test] + fn test_nip06_official_spec_vectors() { + let vectors = [ + ( + "leader monkey parrot ring guide accident before fence cannon height naive bean", + "7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a", + "17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917", + ), + ( + "what bleak badge arrange retreat wolf trade produce cricket blur garlic valid proud rude strong choose busy staff weather area salt hollow arm fade", + "c15d739894c81a2fcfd3a2df85a0d2c0dbc47a280d092799f144d73d7ae78add", + "d41b22899549e1f3d335a31002cfd382174006e166d3e658e3a5eecdb6463573", + ), + ]; + for (mnemonic, sk_hex, pk_hex) in vectors { + assert!(mnemonic_validate(mnemonic), "spec mnemonic must validate"); + let seed = mnemonic_to_seed(mnemonic, ""); + let (sk, pk) = keypair_from_seed(&seed).unwrap(); + assert_eq!(sk.to_hex(), sk_hex, "secret key for {mnemonic:?}"); + assert_eq!(pk.to_hex(), pk_hex, "public key for {mnemonic:?}"); + } + } + // SLIP-0010 ed25519 pinned vector for the same mnemonic. // ed25519 secret key = dc03109ee8e06e18cee872b30efece39283e898c3355739a89fce2de6aa80cb1 // ed25519 public key = 73b263ebc50f4ad169f18d1d618489cb8e6dd29fbfed8d5f3dc0e4dc32931eb6 diff --git a/nips/src/nip042.rs b/nips/src/nip042.rs index 2cef223..a0fba07 100644 --- a/nips/src/nip042.rs +++ b/nips/src/nip042.rs @@ -76,6 +76,11 @@ pub fn verify_auth_event( return Err(NostrError::Nip42TimeTolerance); } + // The id MUST be recomputed from the event body. Verifying the signature + // over the claimed `event.id` alone lets an attacker alter tags/content + // (e.g. swap the challenge) while keeping a valid-looking signature. + crate::nip001::verify_event_id(event).map_err(|_| NostrError::Nip42AuthEventInvalid)?; + let event_id = event.id.as_ref().ok_or(NostrError::Nip42AuthEventInvalid)?; let sig = event.sig.as_ref().ok_or(NostrError::Nip42AuthEventInvalid)?; schnorr_verify(&event.pubkey, event_id.as_bytes(), sig) @@ -128,6 +133,49 @@ mod tests { assert!(result.is_err()); } + fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + } + + /// Regression: tags/content altered after signing must be rejected even + /// though `event.sig` is still a valid signature over the stale `event.id`. + #[test] + fn test_verify_auth_event_rejects_tampered_body() { + let (sk, _) = generate_keypair(); + let mut event = create_auth_event("c", "wss://relay.example.com", &sk, now()).unwrap(); + event.tags.push(Tag::with_value("injected", "x")); + assert_eq!( + verify_auth_event(&event, "c", "wss://relay.example.com", 600), + Err(NostrError::Nip42AuthEventInvalid) + ); + } + + #[test] + fn test_verify_auth_event_rejects_bad_signature() { + let (sk, _) = generate_keypair(); + let mut event = create_auth_event("c", "wss://relay.example.com", &sk, now()).unwrap(); + event.sig = Some(nostr_core::types::Signature::from_bytes([0xab; 64])); + assert_ne!(verify_auth_event(&event, "c", "wss://relay.example.com", 600), Ok(true)); + } + + #[test] + fn test_verify_auth_event_rejects_wrong_relay_and_stale() { + let (sk, _) = generate_keypair(); + let event = create_auth_event("c", "wss://relay.example.com", &sk, now()).unwrap(); + assert_eq!( + verify_auth_event(&event, "c", "wss://other.example.com", 600), + Err(NostrError::Nip42UrlMismatch) + ); + let old = create_auth_event("c", "wss://relay.example.com", &sk, now() - 7200).unwrap(); + assert_eq!( + verify_auth_event(&old, "c", "wss://relay.example.com", 600), + Err(NostrError::Nip42TimeTolerance) + ); + } + #[test] fn test_generate_challenge() { let c1 = generate_challenge(); diff --git a/plans/incomplete_implementations_audit.md b/plans/incomplete_implementations_audit.md index fcf336f..faa19a7 100644 --- a/plans/incomplete_implementations_audit.md +++ b/plans/incomplete_implementations_audit.md @@ -141,7 +141,7 @@ The Rust port implements **all of the above**: |----------|---------------|--------| | NIP-01 | [`nips/src/nip001.rs`](nips/src/nip001.rs) | ✅ **Full** — event creation, signing, validation | | NIP-03 | [`nips/src/nip003.rs`](nips/src/nip003.rs) | ✅ **Full** — OTS parsing, proof execution, verification | -| NIP-04 | [`nips/src/nip004.rs`](nips/src/nip004.rs) | ✅ **Full** — AES-256-CBC encrypt/decrypt | +| NIP-04 | [`core/src/crypto/nip04.rs`](core/src/crypto/nip04.rs) (re-exported by `nips/src/nip004.rs`) | ✅ **Full** — AES-256-CBC, raw-X ECDH, `ct?iv=iv` | | NIP-05 | [`nips/src/nip005.rs`](nips/src/nip005.rs) | ✅ **Full** — DNS identifier verification | | NIP-06 | [`nips/src/nip006.rs`](nips/src/nip006.rs) | ✅ **Full** — BIP39, BIP-32, SLIP-0010 | | NIP-11 | [`nips/src/nip011.rs`](nips/src/nip011.rs) | ✅ **Full** — Relay info document | @@ -151,7 +151,7 @@ The Rust port implements **all of the above**: | NIP-21 | [`nips/src/nip021.rs`](nips/src/nip021.rs) | ✅ **Full** — nostr: URI parsing | | NIP-34 | [`nips/src/nip034.rs`](nips/src/nip034.rs) | ✅ **Full** — Git events (repo, patch, PR, issue) | | NIP-42 | [`nips/src/nip042.rs`](nips/src/nip042.rs) | ✅ **Full** — Auth events, challenge generation | -| NIP-44 | [`core/src/crypto/nip44.rs`](core/src/crypto/nip44.rs) | ✅ **Full** — ChaCha20-Poly1305 AEAD | +| NIP-44 | [`core/src/crypto/nip44.rs`](core/src/crypto/nip44.rs) | ✅ **Full** — v2: ChaCha20 + HMAC-SHA256, padding | | NIP-46 | [`nips/src/nip046.rs`](nips/src/nip046.rs) | ✅ **Full** — bunker:// and nostrconnect:// URL parsing, request/response events | | NIP-59 | [`nips/src/nip059.rs`](nips/src/nip059.rs) | ✅ **Full** — Gift wrap, seal, rumor creation/unwrapping | | NIP-60 | [`nips/src/nip060.rs`](nips/src/nip060.rs) | ✅ **Full** — Wallet/token events | diff --git a/signer/Cargo.toml b/signer/Cargo.toml index 96377d1..09a28da 100644 --- a/signer/Cargo.toml +++ b/signer/Cargo.toml @@ -11,9 +11,6 @@ serde_json.workspace = true tracing.workspace = true thiserror.workspace = true tokio = { workspace = true, features = ["io-util", "net", "sync"] } -cbc = "0.1" -aes.workspace = true -sha2.workspace = true hex.workspace = true rand.workspace = true serialport.workspace = true diff --git a/signer/src/local.rs b/signer/src/local.rs index 71214cf..0ac70d6 100644 --- a/signer/src/local.rs +++ b/signer/src/local.rs @@ -3,8 +3,7 @@ use std::sync::Mutex; use nostr_core::crypto::keys::{public_key_from_secret_key, schnorr_sign}; -use nostr_core::crypto::nip44; -use nostr_core::error::NostrError; +use nostr_core::crypto::{nip04, nip44}; use nostr_core::types::{Event, PublicKey, SecretKey}; use nostr_core::NostrResult; @@ -55,11 +54,11 @@ impl NostrSigner for LocalSigner { } fn nip04_encrypt(&self, peer_pubkey: &PublicKey, plaintext: &str) -> NostrResult { - nip04_encrypt_impl(&self.secret_key, peer_pubkey, plaintext) + nip04::nip04_encrypt(&self.secret_key, peer_pubkey, plaintext) } fn nip04_decrypt(&self, peer_pubkey: &PublicKey, ciphertext: &str) -> NostrResult { - nip04_decrypt_impl(&self.secret_key, peer_pubkey, ciphertext) + nip04::nip04_decrypt(&self.secret_key, peer_pubkey, ciphertext) } fn nip44_encrypt(&self, peer_pubkey: &PublicKey, plaintext: &str) -> NostrResult> { @@ -88,87 +87,52 @@ impl NostrSigner for LocalSigner { } } -// ── NIP-04 implementation (local) ─────────────────────────────────────────── - -fn nip04_encrypt_impl( - sender_sk: &SecretKey, - recipient_pk: &PublicKey, - plaintext: &str, -) -> NostrResult { - use cbc::Encryptor; - use aes::cipher::{block_padding::Pkcs7, BlockEncryptMut, KeyIvInit}; - use rand::rngs::OsRng; - use rand::RngCore; - - use nostr_core::crypto::keys::ecdh_shared_secret_both; - - let (shared_even, _) = ecdh_shared_secret_both(sender_sk, recipient_pk)?; - let key = derive_nip04_key(&shared_even); - - let mut iv = [0u8; 16]; - OsRng.fill_bytes(&mut iv); - - let mut buf = vec![0u8; plaintext.len() + 16 + 16]; - buf[..plaintext.len()].copy_from_slice(plaintext.as_bytes()); - - let cipher = Encryptor::::new_from_slices(&key, &iv) - .map_err(|_| NostrError::Nip04InvalidFormat)?; - let encrypted = cipher - .encrypt_padded_mut::(&mut buf, plaintext.len()) - .map_err(|_| NostrError::Nip04InvalidFormat)?; - - let mut result = Vec::with_capacity(16 + encrypted.len()); - result.extend_from_slice(&iv); - result.extend_from_slice(encrypted); - Ok(nostr_core::util::base64_encode(&result)) -} - -fn nip04_decrypt_impl( - recipient_sk: &SecretKey, - sender_pk: &PublicKey, - ciphertext_b64: &str, -) -> NostrResult { - use cbc::Decryptor; - use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit}; - - use nostr_core::crypto::keys::ecdh_shared_secret_both; - - let (shared_even, shared_odd) = ecdh_shared_secret_both(recipient_sk, sender_pk)?; - let decoded = nostr_core::util::base64_decode(ciphertext_b64)?; - if decoded.len() < 16 + 16 { - return Err(NostrError::Nip04InvalidFormat); - } - let iv = &decoded[..16]; - let encrypted = &decoded[16..]; - - for shared in [shared_even, shared_odd] { - let key = derive_nip04_key(&shared); - if let Ok(cipher) = Decryptor::::new_from_slices(&key, iv) { - 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); - } - } - } - } - Err(NostrError::Nip04DecryptFailed) -} - -fn derive_nip04_key(shared_secret: &[u8; 32]) -> [u8; 32] { - use sha2::Digest; - let hash = sha2::Sha256::digest(shared_secret); - let mut key = [0u8; 32]; - key.copy_from_slice(&hash); - key -} - #[cfg(test)] mod tests { use super::*; use nostr_core::crypto::keys::generate_keypair; use nostr_core::types::Kind; + // Interop vectors produced by `nak` (independent implementation). + // SK_A = 1111...11, SK_B = 2222...22 + // nak encrypt [--nip04] --sec SK_B -p pub(SK_A) "" + const SK_A: [u8; 32] = [0x11; 32]; + const PK_B: &str = "466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f27"; + const NAK_NIP04: &str = "ZiEsGMnKs9sjO7r/HNQdB+T07E8pQdadbMBgA4j1MPo=?iv=Siodb8Kb1Ahk5YLpS1w2LA=="; + const NAK_NIP44: &str = "AgKFHl+hGEe6OVzGumsGpkArIDajp6E/ovG8V62YHM1V5HMJUB4D+wYBvZq0ghOs0iDtZWCrWsny86xAKCMnc/pXI5lqk2P2x/Tu/FDSF/M9FyzuLSVYBSkjQ1FhvqeHisRl"; + + fn signer_a() -> (LocalSigner, PublicKey) { + (LocalSigner::new(SecretKey::from_bytes(SK_A)).unwrap(), PK_B.parse().unwrap()) + } + + /// Regression: LocalSigner used to carry its own NIP-04 copy with a + /// SHA-256-hashed key and `base64(iv||ct)` layout, so it could not read + /// any real client's DMs even after the 0.1.1 fix to `nostr_nips`. + #[test] + fn test_local_signer_nip04_decrypts_nak() { + let (s, peer) = signer_a(); + assert_eq!(s.nip04_decrypt(&peer, NAK_NIP04).unwrap(), "hello from nak nip04"); + } + + #[test] + fn test_local_signer_nip04_output_is_standard_format() { + let (s, peer) = signer_a(); + let out = s.nip04_encrypt(&peer, "hi").unwrap(); + assert!(out.contains("?iv="), "must use ct?iv=iv wire format, got {out}"); + // And it must be readable by the canonical implementation. + assert_eq!( + nip04::nip04_decrypt(&SecretKey::from_bytes(SK_A), &peer, &out).unwrap(), + "hi" + ); + } + + #[test] + fn test_local_signer_nip44_decrypts_nak() { + let (s, peer) = signer_a(); + let raw = nostr_core::util::base64_decode(NAK_NIP44).unwrap(); + assert_eq!(s.nip44_decrypt(&peer, &raw).unwrap(), b"hello from nak nip44"); + } + #[test] fn test_local_signer_get_public_key() { let (sk, pk) = generate_keypair(); diff --git a/tests/src/lib.rs b/tests/src/lib.rs index 19c1358..539285e 100644 --- a/tests/src/lib.rs +++ b/tests/src/lib.rs @@ -1,4 +1,11 @@ //! Integration tests ported from the C test suite. +//! +//! Rule for crypto tests in this file: a round-trip (`decrypt(encrypt(x)) == x`) +//! proves nothing about interoperability — a wrong-but-symmetric +//! implementation passes it. Every encryption scheme must also have at least +//! one known-answer vector produced by an independent implementation (`nak`, +//! or official spec vectors). +#![cfg(test)] use nostr_core::crypto::hmac::hmac_sha256; use nostr_core::crypto::keys::{ @@ -126,16 +133,34 @@ fn test_nip01_invalid_pubkey_length() { } // ── NIP-04 Test Vectors ───────────────────────────────────────────────────── +// +// Previously named "known_vectors" but only round-tripped, so it passed with +// the old hashed-key / packed-IV implementation. The ciphertext below was +// produced by `nak encrypt --nip04 --sec <sk1> -p <pk2> nanana`. + +const NIP04_SK1: &str = "91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe"; +const NIP04_SK2: &str = "96f6fa197aa07477ab88f6981118466ae3a982faab8ad5db9d5426870c73d220"; +const NIP04_PK1: &str = "b38ce15d3d9874ee710dfabb7ff9801b1e0e20aace6e9a1a05fa7482a04387d1"; +const NIP04_PK2: &str = "dcb33a629560280a0ee3b6b99b68c044fe8914ad8a984001ebf6099a9b474dc3"; +const NAK_NIP04_NANANA: &str = "t1Ql9H8r9XUeaE5wLg9I6A==?iv=JbKO96c0ABhevClnoVhZGg=="; #[test] fn test_nip04_known_vectors() { - let sk1: SecretKey = "91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe".parse().unwrap(); - let sk2: SecretKey = "96f6fa197aa07477ab88f6981118466ae3a982faab8ad5db9d5426870c73d220".parse().unwrap(); - let pk1: PublicKey = "b38ce15d3d9874ee710dfabb7ff9801b1e0e20aace6e9a1a05fa7482a04387d1".parse().unwrap(); - let pk2: PublicKey = "dcb33a629560280a0ee3b6b99b68c044fe8914ad8a984001ebf6099a9b474dc3".parse().unwrap(); - let encrypted = nostr_nips::nip004::nip04_encrypt(&sk1, &pk2, "nanana").unwrap(); - let decrypted = nostr_nips::nip004::nip04_decrypt(&sk2, &pk1, &encrypted).unwrap(); - assert_eq!(decrypted, "nanana"); + let sk1: SecretKey = NIP04_SK1.parse().unwrap(); + let sk2: SecretKey = NIP04_SK2.parse().unwrap(); + let pk1: PublicKey = NIP04_PK1.parse().unwrap(); + let pk2: PublicKey = NIP04_PK2.parse().unwrap(); + assert_eq!(public_key_from_secret_key(&sk1).unwrap(), pk1); + assert_eq!(public_key_from_secret_key(&sk2).unwrap(), pk2); + + // Recipient decrypts nak's ciphertext. + assert_eq!(nostr_nips::nip004::nip04_decrypt(&sk2, &pk1, NAK_NIP04_NANANA).unwrap(), "nanana"); + // Sender can also read it (ECDH symmetry). + assert_eq!(nostr_nips::nip004::nip04_decrypt(&sk1, &pk2, NAK_NIP04_NANANA).unwrap(), "nanana"); + + // Byte-exact encrypt with nak's IV. + let iv: [u8; 16] = nostr_core::util::base64_decode("JbKO96c0ABhevClnoVhZGg==").unwrap().try_into().unwrap(); + assert_eq!(nostr_nips::nip004::nip04_encrypt_with_iv(&sk1, &pk2, "nanana", &iv).unwrap(), NAK_NIP04_NANANA); } // ── NIP-13 PoW Tests ──────────────────────────────────────────────────────── @@ -197,12 +222,24 @@ fn test_nip19_nsec_roundtrip() { assert_eq!(decoded, Bech32Entity::Nsec(sk)); } -// ── NIP-44 Round-trip Tests ───────────────────────────────────────────────── +// ── NIP-44 Tests ──────────────────────────────────────────────────────────── + +/// `nak encrypt --sec <NIP04_SK1> -p <NIP04_PK2> 'Hello, NIP-44!'` +#[test] +fn test_nip44_nak_known_answer() { + let sk2: SecretKey = NIP04_SK2.parse().unwrap(); + let pk1: PublicKey = NIP04_PK1.parse().unwrap(); + let raw = nostr_core::util::base64_decode( + "Anpxt3Dh3Ry6jFCyk4wP19eFOgflbutisqscdKydeCLcXdX7LPcCfpvC42Lk+q4+ZiqEoXc28rsV4NHloNsBTuFnok5jvy/UbMN+3FYdookXQw74i6WzYsAzEKYvu7jinR4e", + ) + .unwrap(); + assert_eq!(nip44_decrypt(&sk2, &pk1, &raw).unwrap(), b"Hello, NIP-44!"); +} #[test] fn test_nip44_round_trip_basic() { - let sk1: SecretKey = "91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe".parse().unwrap(); - let sk2: SecretKey = "96f6fa197aa07477ab88f6981118466ae3a982faab8ad5db9d5426870c73d220".parse().unwrap(); + let sk1: SecretKey = NIP04_SK1.parse().unwrap(); + let sk2: SecretKey = NIP04_SK2.parse().unwrap(); let pk1 = public_key_from_secret_key(&sk1).unwrap(); let pk2 = public_key_from_secret_key(&sk2).unwrap(); let enc = nip44_encrypt(&sk1, &pk2, b"Hello, NIP-44!").unwrap(); @@ -221,15 +258,15 @@ fn test_nip44_unicode() { assert_eq!(String::from_utf8(dec).unwrap(), "Hello 🌍 World! 🚀"); } +/// NIP-44 forbids empty plaintext (nak: "plaintext can't be empty"). This +/// test used to assert that empty messages round-trip, enshrining a spec +/// violation whose output other clients reject. #[test] -fn test_nip44_empty() { +fn test_nip44_empty_rejected() { let sk1: SecretKey = "3333333333333333333333333333333333333333333333333333333333333333".parse().unwrap(); let sk2: SecretKey = "4444444444444444444444444444444444444444444444444444444444444444".parse().unwrap(); - let pk1 = public_key_from_secret_key(&sk1).unwrap(); let pk2 = public_key_from_secret_key(&sk2).unwrap(); - let enc = nip44_encrypt(&sk1, &pk2, b"").unwrap(); - let dec = nip44_decrypt(&sk2, &pk1, &enc).unwrap(); - assert_eq!(dec, b""); + assert!(nip44_encrypt(&sk1, &pk2, b"").is_err()); } // ── NIP-59 Gift Wrap Tests ──────────────────────────────────────────────────