From 7f50222fff8dc2e446b0bb4ea9c2bc7a9be35fba Mon Sep 17 00:00:00 2001 From: Jeff Gardner <202880+erskingardner@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:56:13 +0100 Subject: [PATCH 1/2] nostr(nip44): support full-range v2 payloads --- nostr/CHANGELOG.md | 1 + nostr/src/nips/nip44/impl.rs | 62 ++++-- nostr/src/nips/nip44/v2.rs | 384 ++++++++++++++++++++++++++--------- 3 files changed, 344 insertions(+), 103 deletions(-) diff --git a/nostr/CHANGELOG.md b/nostr/CHANGELOG.md index 36c6ca054..55076f9b4 100644 --- a/nostr/CHANGELOG.md +++ b/nostr/CHANGELOG.md @@ -36,6 +36,7 @@ ### Added - Impl `From` for `Tag` (https://github.com/nostrdevkit/nostr/pull/1446) +- Support NIP-44 v2 payloads up to 2^32 - 1 plaintext bytes - Take an `Into` iterator instead of `Tag` iterator in `GiftWrapBuilder::extra_tags`, `PrivateDirectMessageBuilder::extra_tags` and `PrivateDirectMessageBuilder::rumor_extra_tags` (https://github.com/nostrdevkit/nostr/pull/1447) diff --git a/nostr/src/nips/nip44/impl.rs b/nostr/src/nips/nip44/impl.rs index 7a6a4644c..eb598a8cb 100644 --- a/nostr/src/nips/nip44/impl.rs +++ b/nostr/src/nips/nip44/impl.rs @@ -36,13 +36,13 @@ impl Version { *self as u8 } - fn max_encoded_payload_size(self) -> usize { + fn max_encoded_payload_size(self) -> u64 { match self { Self::V2 => v2::MAX_ENCODED_PAYLOAD_SIZE, } } - fn validate_encoded_payload_size(self, len: usize) -> Result<(), Error> { + fn validate_encoded_payload_size(self, len: u64) -> Result<(), Error> { if len > self.max_encoded_payload_size() { return Err(Error::with_static_message( ErrorKind::Invalid, @@ -54,6 +54,17 @@ impl Version { } } +fn unsupported_platform_size() -> Error { + Error::with_static_message( + ErrorKind::Unsupported, + "NIP-44 payload size is not supported on this platform", + ) +} + +fn allocation_failed() -> Error { + Error::with_static_message(ErrorKind::Other, "failed to allocate NIP-44 payload buffer") +} + fn decode_payload_version(payload: &[u8]) -> Result { // Decode one Base64 quantum so the version-specific limit runs before full allocation. let encoded_prefix = payload.get(..4).unwrap_or(payload); @@ -144,7 +155,18 @@ where T: AsRef<[u8]>, { let payload: Vec = encrypt_to_bytes_with_nonce(secret_key, public_key, content, nonce)?; - Ok(general_purpose::STANDARD.encode(payload)) + let encoded_len: usize = + base64::encoded_len(payload.len(), true).ok_or_else(unsupported_platform_size)?; + if encoded_len > isize::MAX as usize { + return Err(unsupported_platform_size()); + } + + let mut encoded: String = String::new(); + encoded + .try_reserve_exact(encoded_len) + .map_err(|_| allocation_failed())?; + general_purpose::STANDARD.encode_string(payload, &mut encoded); + Ok(encoded) } /// Encrypt to bytes (**not base64 encoded!**) @@ -169,6 +191,11 @@ where } /// Decrypt +/// +/// NIP-44 permits payloads containing up to [`u32::MAX`] plaintext bytes. +/// Decrypting payloads near that limit requires several gigabytes of contiguous +/// memory. Applications should enforce a smaller encoded-payload limit when +/// processing untrusted events on resource-constrained systems. #[inline] pub fn decrypt( secret_key: &SecretKey, @@ -183,6 +210,11 @@ where } /// Decrypt **without** converting bytes to UTF-8 string +/// +/// NIP-44 permits payloads containing up to [`u32::MAX`] plaintext bytes. +/// Decrypting payloads near that limit requires several gigabytes of contiguous +/// memory. Applications should enforce a smaller encoded-payload limit when +/// processing untrusted events on resource-constrained systems. pub fn decrypt_to_bytes( secret_key: &SecretKey, public_key: &PublicKey, @@ -193,18 +225,26 @@ where { let payload = payload.as_ref(); let version = decode_payload_version(payload)?; - version.validate_encoded_payload_size(payload.len())?; + version.validate_encoded_payload_size(payload.len() as u64)?; // Decode base64 payload - let payload: Vec = general_purpose::STANDARD - .decode(payload) + let decoded_len: usize = base64::decoded_len_estimate(payload.len()); + if decoded_len > isize::MAX as usize { + return Err(unsupported_platform_size()); + } + let mut decoded: Vec = Vec::new(); + decoded + .try_reserve_exact(decoded_len) + .map_err(|_| allocation_failed())?; + general_purpose::STANDARD + .decode_vec(payload, &mut decoded) .map_err(Error::malformed_display)?; match version { Version::V2 => { let conversation_key: ConversationKey = ConversationKey::derive(secret_key, public_key)?; - v2::decrypt_to_bytes(&conversation_key, &payload) + v2::decrypt_to_bytes(&conversation_key, &decoded) } } } @@ -244,11 +284,9 @@ mod tests { #[test] fn test_oversized_base64_payload_is_rejected_before_decoding() { - let keys = Keys::generate(); - let mut payload = vec![b'A'; v2::MAX_ENCODED_PAYLOAD_SIZE + 1]; - payload[..4].copy_from_slice(b"AgAA"); - - let err = decrypt_to_bytes(keys.secret_key(), &keys.public_key(), payload).unwrap_err(); + let err = Version::V2 + .validate_encoded_payload_size(v2::MAX_ENCODED_PAYLOAD_SIZE + 1) + .unwrap_err(); assert_eq!(err.kind(), ErrorKind::Invalid); assert_eq!(err.to_string(), "message too long"); } diff --git a/nostr/src/nips/nip44/v2.rs b/nostr/src/nips/nip44/v2.rs index c24e27784..79f7739e0 100644 --- a/nostr/src/nips/nip44/v2.rs +++ b/nostr/src/nips/nip44/v2.rs @@ -23,16 +23,18 @@ use crate::util::{self, hkdf}; const VERSION_SIZE: usize = 1; const NONCE_SIZE: usize = 32; -const LENGTH_PREFIX_SIZE: usize = 2; -const MIN_CIPHERTEXT_SIZE: usize = LENGTH_PREFIX_SIZE + 32; +const LEGACY_LENGTH_PREFIX_SIZE: usize = 2; +const EXTENDED_LENGTH_PREFIX_SIZE: usize = 6; +const EXTENDED_PREFIX_THRESHOLD: u64 = 65_536; +const MAX_PLAINTEXT_SIZE: u64 = u32::MAX as u64; +const MIN_CIPHERTEXT_SIZE: usize = LEGACY_LENGTH_PREFIX_SIZE + 32; const HMAC_SIZE: usize = 32; const MIN_PAYLOAD_SIZE: usize = VERSION_SIZE + NONCE_SIZE + MIN_CIPHERTEXT_SIZE + HMAC_SIZE; -// This codec currently supports the original two-byte length prefix only. -const MAX_SUPPORTED_PLAINTEXT_SIZE: usize = 65_536 - 128; -const MAX_CIPHERTEXT_SIZE: usize = LENGTH_PREFIX_SIZE + calc_padding(MAX_SUPPORTED_PLAINTEXT_SIZE); -pub(super) const MAX_PAYLOAD_SIZE: usize = - VERSION_SIZE + NONCE_SIZE + MAX_CIPHERTEXT_SIZE + HMAC_SIZE; -pub(super) const MAX_ENCODED_PAYLOAD_SIZE: usize = MAX_PAYLOAD_SIZE.div_ceil(3) * 4; +const MAX_CIPHERTEXT_SIZE: u64 = + EXTENDED_LENGTH_PREFIX_SIZE as u64 + calc_padding(MAX_PLAINTEXT_SIZE); +pub(super) const MAX_PAYLOAD_SIZE: u64 = + VERSION_SIZE as u64 + NONCE_SIZE as u64 + MAX_CIPHERTEXT_SIZE + HMAC_SIZE as u64; +pub(super) const MAX_ENCODED_PAYLOAD_SIZE: u64 = MAX_PAYLOAD_SIZE.div_ceil(3) * 4; const MESSAGE_KEYS_SIZE: usize = 76; const MESSAGES_KEYS_ENCRYPTION_SIZE: usize = 32; @@ -49,6 +51,8 @@ enum ErrorV2 { NotFound(&'static str), MessageEmpty, MessageTooLong, + PlatformSizeUnsupported, + AllocationFailed, InvalidHmac, InvalidPadding, } @@ -66,6 +70,14 @@ impl From for Error { ErrorV2::MessageTooLong => { Error::with_static_message(ErrorKind::Invalid, "message too long") } + ErrorV2::PlatformSizeUnsupported => Error::with_static_message( + ErrorKind::Unsupported, + "NIP-44 payload size is not supported on this platform", + ), + ErrorV2::AllocationFailed => Error::with_static_message( + ErrorKind::Other, + "failed to allocate NIP-44 payload buffer", + ), ErrorV2::InvalidHmac => Error::with_static_message(ErrorKind::Crypto, "invalid HMAC"), ErrorV2::InvalidPadding => { Error::with_static_message(ErrorKind::Invalid, "invalid padding") @@ -74,6 +86,46 @@ impl From for Error { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PayloadLayout { + prefix_len: u64, + ciphertext_len: u64, + payload_len: u64, +} + +impl PayloadLayout { + fn new(plaintext_len: u64) -> Result { + if plaintext_len == 0 { + return Err(ErrorV2::MessageEmpty); + } + + if plaintext_len > MAX_PLAINTEXT_SIZE { + return Err(ErrorV2::MessageTooLong); + } + + let prefix_len: u64 = if plaintext_len < EXTENDED_PREFIX_THRESHOLD { + LEGACY_LENGTH_PREFIX_SIZE as u64 + } else { + EXTENDED_LENGTH_PREFIX_SIZE as u64 + }; + let padded_len: u64 = calc_padding(plaintext_len); + let ciphertext_len: u64 = prefix_len + .checked_add(padded_len) + .ok_or(ErrorV2::MessageTooLong)?; + let payload_len: u64 = (VERSION_SIZE as u64) + .checked_add(NONCE_SIZE as u64) + .and_then(|len| len.checked_add(ciphertext_len)) + .and_then(|len| len.checked_add(HMAC_SIZE as u64)) + .ok_or(ErrorV2::MessageTooLong)?; + + Ok(Self { + prefix_len, + ciphertext_len, + payload_len, + }) + } +} + struct MessageKeys([u8; MESSAGE_KEYS_SIZE]); impl MessageKeys { @@ -155,15 +207,9 @@ pub fn encrypt_to_bytes_with_nonce( nonce: [u8; 32], ) -> Result, Error> { 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()); - } + let layout: PayloadLayout = PayloadLayout::new(len as u64)?; + let payload_len: usize = supported_allocation_size(layout.payload_len)?; + let ciphertext_len: usize = supported_allocation_size(layout.ciphertext_len)?; // Get Message Keys let keys: MessageKeys = get_message_keys(conversation_key, &nonce); @@ -171,14 +217,21 @@ pub fn encrypt_to_bytes_with_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); + let mut payload: Vec = Vec::new(); + payload + .try_reserve_exact(payload_len) + .map_err(|_| ErrorV2::AllocationFailed)?; payload.push(2); // Version payload.extend_from_slice(&nonce); - payload.extend_from_slice(&(len as u16).to_be_bytes()); + if layout.prefix_len == LEGACY_LENGTH_PREFIX_SIZE as u64 { + payload.extend_from_slice(&(len as u16).to_be_bytes()); + } else { + payload.extend_from_slice(&[0, 0]); + payload.extend_from_slice(&(len as u32).to_be_bytes()); + } payload.extend_from_slice(plaintext); - payload.resize(mac_start + HMAC_SIZE, 0); + payload.resize(payload_len, 0); // Compose cipher and encrypt in place let ciphertext: &mut [u8] = &mut payload[VERSION_SIZE + NONCE_SIZE..mac_start]; @@ -197,6 +250,10 @@ pub fn encrypt_to_bytes_with_nonce( /// Decrypt with NIP44 (v2) /// /// **The payload MUST be already decoded from base64** +/// +/// NIP-44 permits payloads containing up to [`u32::MAX`] plaintext bytes. +/// Callers on resource-constrained systems should reject oversized payloads +/// before invoking this function. pub fn decrypt_to_bytes( conversation_key: &ConversationKey, payload: &[u8], @@ -207,9 +264,7 @@ pub fn decrypt_to_bytes( return Err(ErrorV2::PayloadTooShort.into()); } // Reject before HMAC and ciphertext allocation using the largest payload we can emit. - if len > MAX_PAYLOAD_SIZE { - return Err(ErrorV2::MessageTooLong.into()); - } + validate_payload_size(len as u64)?; // Extract nonce, buffer and hmac from payload let nonce: &[u8] = payload @@ -236,18 +291,20 @@ pub fn decrypt_to_bytes( // Compose cipher let mut cipher = ChaCha20::new(keys.encryption().into(), keys.nonce().into()); - let mut buffer: Vec = buffer.to_vec(); - cipher.apply_keystream(&mut buffer); - - let be_bytes: [u8; 2] = buffer - .get(0..2) - .ok_or(ErrorV2::InvalidPadding)? - .try_into() - .map_err(|_| ErrorV2::InvalidPadding)?; - let unpadded_len: usize = u16::from_be_bytes(be_bytes) as usize; + let mut decrypted: Vec = Vec::new(); + decrypted + .try_reserve_exact(buffer.len()) + .map_err(|_| ErrorV2::AllocationFailed)?; + decrypted.extend_from_slice(buffer); + cipher.apply_keystream(&mut decrypted); + + let (prefix_len, unpadded_len) = parse_plaintext_length(&decrypted)?; + let plaintext_end: usize = prefix_len + .checked_add(unpadded_len) + .ok_or(ErrorV2::InvalidPadding)?; - let unpadded: &[u8] = buffer - .get(2..2 + unpadded_len) + let unpadded: &[u8] = decrypted + .get(prefix_len..plaintext_end) .ok_or(ErrorV2::InvalidPadding)?; if unpadded.is_empty() { @@ -258,11 +315,61 @@ pub fn decrypt_to_bytes( return Err(ErrorV2::InvalidPadding.into()); } - if buffer.len() != 2 + calc_padding(unpadded_len) { + let expected_len: u64 = (prefix_len as u64) + .checked_add(calc_padding(unpadded_len as u64)) + .ok_or(ErrorV2::InvalidPadding)?; + if decrypted.len() as u64 != expected_len { return Err(ErrorV2::InvalidPadding.into()); } - Ok(unpadded.to_vec()) + decrypted.copy_within(prefix_len..plaintext_end, 0); + decrypted.truncate(unpadded_len); + Ok(decrypted) +} + +#[inline] +fn validate_payload_size(len: u64) -> Result<(), ErrorV2> { + if len > MAX_PAYLOAD_SIZE { + return Err(ErrorV2::MessageTooLong); + } + + Ok(()) +} + +fn parse_plaintext_length(buffer: &[u8]) -> Result<(usize, usize), ErrorV2> { + let first_two: [u8; 2] = buffer + .get(..LEGACY_LENGTH_PREFIX_SIZE) + .ok_or(ErrorV2::InvalidPadding)? + .try_into() + .map_err(|_| ErrorV2::InvalidPadding)?; + let legacy_len: u16 = u16::from_be_bytes(first_two); + + if legacy_len != 0 { + return Ok((LEGACY_LENGTH_PREFIX_SIZE, legacy_len as usize)); + } + + let extended_bytes: [u8; 4] = buffer + .get(LEGACY_LENGTH_PREFIX_SIZE..EXTENDED_LENGTH_PREFIX_SIZE) + .ok_or(ErrorV2::InvalidPadding)? + .try_into() + .map_err(|_| ErrorV2::InvalidPadding)?; + let extended_len: u32 = u32::from_be_bytes(extended_bytes); + if (extended_len as u64) < EXTENDED_PREFIX_THRESHOLD { + return Err(ErrorV2::InvalidPadding); + } + + let plaintext_len: usize = + usize::try_from(extended_len).map_err(|_| ErrorV2::PlatformSizeUnsupported)?; + Ok((EXTENDED_LENGTH_PREFIX_SIZE, plaintext_len)) +} + +#[inline] +fn supported_allocation_size(len: u64) -> Result { + if len > isize::MAX as u64 { + return Err(ErrorV2::PlatformSizeUnsupported); + } + + usize::try_from(len).map_err(|_| ErrorV2::PlatformSizeUnsupported) } #[inline] @@ -273,23 +380,23 @@ fn get_message_keys(conversation_key: &ConversationKey, nonce: &[u8]) -> Message } #[inline] -const fn calc_padding(len: usize) -> usize { +const fn calc_padding(len: u64) -> u64 { if len <= 32 { return 32; } - let nextpower: usize = 1 << (log2_round_down(len - 1) + 1); - let chunk: usize = if nextpower <= 256 { 32 } else { nextpower / 8 }; + let nextpower: u64 = 1 << (log2_round_down(len - 1) + 1); + let chunk: u64 = if nextpower <= 256 { 32 } else { nextpower / 8 }; chunk * (((len - 1) / chunk) + 1) } /// Returns the base 2 logarithm of the number, rounded down. #[inline] -const fn log2_round_down(x: usize) -> u32 { +const fn log2_round_down(x: u64) -> u32 { if x == 0 { 0 } else { // This is equivalent to floor(log2(x)) - (usize::BITS - 1) - x.leading_zeros() + (u64::BITS - 1) - x.leading_zeros() } } @@ -310,18 +417,17 @@ mod tests { /// 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 layout: PayloadLayout = PayloadLayout::new(len as u64)?; + let padded_len: usize = usize::try_from(calc_padding(len as u64)).unwrap(); + let take: usize = padded_len - len; + let prefix_len: usize = usize::try_from(layout.prefix_len).unwrap(); + let mut padded: Vec = Vec::with_capacity(prefix_len + padded_len); + if len < EXTENDED_PREFIX_THRESHOLD as usize { + padded.extend_from_slice(&(len as u16).to_be_bytes()); + } else { + padded.extend_from_slice(&[0, 0]); + padded.extend_from_slice(&(len as u32).to_be_bytes()); } - - 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) @@ -364,7 +470,7 @@ mod tests { // Check if out manual implementation work in the same way as the std one. #[test] fn test_log2_round_down() { - let f = |x: usize| -> u32 { + let f = |x: u64| -> u32 { let x: f64 = x as f64; x.log2().floor() as u32 }; @@ -449,8 +555,8 @@ mod tests { .as_array() .unwrap() { - let len = elem[0].as_number().unwrap().as_u64().unwrap() as usize; - let pad = elem[1].as_number().unwrap().as_u64().unwrap() as usize; + let len = elem[0].as_number().unwrap().as_u64().unwrap(); + let pad = elem[1].as_number().unwrap().as_u64().unwrap(); assert_eq!(calc_padding(len), pad); } } @@ -653,6 +759,28 @@ mod tests { payload } + fn make_authenticated_v2_payload( + conversation_key: &ConversationKey, + mut padded_plaintext: Vec, + ) -> Vec { + let nonce: [u8; 32] = [0x42; 32]; + let keys: MessageKeys = get_message_keys(conversation_key, &nonce); + let mut cipher = ChaCha20::new(keys.encryption().into(), keys.nonce().into()); + cipher.apply_keystream(&mut padded_plaintext); + + let mut engine: HmacEngine = HmacEngine::new(keys.auth()); + engine.input(&nonce); + engine.input(&padded_plaintext); + let mac: [u8; 32] = engine.finalize().to_byte_array(); + + let mut payload: Vec = Vec::with_capacity(65 + padded_plaintext.len()); + payload.push(2); + payload.extend_from_slice(&nonce); + payload.extend_from_slice(&padded_plaintext); + payload.extend_from_slice(&mac); + payload + } + #[test] fn test_short_authenticated_payloads_return_error_instead_of_panicking() { // Alice is the sender; Bob is the recipient. @@ -687,26 +815,37 @@ mod tests { #[test] fn test_oversized_binary_payload_is_rejected() { - let conversation_key = ConversationKey::new([0x42; 32]); - let payload = vec![0u8; MAX_PAYLOAD_SIZE + 1]; - - let err = decrypt_to_bytes(&conversation_key, &payload).unwrap_err(); - assert_eq!(err.kind(), ErrorKind::Invalid); - assert_eq!(err.to_string(), "message too long"); + assert!(matches!( + validate_payload_size(MAX_PAYLOAD_SIZE + 1), + Err(ErrorV2::MessageTooLong) + )); } #[test] - fn test_maximum_plaintext_roundtrip() { - let conversation_key = ConversationKey::new([0x42; 32]); - let plaintext = vec![0x24; MAX_SUPPORTED_PLAINTEXT_SIZE]; - let payload = - encrypt_to_bytes_with_nonce(&conversation_key, &plaintext, [0x11; 32]).unwrap(); - - assert_eq!(payload.len(), MAX_PAYLOAD_SIZE); + fn test_maximum_payload_arithmetic() { + let layout = PayloadLayout::new(MAX_PLAINTEXT_SIZE).unwrap(); + assert_eq!(calc_padding(MAX_PLAINTEXT_SIZE), 4_294_967_296); + assert_eq!(layout.prefix_len, 6); + assert_eq!(layout.ciphertext_len, 4_294_967_302); + assert_eq!(layout.payload_len, 4_294_967_367); + assert_eq!(MAX_PAYLOAD_SIZE, 4_294_967_367); + assert_eq!(MAX_ENCODED_PAYLOAD_SIZE, 5_726_623_156); + + assert!(matches!( + PayloadLayout::new(MAX_PLAINTEXT_SIZE + 1), + Err(ErrorV2::MessageTooLong) + )); + + #[cfg(target_pointer_width = "64")] assert_eq!( - decrypt_to_bytes(&conversation_key, &payload).unwrap(), - plaintext + supported_allocation_size(MAX_PAYLOAD_SIZE).unwrap(), + MAX_PAYLOAD_SIZE as usize ); + #[cfg(target_pointer_width = "32")] + assert!(matches!( + supported_allocation_size(MAX_PAYLOAD_SIZE), + Err(ErrorV2::PlatformSizeUnsupported) + )); } #[test] @@ -718,10 +857,10 @@ mod tests { 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"); + assert!(matches!( + PayloadLayout::new(MAX_PLAINTEXT_SIZE + 1), + Err(ErrorV2::MessageTooLong) + )); } /// Composing the payload in place must be byte-identical to the @@ -732,22 +871,8 @@ mod tests { 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, + 1usize, 2, 31, 32, 33, 63, 64, 65, 100, 255, 256, 257, 1000, 4096, 4097, 65_535, + 65_536, 65_537, 100_000, ] { let plaintext: Vec = (0..len).map(|i| (i % 251) as u8).collect(); @@ -772,6 +897,83 @@ mod tests { } } + #[test] + fn test_extended_length_prefix_vectors() { + let conversation_key = ConversationKey::from_slice(&hex_decode( + "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d", + )) + .unwrap(); + let nonce: [u8; 32] = + hex_decode("0000000000000000000000000000000000000000000000000000000000000001") + .try_into() + .unwrap(); + + for (len, plaintext_hash, payload_hash) in [ + ( + 65_535usize, + "6e1bebca6a8229364a162a72ef064826c4cd7457bf54f190ef782bd9deff3e42", + "6d8c2810d1e870fbaa1f0a0937126cca837a15f9260e27060c331d70a3c0bc84", + ), + ( + 65_536, + "bf718b6f653bebc184e1479f1935b8da974d701b893afcf49e701f3e2f9f9c5a", + "b7b4edb36ba92e267d322d56d9aebc22e7fa96ff52e3c12adc07f07a43cbc616", + ), + ( + 65_537, + "008ffc88d3c96a9f307524eb361e47c5222a887fc45fa0c1fb8d429c5c23b430", + "eeb7c7c5373894ea2c1547cfd3ccb15d5a0b2d619da852e5c79df792dcc9e435", + ), + ] { + let plaintext: Vec = vec![b'a'; len]; + assert_eq!(Sha256Hash::hash(&plaintext).to_string(), plaintext_hash); + + let payload = + encrypt_to_bytes_with_nonce(&conversation_key, &plaintext, nonce).unwrap(); + let encoded = general_purpose::STANDARD.encode(&payload); + assert_eq!( + Sha256Hash::hash(encoded.as_bytes()).to_string(), + payload_hash + ); + assert_eq!( + decrypt_to_bytes(&conversation_key, &payload).unwrap(), + plaintext + ); + + let padded = pad(&plaintext).unwrap(); + if len < EXTENDED_PREFIX_THRESHOLD as usize { + assert_eq!(&padded[..2], &(len as u16).to_be_bytes()); + } else { + assert_eq!(&padded[..2], &[0, 0]); + assert_eq!(&padded[2..6], &(len as u32).to_be_bytes()); + } + } + } + + #[test] + fn test_invalid_extended_length_prefixes() { + assert!(matches!( + parse_plaintext_length(&[0, 0]), + Err(ErrorV2::InvalidPadding) + )); + + let conversation_key = ConversationKey::new([0x42; 32]); + + let mut noncanonical = vec![0u8; MIN_CIPHERTEXT_SIZE]; + noncanonical[2..6].copy_from_slice(&1u32.to_be_bytes()); + let payload = make_authenticated_v2_payload(&conversation_key, noncanonical); + let err = decrypt_to_bytes(&conversation_key, &payload).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::Invalid); + assert_eq!(err.to_string(), "invalid padding"); + + let mut mismatched = vec![0u8; MIN_CIPHERTEXT_SIZE]; + mismatched[2..6].copy_from_slice(&65_536u32.to_be_bytes()); + let payload = make_authenticated_v2_payload(&conversation_key, mismatched); + let err = decrypt_to_bytes(&conversation_key, &payload).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::Invalid); + assert_eq!(err.to_string(), "invalid padding"); + } + #[test] fn test_conversation_key_from_slice() { let bytes: [u8; 32] = [0x42; 32]; From 0c71b5b84ff9a40f4bc16a7bed4e022c82306606 Mon Sep 17 00:00:00 2001 From: Jeff Gardner <202880+erskingardner@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:20:03 +0100 Subject: [PATCH 2/2] nostr: cover NIP-44 allocation errors Exercise platform size and allocation error mappings without requiring multi-gigabyte buffers. --- nostr/src/nips/nip44/impl.rs | 36 +++++++++++++++++++++++++++--------- nostr/src/nips/nip44/v2.rs | 11 +++++++++++ 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/nostr/src/nips/nip44/impl.rs b/nostr/src/nips/nip44/impl.rs index eb598a8cb..d26f3cf93 100644 --- a/nostr/src/nips/nip44/impl.rs +++ b/nostr/src/nips/nip44/impl.rs @@ -65,6 +65,14 @@ fn allocation_failed() -> Error { Error::with_static_message(ErrorKind::Other, "failed to allocate NIP-44 payload buffer") } +fn supported_allocation_size(len: usize) -> Result { + if len > isize::MAX as usize { + return Err(unsupported_platform_size()); + } + + Ok(len) +} + fn decode_payload_version(payload: &[u8]) -> Result { // Decode one Base64 quantum so the version-specific limit runs before full allocation. let encoded_prefix = payload.get(..4).unwrap_or(payload); @@ -155,11 +163,9 @@ where T: AsRef<[u8]>, { let payload: Vec = encrypt_to_bytes_with_nonce(secret_key, public_key, content, nonce)?; - let encoded_len: usize = - base64::encoded_len(payload.len(), true).ok_or_else(unsupported_platform_size)?; - if encoded_len > isize::MAX as usize { - return Err(unsupported_platform_size()); - } + let encoded_len: usize = base64::encoded_len(payload.len(), true) + .ok_or_else(unsupported_platform_size) + .and_then(supported_allocation_size)?; let mut encoded: String = String::new(); encoded @@ -228,10 +234,8 @@ where version.validate_encoded_payload_size(payload.len() as u64)?; // Decode base64 payload - let decoded_len: usize = base64::decoded_len_estimate(payload.len()); - if decoded_len > isize::MAX as usize { - return Err(unsupported_platform_size()); - } + let decoded_len: usize = + supported_allocation_size(base64::decoded_len_estimate(payload.len()))?; let mut decoded: Vec = Vec::new(); decoded .try_reserve_exact(decoded_len) @@ -290,4 +294,18 @@ mod tests { assert_eq!(err.kind(), ErrorKind::Invalid); assert_eq!(err.to_string(), "message too long"); } + + #[test] + fn test_allocation_errors() { + let err = supported_allocation_size(usize::MAX).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::Unsupported); + assert_eq!( + err.to_string(), + "NIP-44 payload size is not supported on this platform" + ); + + let err = allocation_failed(); + assert_eq!(err.kind(), ErrorKind::Other); + assert_eq!(err.to_string(), "failed to allocate NIP-44 payload buffer"); + } } diff --git a/nostr/src/nips/nip44/v2.rs b/nostr/src/nips/nip44/v2.rs index 79f7739e0..d1365a1a9 100644 --- a/nostr/src/nips/nip44/v2.rs +++ b/nostr/src/nips/nip44/v2.rs @@ -836,6 +836,17 @@ mod tests { Err(ErrorV2::MessageTooLong) )); + let err: Error = supported_allocation_size(u64::MAX).unwrap_err().into(); + assert_eq!(err.kind(), ErrorKind::Unsupported); + assert_eq!( + err.to_string(), + "NIP-44 payload size is not supported on this platform" + ); + + let err: Error = ErrorV2::AllocationFailed.into(); + assert_eq!(err.kind(), ErrorKind::Other); + assert_eq!(err.to_string(), "failed to allocate NIP-44 payload buffer"); + #[cfg(target_pointer_width = "64")] assert_eq!( supported_allocation_size(MAX_PAYLOAD_SIZE).unwrap(),