diff --git a/nostr/CHANGELOG.md b/nostr/CHANGELOG.md index 5d7ada8a9..bd99b43ef 100644 --- a/nostr/CHANGELOG.md +++ b/nostr/CHANGELOG.md @@ -33,6 +33,10 @@ - The `Display` implementations of `NostrConnectMessage` redact the sensitive data (https://github.com/nostrdevkit/nostr/pull/1432) +### Security + +- Use `zeroize` for ECDH key handling in NIP-44 and NIP-04 (https://github.com/nostrdevkit/nostr/pull/1435) + ## v0.45.0 - 2026/08/05 ### Breaking changes diff --git a/nostr/Cargo.toml b/nostr/Cargo.toml index c249a98a6..fc417de3f 100644 --- a/nostr/Cargo.toml +++ b/nostr/Cargo.toml @@ -34,6 +34,7 @@ std = [ "unicode-normalization?/std", "universal-time/std", "url/std", + "zeroize?/std", ] alloc = [ "base64?/alloc", @@ -47,6 +48,7 @@ alloc = [ "secp256k1/alloc", "serde/alloc", "serde_json/alloc", + "zeroize?/alloc", ] # Enable random stuff rand = ["dep:rand"] @@ -55,9 +57,9 @@ os-rng = ["rand", "rand/sys_rng"] # Enable event POW mining using multi-threads pow-multi-thread = ["std"] all-nips = ["nip04", "nip06", "nip44", "nip46", "nip47", "nip49", "nip59", "nip60", "nip98"] -nip04 = ["dep:aes", "dep:base64", "dep:cbc"] +nip04 = ["dep:aes", "dep:base64", "dep:cbc", "dep:zeroize"] nip06 = ["dep:bip39"] -nip44 = ["dep:base64", "dep:chacha20"] +nip44 = ["dep:base64", "dep:chacha20", "dep:zeroize"] nip46 = ["nip04", "nip44"] nip47 = ["nip04", "nip44"] nip49 = ["dep:chacha20poly1305", "dep:scrypt", "dep:unicode-normalization", "dep:zeroize"] @@ -84,7 +86,7 @@ serde_json.workspace = true unicode-normalization = { version = "0.1", default-features = false, optional = true } universal-time.workspace = true url = { version = "2.5", default-features = false, features = ["serde"] } -zeroize = { version = "1.9", default-features = false, features = ["alloc"], optional = true } +zeroize = { version = "1.9", default-features = false, optional = true } [dev-dependencies] reqwest = { workspace = true, features = ["rustls-tls"] } diff --git a/nostr/src/nips/nip04/impl.rs b/nostr/src/nips/nip04/impl.rs index b4d182c03..25e59fc26 100644 --- a/nostr/src/nips/nip04/impl.rs +++ b/nostr/src/nips/nip04/impl.rs @@ -17,6 +17,7 @@ use rand::Rng; use rand::rand_core::UnwrapErr; #[cfg(all(feature = "std", feature = "os-rng"))] use rand::rngs::SysRng; +use zeroize::Zeroizing; use crate::error::{Error, ErrorKind}; use crate::key::{PublicKey, SecretKey}; @@ -78,10 +79,11 @@ where T: AsRef<[u8]>, { // Generate key - let key: [u8; 32] = util::generate_shared_key(secret_key, public_key)?; + let key: Zeroizing<[u8; 32]> = util::generate_shared_key(secret_key, public_key)?; // Compose cipher - let cipher = Aes256CbcEnc::new(&key.into(), &iv.into()); + let cipher: Aes256CbcEnc = + Aes256CbcEnc::new_from_slices(key.as_slice(), &iv).map_err(Error::malformed_display)?; // Encrypt let result: Vec = cipher.encrypt_padded_vec_mut::(content.as_ref()); @@ -130,9 +132,10 @@ where .as_slice() .try_into() .map_err(|_| Error::with_static_message(ErrorKind::Malformed, "invalid IV length"))?; - let key: [u8; 32] = util::generate_shared_key(secret_key, public_key)?; + let key: Zeroizing<[u8; 32]> = util::generate_shared_key(secret_key, public_key)?; - let cipher = Aes256CbcDec::new(&key.into(), &iv.into()); + let cipher: Aes256CbcDec = + Aes256CbcDec::new_from_slices(key.as_slice(), &iv).map_err(Error::malformed_display)?; let result = cipher .decrypt_padded_vec_mut::(&encrypted_content) .map_err(|_| Error::with_static_message(ErrorKind::Crypto, "wrong block mode"))?; diff --git a/nostr/src/nips/nip44/v2.rs b/nostr/src/nips/nip44/v2.rs index a1e61c962..c24e27784 100644 --- a/nostr/src/nips/nip44/v2.rs +++ b/nostr/src/nips/nip44/v2.rs @@ -15,6 +15,7 @@ use bitcoin_hashes::sha256::{self, Hash as Sha256Hash}; use bitcoin_hashes::{Hash, HashEngine}; use chacha20::ChaCha20; use chacha20::cipher::{KeyIvInit, StreamCipher}; +use zeroize::Zeroizing; use crate::error::{Error, ErrorKind}; use crate::key::{PublicKey, SecretKey}; @@ -120,8 +121,8 @@ impl ConversationKey { /// Derive Conversation Key #[inline] pub fn derive(secret_key: &SecretKey, public_key: &PublicKey) -> Result { - let shared_key: [u8; 32] = util::generate_shared_key(secret_key, public_key)?; - Ok(Self(hkdf::extract(b"nip44-v2", &shared_key))) + let shared_key: Zeroizing<[u8; 32]> = util::generate_shared_key(secret_key, public_key)?; + Ok(Self(hkdf::extract(b"nip44-v2", shared_key.as_slice()))) } /// Compose Conversation Key from bytes diff --git a/nostr/src/util/mod.rs b/nostr/src/util/mod.rs index 5ef1028ec..ceb4fe0b7 100644 --- a/nostr/src/util/mod.rs +++ b/nostr/src/util/mod.rs @@ -18,6 +18,8 @@ use rand::rngs::SysRng; use secp256k1::{All, Secp256k1}; #[cfg(any(feature = "nip04", feature = "nip44"))] use secp256k1::{PublicKey as NormalizedPublicKey, ecdh}; +#[cfg(any(feature = "nip04", feature = "nip44"))] +use zeroize::Zeroizing; #[cfg(feature = "nip44")] pub(crate) mod hkdf; @@ -74,7 +76,7 @@ pub(crate) fn hex_decode(hex: &str) -> Result<[u8; SIZE], Err pub(crate) fn generate_shared_key( secret_key: &SecretKey, public_key: &PublicKey, -) -> Result<[u8; 32], Error> { +) -> Result, Error> { // `from_x_only_public_key(pk, Parity::Even)` builds exactly this form and // parses it again, so building it directly is equivalent and parses once. let mut compressed: [u8; 33] = [0u8; 33]; @@ -82,9 +84,14 @@ pub(crate) fn generate_shared_key( compressed[1..].copy_from_slice(public_key.as_bytes()); let public_key_normalized: NormalizedPublicKey = NormalizedPublicKey::from_slice(&compressed).map_err(Error::invalid_display)?; - let ssp: [u8; 64] = ecdh::shared_secret_point(&public_key_normalized, secret_key); - let mut shared_key: [u8; 32] = [0u8; 32]; + + let ssp: Zeroizing<[u8; 64]> = Zeroizing::new(ecdh::shared_secret_point( + &public_key_normalized, + secret_key, + )); + let mut shared_key: Zeroizing<[u8; 32]> = Zeroizing::new([0u8; 32]); shared_key.copy_from_slice(&ssp[..32]); + Ok(shared_key) }