diff --git a/nostr/CHANGELOG.md b/nostr/CHANGELOG.md index b4fa6b1cd..d202a79c3 100644 --- a/nostr/CHANGELOG.md +++ b/nostr/CHANGELOG.md @@ -116,6 +116,7 @@ - Optimize NIP-21 URI parsing in `PublicKey::parse` (https://github.com/nostrdevkit/nostr/pull/1308) - Optimize event serialization by ~73% (https://github.com/nostrdevkit/nostr/pull/1319) - Hardware-accelerate SHA-256 where available (https://github.com/nostrdevkit/nostr/pull/1419) +- Reduce allocations and redundant key parsing in the NIP-44 v2 path (https://github.com/nostrdevkit/nostr/pull/1421) ### Security diff --git a/nostr/src/nips/nip44/v2.rs b/nostr/src/nips/nip44/v2.rs index 5f2e9a1c5..a1e61c962 100644 --- a/nostr/src/nips/nip44/v2.rs +++ b/nostr/src/nips/nip44/v2.rs @@ -6,7 +6,6 @@ //! //! -use alloc::vec; use alloc::vec::Vec; use core::fmt; use core::ops::{Deref, Range}; @@ -46,9 +45,7 @@ const MESSAGES_KEYS_AUTH_RANGE: Range = #[derive(Debug)] enum ErrorV2 { PayloadTooShort, - HkdfLength, NotFound(&'static str), - TryFromSlice, MessageEmpty, MessageTooLong, InvalidHmac, @@ -61,13 +58,7 @@ impl From for Error { ErrorV2::PayloadTooShort => { Error::with_static_message(ErrorKind::Invalid, "payload size is too short") } - ErrorV2::HkdfLength => { - Error::with_static_message(ErrorKind::Invalid, "invalid HKDF length") - } ErrorV2::NotFound(value) => Error::with_static_message(ErrorKind::Missing, value), - ErrorV2::TryFromSlice => { - Error::with_static_message(ErrorKind::Malformed, "invalid slice length") - } ErrorV2::MessageEmpty => { Error::with_static_message(ErrorKind::Invalid, "message empty") } @@ -85,11 +76,6 @@ impl From for Error { struct MessageKeys([u8; MESSAGE_KEYS_SIZE]); impl MessageKeys { - #[inline] - pub fn from_slice(slice: &[u8]) -> Result { - Ok(Self(slice.try_into().map_err(|_| ErrorV2::TryFromSlice)?)) - } - #[inline] pub fn encryption(&self) -> &[u8] { &self.0[MESSAGES_KEYS_ENCRYPTION_RANGE] @@ -167,27 +153,42 @@ pub fn encrypt_to_bytes_with_nonce( plaintext: &[u8], nonce: [u8; 32], ) -> Result, Error> { - // Get Message Keys - let keys: MessageKeys = get_message_keys(conversation_key, &nonce)?; + let len: usize = plaintext.len(); + + // Same bounds `pad` enforces, checked before anything is allocated. + if len < 1 { + return Err(ErrorV2::MessageEmpty.into()); + } + + if len > MAX_SUPPORTED_PLAINTEXT_SIZE { + return Err(ErrorV2::MessageTooLong.into()); + } - // Pad - let mut buffer: Vec = pad(plaintext)?; + // Get Message Keys + let keys: MessageKeys = get_message_keys(conversation_key, &nonce); + + // Build the payload in place, as [version | nonce | length | plaintext | + // padding | MAC], then encrypt the ciphertext region where it already sits. + // Padding and MAC are zero-filled by `resize` and overwritten below. + let ciphertext_len: usize = LENGTH_PREFIX_SIZE + calc_padding(len); + let mac_start: usize = VERSION_SIZE + NONCE_SIZE + ciphertext_len; + let mut payload: Vec = Vec::with_capacity(mac_start + HMAC_SIZE); + payload.push(2); // Version + payload.extend_from_slice(&nonce); + payload.extend_from_slice(&(len as u16).to_be_bytes()); + payload.extend_from_slice(plaintext); + payload.resize(mac_start + HMAC_SIZE, 0); - // Compose cipher and encrypt + // Compose cipher and encrypt in place + let ciphertext: &mut [u8] = &mut payload[VERSION_SIZE + NONCE_SIZE..mac_start]; let mut cipher = ChaCha20::new(keys.encryption().into(), keys.nonce().into()); - cipher.apply_keystream(&mut buffer); + cipher.apply_keystream(ciphertext); - // HMAC-SHA256 + // HMAC-SHA256 over nonce | ciphertext, both already contiguous in `payload` let mut engine: HmacEngine = HmacEngine::new(keys.auth()); - engine.input(&nonce); - engine.input(&buffer); + engine.input(&payload[VERSION_SIZE..mac_start]); let hmac: [u8; 32] = engine.finalize().to_byte_array(); - - // Compose payload - let mut payload: Vec = vec![2]; // Version - payload.extend_from_slice(&nonce); - payload.extend_from_slice(&buffer); - payload.extend_from_slice(&hmac); + payload[mac_start..].copy_from_slice(&hmac); Ok(payload) } @@ -221,7 +222,7 @@ pub fn decrypt_to_bytes( .ok_or(ErrorV2::NotFound("hmac"))?; // Compose Message Keys - let keys: MessageKeys = get_message_keys(conversation_key, nonce)?; + let keys: MessageKeys = get_message_keys(conversation_key, nonce); // Check HMAC-SHA256 let mut engine: HmacEngine = HmacEngine::new(keys.auth()); @@ -264,31 +265,10 @@ pub fn decrypt_to_bytes( } #[inline] -fn get_message_keys( - conversation_key: &ConversationKey, - nonce: &[u8], -) -> Result { - let expanded_key: Vec = hkdf::expand(conversation_key.as_bytes(), nonce, MESSAGE_KEYS_SIZE); - MessageKeys::from_slice(&expanded_key).map_err(|_| ErrorV2::HkdfLength) -} - -fn pad(unpadded: &[u8]) -> Result, ErrorV2> { - let len: usize = unpadded.len(); - - if len < 1 { - return Err(ErrorV2::MessageEmpty); - } - - if len > MAX_SUPPORTED_PLAINTEXT_SIZE { - return Err(ErrorV2::MessageTooLong); - } - - let take: usize = calc_padding(len) - len; - let mut padded: Vec = Vec::with_capacity(2 + len + take); - padded.extend_from_slice(&(len as u16).to_be_bytes()); - padded.extend_from_slice(unpadded); - padded.extend(core::iter::repeat_n(0, take)); - Ok(padded) +fn get_message_keys(conversation_key: &ConversationKey, nonce: &[u8]) -> MessageKeys { + let mut keys: [u8; MESSAGE_KEYS_SIZE] = [0u8; MESSAGE_KEYS_SIZE]; + hkdf::expand_into(conversation_key.as_bytes(), nonce, &mut keys); + MessageKeys(keys) } #[inline] @@ -317,12 +297,34 @@ const fn log2_round_down(x: usize) -> u32 { mod tests { #![allow(dead_code)] + use alloc::vec; use core::str::FromStr; use base64::engine::{Engine, general_purpose}; use super::*; use crate::key::Keys; + + /// Straightforward reference padding, kept as an oracle for the in-place + /// payload construction in `encrypt_to_bytes_with_nonce`. + fn pad(unpadded: &[u8]) -> Result, ErrorV2> { + let len: usize = unpadded.len(); + + if len < 1 { + return Err(ErrorV2::MessageEmpty); + } + + if len > MAX_SUPPORTED_PLAINTEXT_SIZE { + return Err(ErrorV2::MessageTooLong); + } + + let take: usize = calc_padding(len) - len; + let mut padded: Vec = Vec::with_capacity(2 + len + take); + padded.extend_from_slice(&(len as u16).to_be_bytes()); + padded.extend_from_slice(unpadded); + padded.extend(core::iter::repeat_n(0, take)); + Ok(padded) + } use crate::nips::nip44; const JSON_VECTORS: &str = include_str!("nip44.vectors.json"); @@ -629,7 +631,7 @@ mod tests { let nonce: [u8; 32] = [0x42; 32]; - let keys: MessageKeys = get_message_keys(conversation_key, &nonce).unwrap(); + let keys: MessageKeys = get_message_keys(conversation_key, &nonce); // Zero or one encrypted byte. ChaCha20 preserves the buffer length, // therefore the decrypted buffer will also contain zero or one byte. @@ -706,6 +708,69 @@ mod tests { ); } + #[test] + fn test_encrypt_rejects_invalid_plaintext_len() { + let conversation_key = ConversationKey::new([0x42; 32]); + let nonce: [u8; 32] = [0x11; 32]; + + let err = encrypt_to_bytes_with_nonce(&conversation_key, b"", nonce).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::Invalid); + assert_eq!(err.to_string(), "message empty"); + + let too_long: Vec = vec![0x24; MAX_SUPPORTED_PLAINTEXT_SIZE + 1]; + let err = encrypt_to_bytes_with_nonce(&conversation_key, &too_long, nonce).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::Invalid); + assert_eq!(err.to_string(), "message too long"); + } + + /// Composing the payload in place must be byte-identical to the + /// pad-encrypt-append form it replaced. + #[test] + fn test_encrypt_matches_reference_construction() { + let conversation_key = ConversationKey::new([0x42; 32]); + let nonce: [u8; 32] = [0x11; 32]; + + for len in [ + 1usize, + 2, + 31, + 32, + 33, + 63, + 64, + 65, + 100, + 255, + 256, + 257, + 1000, + 4096, + 4097, + MAX_SUPPORTED_PLAINTEXT_SIZE, + ] { + let plaintext: Vec = (0..len).map(|i| (i % 251) as u8).collect(); + + let keys: MessageKeys = get_message_keys(&conversation_key, &nonce); + let mut buffer: Vec = pad(&plaintext).unwrap(); + let mut cipher = ChaCha20::new(keys.encryption().into(), keys.nonce().into()); + cipher.apply_keystream(&mut buffer); + + let mut engine: HmacEngine = HmacEngine::new(keys.auth()); + engine.input(&nonce); + engine.input(&buffer); + let mac: [u8; 32] = engine.finalize().to_byte_array(); + + let mut expected: Vec = vec![2]; + expected.extend_from_slice(&nonce); + expected.extend_from_slice(&buffer); + expected.extend_from_slice(&mac); + + let payload: Vec = + encrypt_to_bytes_with_nonce(&conversation_key, &plaintext, nonce).unwrap(); + assert_eq!(payload, expected, "payload mismatch at plaintext len {len}"); + } + } + #[test] fn test_conversation_key_from_slice() { let bytes: [u8; 32] = [0x42; 32]; diff --git a/nostr/src/util/hkdf.rs b/nostr/src/util/hkdf.rs index 4917fec35..0bc403372 100644 --- a/nostr/src/util/hkdf.rs +++ b/nostr/src/util/hkdf.rs @@ -4,8 +4,6 @@ //! HKDF Util -use alloc::vec::Vec; - use bitcoin_hashes::hmac::{Hmac, HmacEngine}; use bitcoin_hashes::sha256::{self, Hash as Sha256Hash}; use bitcoin_hashes::{Hash, HashEngine}; @@ -18,28 +16,134 @@ pub fn extract(salt: &[u8], input_key_material: &[u8]) -> Hmac { engine.finalize() } -/// HKDF expand -pub fn expand(prk: &[u8], info: &[u8], output_len: usize) -> Vec { - let mut output: Vec = Vec::with_capacity(output_len); - let mut t: Vec = Vec::with_capacity(32); +/// HKDF expand, filling `out` with `out.len()` bytes of output key material. +/// +/// The caller owns the destination, so a fixed-size output needs no allocation. +/// +/// Per RFC 5869, `out` may span at most 255 blocks (8160 bytes). +pub fn expand_into(prk: &[u8], info: &[u8], out: &mut [u8]) { + debug_assert!( + out.len() <= 255 * 32, + "HKDF-expand supports at most 255 blocks of output" + ); - let mut i: u8 = 1u8; - while output.len() < output_len { - let mut engine: HmacEngine = HmacEngine::new(prk); + // The keyed engine depends only on `prk`, and absorbing the ipad/opad key + // schedule is most of the cost of a short HMAC. Build it once and clone the + // midstate per block instead of re-keying for each block. + let keyed: HmacEngine = HmacEngine::new(prk); - if !t.is_empty() { - engine.input(&t); - } + let mut prev: [u8; 32] = [0u8; 32]; + let mut written: usize = 0; + let mut counter: u8 = 1; + while written < out.len() { + // T(i) = HMAC(PRK, T(i-1) | info | i), where T(0) is empty. Feeding the + // parts in turn is byte-identical to feeding them joined. + let mut engine: HmacEngine = keyed.clone(); + if written > 0 { + engine.input(&prev); + } engine.input(info); - engine.input(&[i]); + engine.input(&[counter]); + prev = engine.finalize().to_byte_array(); + + let take: usize = (out.len() - written).min(prev.len()); + out[written..written + take].copy_from_slice(&prev[..take]); + written += take; + counter += 1; + } +} + +#[cfg(test)] +mod tests { + use alloc::vec; + use alloc::vec::Vec; - t = engine.finalize().to_byte_array().to_vec(); - output.extend_from_slice(&t); + use super::*; - i += 1; + fn unhex(s: &str) -> Vec { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) + .collect() } - output.truncate(output_len); - output + struct Vector { + ikm: &'static str, + salt: &'static str, + info: &'static str, + len: usize, + prk: &'static str, + okm: &'static str, + } + + /// RFC 5869 appendix A, test cases 1 to 3 (SHA-256). + /// + /// Case 2 covers a salt longer than the hash block size and an output + /// spanning three blocks; case 3 covers an empty salt and empty info. + const RFC5869: [Vector; 3] = [ + Vector { + ikm: "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b", + salt: "000102030405060708090a0b0c", + info: "f0f1f2f3f4f5f6f7f8f9", + len: 42, + prk: "077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5", + okm: "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865", + }, + Vector { + ikm: "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f", + salt: "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + info: "b0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff", + len: 82, + prk: "06a6b88c5853361a06104c9ceb35b45cef760014904671014a193f40c15fc244", + okm: "b11e398dc80327a1c8e7f78c596a49344f012eda2d4efad8a050cc4c19afa97c59045a99cac7827271cb41c65e590e09da3275600c2f09b8367793a9aca3db71cc30c58179ec3e87c14c01d5c1f3434f1d87", + }, + Vector { + ikm: "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b", + salt: "", + info: "", + len: 42, + prk: "19ef24a32c717b167f33a91d6f648bdf96596776afdb6377ac434c1c293ccb04", + okm: "8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8", + }, + ]; + + #[test] + fn test_rfc5869_vectors() { + for (i, v) in RFC5869.iter().enumerate() { + let prk = extract(&unhex(v.salt), &unhex(v.ikm)); + assert_eq!( + prk.as_byte_array().as_slice(), + unhex(v.prk), + "case {} PRK", + i + 1 + ); + + let mut okm = vec![0u8; v.len]; + expand_into(prk.as_byte_array(), &unhex(v.info), &mut okm); + assert_eq!(okm, unhex(v.okm), "case {} OKM", i + 1); + } + } + + /// Output stopping mid-block must be a prefix of the longer output. + #[test] + fn test_expand_partial_block() { + let prk = extract(b"salt", b"ikm"); + let mut full = [0u8; 64]; + expand_into(prk.as_byte_array(), b"info", &mut full); + + for len in [1usize, 31, 32, 33, 63, 64] { + let mut out = vec![0u8; len]; + expand_into(prk.as_byte_array(), b"info", &mut out); + assert_eq!(out, full[..len], "length {len}"); + } + } + + /// An empty destination must not invoke the PRF. + #[test] + fn test_expand_empty_output() { + let prk = extract(b"salt", b"ikm"); + let mut out: [u8; 0] = []; + expand_into(prk.as_byte_array(), b"info", &mut out); + } } diff --git a/nostr/src/util/mod.rs b/nostr/src/util/mod.rs index b43eb25ee..75f6e0155 100644 --- a/nostr/src/util/mod.rs +++ b/nostr/src/util/mod.rs @@ -17,7 +17,7 @@ use rand::rngs::SysRng; #[cfg(feature = "std")] use secp256k1::{All, Secp256k1}; #[cfg(any(feature = "nip04", feature = "nip44"))] -use secp256k1::{Parity, PublicKey as NormalizedPublicKey, XOnlyPublicKey, ecdh}; +use secp256k1::{PublicKey as NormalizedPublicKey, ecdh}; #[cfg(feature = "nip44")] pub(crate) mod hkdf; @@ -66,9 +66,13 @@ pub(crate) fn generate_shared_key( secret_key: &SecretKey, public_key: &PublicKey, ) -> Result<[u8; 32], Error> { - let pk: XOnlyPublicKey = public_key.xonly()?; + // `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]; + compressed[0] = 0x02; // Even parity + compressed[1..].copy_from_slice(public_key.as_bytes()); let public_key_normalized: NormalizedPublicKey = - NormalizedPublicKey::from_x_only_public_key(pk, Parity::Even); + 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]; shared_key.copy_from_slice(&ssp[..32]);