Skip to main content

nula_core/nips/
nip44.rs

1//! [NIP-44] Encrypted Payloads (Versioned).
2//!
3//! `nula-core` ships **only the v2 algorithm** spelled out by the NIP:
4//! `secp256k1` ECDH + HKDF-SHA256 + `ChaCha20` + HMAC-SHA256 + base64.
5//! v1 is reserved by the spec and v0 is forbidden, so a single namespace
6//! [`encrypt`] / [`decrypt`] keeps the surface tight; future versions
7//! (`Version::V3`, …) can be added as opt-in helpers without touching the
8//! v2 entry points.
9//!
10//! # Threat model
11//!
12//! NIP-44 v2 provides *confidentiality* and *integrity* of the payload,
13//! plus *deniability after key compromise* (the MAC is symmetric, so a
14//! compromised key can forge ciphertexts in either direction). It does
15//! **not** provide forward secrecy, post-compromise security, or sender
16//! anonymity — those are layered via NIP-59 gift wrapping. Treat the
17//! encrypted payload as something the recipient can authenticate to
18//! themselves but not to a third party.
19//!
20//! # Padding
21//!
22//! Plaintext is padded to the next power-of-two boundary (32-byte
23//! minimum) per NIP-44 §Encryption step 4. We strictly enforce the
24//! spec's `1..=65535` plaintext range — the maximum the 2-byte length
25//! prefix can express — matching `nostr-tools` and the reference Python
26//! implementation.
27//!
28//! Heads-up on interop: `rust-nostr` 0.45 is *stricter* here. Its
29//! `pad()` caps plaintext at `65536 - 128 = 65408` bytes, so a message
30//! whose plaintext length falls in `65409..=65535` round-trips in nula
31//! (and the reference impls) but is refused by `rust-nostr`. The
32//! `plaintext_bound_follows_spec_not_rust_nostr_cap` test pins this
33//! divergence. Non-spec extensions (e.g. the 4-byte length prefix some
34//! implementations carry for >64 KiB messages) are rejected on decrypt.
35//!
36//! # Test vectors
37//!
38//! The crate's integration tests exercise the official
39//! `nip44.vectors.json` shipped by `nostr-protocol/nips`. See
40//! `tests/nip44_vectors.rs`.
41//!
42//! [NIP-44]: https://github.com/nostr-protocol/nips/blob/master/44.md
43
44// `expect` and `unwrap_in_result` are gated at the module level because
45// each `expect` here guards a length-only invariant that the
46// surrounding code has *already proved* — e.g. a 32-byte slice fed
47// into `[u8; 32]: TryFrom<&[u8]>` after a length check, or
48// `Hkdf::from_prk` over a 32-byte PRK. Spelling out an `#[allow]` per
49// call site would add ~15 lines of noise; the trade-off is that any
50// new `expect` in this module needs to come with a comment proving its
51// own infallibility (the convention is enforced by code review).
52//
53// `clippy::panic` and `clippy::missing_panics_doc` are *not* lifted at
54// module scope: the only `panic!`s are local to `MessageKeys::*` const
55// fn accessors (where `?`/`expect` are not const-stable). Those
56// methods carry their own targeted `#[allow]`.
57#![allow(
58    clippy::expect_used,
59    clippy::unwrap_in_result,
60    reason = "see module-level comment above the attribute: every expect \
61              guards a precondition the surrounding code has already \
62              checked; replacing them with `?` would force every caller \
63              to handle errors that cannot occur in practice."
64)]
65
66use base64::Engine;
67use base64::engine::general_purpose::STANDARD as BASE64;
68use chacha20::ChaCha20;
69use chacha20::cipher::{KeyIvInit, StreamCipher};
70use hkdf::Hkdf;
71use hmac::digest::KeyInit;
72use hmac::{Hmac, Mac};
73use secp256k1::{Parity, ecdh};
74use sha2::Sha256;
75use thiserror::Error;
76use zeroize::{Zeroize, ZeroizeOnDrop};
77
78use crate::key::{PublicKey, SecretKey};
79use crate::util::rng;
80
81/// Wire version byte for NIP-44 v2.
82pub const VERSION: u8 = 2;
83
84/// Salt fed into the HKDF-Extract step ("nip44-v2", per spec).
85const HKDF_SALT: &[u8] = b"nip44-v2";
86
87/// Spec-mandated plaintext range (§Encryption step 4).
88const MIN_PLAINTEXT_BYTES: usize = 1;
89const MAX_PLAINTEXT_BYTES: usize = 65_535;
90
91/// Wire-level constants (§Encryption step 7, §Decryption step 2).
92const NONCE_BYTES: usize = 32;
93const HMAC_BYTES: usize = 32;
94const VERSION_BYTE: usize = 1;
95
96/// Min/max payload length **after** base64 decoding, per §Decryption.
97const MIN_PAYLOAD_BYTES: usize = 99;
98const MAX_PAYLOAD_BYTES: usize = 65_603;
99
100/// Min/max base64-encoded length (§Decryption step 2 hard limit).
101const MIN_PAYLOAD_CHARS: usize = 132;
102const MAX_PAYLOAD_CHARS: usize = 87_472;
103
104/// HKDF-Expand output sliced into `ChaCha20` key (0..32), `ChaCha20`
105/// nonce (32..44), and HMAC key (44..76). Sub-slicing happens via
106/// `split_at` inside the [`MessageKeys`] accessors so the offsets stay
107/// close to the only place that consumes them.
108const MESSAGE_KEYS_BYTES: usize = 76;
109const CHACHA_KEY_BYTES: usize = 32;
110const CHACHA_NONCE_BYTES: usize = 12;
111const MESSAGE_KEY_HMAC_OFFSET: usize = CHACHA_KEY_BYTES + CHACHA_NONCE_BYTES;
112
113/// Errors raised by [`encrypt`], [`decrypt`], and [`ConversationKey::derive`].
114#[derive(Debug, Error)]
115#[non_exhaustive]
116pub enum Nip44Error {
117    /// Plaintext is empty (NIP-44 v2 requires at least 1 byte).
118    #[error("plaintext is empty")]
119    EmptyPlaintext,
120    /// Plaintext exceeds the v2 cap of `65_535` bytes.
121    #[error("plaintext too long: {0} bytes (max {MAX_PLAINTEXT_BYTES})")]
122    PlaintextTooLong(usize),
123    /// The base64 string is shorter than the smallest possible payload.
124    #[error("payload too short: {0} characters (min {MIN_PAYLOAD_CHARS})")]
125    PayloadTooShort(usize),
126    /// The base64 string exceeds the v2 cap.
127    #[error("payload too long: {0} characters (max {MAX_PAYLOAD_CHARS})")]
128    PayloadTooLong(usize),
129    /// The decoded payload is shorter than the smallest possible
130    /// (1-byte version + 32-byte nonce + 34-byte ciphertext + 32-byte MAC).
131    #[error("decoded payload too short: {0} bytes")]
132    DecodedTooShort(usize),
133    /// The decoded payload exceeds the v2 cap.
134    #[error("decoded payload too long: {0} bytes")]
135    DecodedTooLong(usize),
136    /// The version byte is not `0x02`. NIP-44 reserves a leading `'#'` for
137    /// future non-base64 framings; either case ends up here.
138    #[error("unsupported NIP-44 version byte: {0:#04x}")]
139    UnsupportedVersion(u8),
140    /// `base64` could not decode the payload.
141    #[error("invalid base64: {0}")]
142    InvalidBase64(#[from] base64::DecodeError),
143    /// HMAC verification failed: the payload was tampered with or the
144    /// conversation key is wrong.
145    #[error("invalid MAC")]
146    InvalidMac,
147    /// The padded plaintext could not be unpadded according to the spec.
148    #[error("invalid padding")]
149    InvalidPadding,
150    /// The decrypted plaintext was not valid UTF-8 (NIP-44 carries
151    /// arbitrary bytes, but the public string API insists on UTF-8 so
152    /// callers cannot accidentally hand non-text to JSON serialisers).
153    #[error("plaintext is not valid UTF-8")]
154    InvalidUtf8,
155    /// Failed to read the operating system entropy source.
156    #[error("entropy source failed: {0}")]
157    Rng(#[from] rng::RngError),
158}
159
160/// 32-byte HKDF-Extract result over the secp256k1 ECDH shared X coordinate.
161///
162/// Two parties holding the same `(secret, peer_public)` pair *and* the
163/// reverse pair derive **bit-identical** conversation keys: NIP-44 uses
164/// the unhashed shared X coordinate, which is symmetric in
165/// `(sk, pk_peer)` ↔ `(sk_peer, pk)`. Cache and reuse the
166/// [`ConversationKey`] across messages between the same two pubkeys —
167/// it does not depend on the per-message nonce.
168///
169/// The struct zeroes its bytes on drop; clone explicitly when handing
170/// the key to long-lived state.
171#[derive(Clone, ZeroizeOnDrop)]
172pub struct ConversationKey([u8; 32]);
173
174impl std::fmt::Debug for ConversationKey {
175    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176        f.write_str("ConversationKey(<redacted>)")
177    }
178}
179
180impl ConversationKey {
181    /// Derive a conversation key from `(secret, peer_public)`.
182    ///
183    /// The derivation is `HKDF-Extract(salt = "nip44-v2", ikm = ecdh_x)`
184    /// where `ecdh_x` is the unhashed 32-byte X coordinate of the
185    /// shared point produced by secp256k1 ECDH.
186    #[must_use]
187    pub fn derive(secret: &SecretKey, peer_public: &PublicKey) -> Self {
188        // NIP-44 §Encryption step 1: lift the peer's x-only key with
189        // even parity and run ECDH. The parity choice is convention —
190        // both even and odd parities produce the same X coordinate, but
191        // the ecosystem (rust-nostr, nostr-tools, paulmillr/nip44) uses
192        // even, so we match.
193        let normalized =
194            secp256k1::PublicKey::from_x_only_public_key(*peer_public.as_inner(), Parity::Even);
195        let ssp = ecdh::shared_secret_point(&normalized, secret.as_inner());
196
197        // Take only the X coordinate (first 32 bytes of the 64-byte
198        // serialized point). This must be unhashed per the spec.
199        let mut shared_x = [0u8; 32];
200        shared_x.copy_from_slice(&ssp[..32]);
201
202        // HKDF-Extract is just `HMAC-SHA256(salt, ikm)`. We use the
203        // `hkdf` crate's typed `Hkdf::extract` for clarity.
204        let (prk, _) = Hkdf::<Sha256>::extract(Some(HKDF_SALT), &shared_x);
205
206        // Wipe the local copy of the shared X coordinate before
207        // returning. The compiler cannot elide this because `prk`
208        // doesn't depend on `shared_x` after `extract` returns.
209        shared_x.zeroize();
210
211        let mut bytes = [0u8; 32];
212        bytes.copy_from_slice(prk.as_slice());
213        Self(bytes)
214    }
215
216    /// Construct from raw 32 bytes.
217    ///
218    /// Useful for deserializing test vectors or persisted state. The
219    /// bytes must be a valid HKDF-SHA256 PRK; we do not (and cannot)
220    /// validate that property, so callers MUST treat any value coming
221    /// from outside [`Self::derive`] as untrusted.
222    #[must_use]
223    pub const fn from_byte_array(bytes: [u8; 32]) -> Self {
224        Self(bytes)
225    }
226
227    /// View as raw 32 bytes (for serialization / vectors).
228    #[must_use]
229    pub const fn as_byte_array(&self) -> &[u8; 32] {
230        &self.0
231    }
232}
233
234/// Per-message HKDF-Expand output split into `ChaCha20` key, `ChaCha20`
235/// nonce, and HMAC key. Lives in this module only; we never expose the
236/// individual sub-keys.
237struct MessageKeys([u8; MESSAGE_KEYS_BYTES]);
238
239impl MessageKeys {
240    fn derive(conversation_key: &ConversationKey, nonce: &[u8; NONCE_BYTES]) -> Self {
241        let hk = Hkdf::<Sha256>::from_prk(conversation_key.as_byte_array())
242            .expect("PRK is exactly 32 bytes — HKDF::from_prk only fails on length");
243        let mut okm = [0u8; MESSAGE_KEYS_BYTES];
244        hk.expand(nonce, &mut okm)
245            .expect("76 bytes <= 255*32 — Hkdf::expand only fails when the OKM exceeds that");
246        Self(okm)
247    }
248
249    /// `ChaCha20` key slice (bytes 0..32 of the 76-byte OKM).
250    ///
251    /// # Panics
252    ///
253    /// Statically unreachable: [`Self`]'s inner buffer is exactly
254    /// `MESSAGE_KEYS_BYTES` long by construction, so `first_chunk::<32>`
255    /// always returns `Some`. The `panic!` is there because `?` and
256    /// `expect` are not yet `const`-stable, and we want this accessor
257    /// to be `const` for use inside other `const fn`.
258    #[allow(
259        clippy::panic,
260        clippy::missing_panics_doc,
261        reason = "panic guard for a const fn that operates on a fixed-size buffer; the # Panics doc explains the guarantee"
262    )]
263    const fn chacha_key(&self) -> &[u8; CHACHA_KEY_BYTES] {
264        match self.0.first_chunk::<CHACHA_KEY_BYTES>() {
265            Some(arr) => arr,
266            None => panic!("OKM is 76 bytes; first 32 always present"),
267        }
268    }
269
270    /// `ChaCha20` nonce slice (bytes 32..44 of the 76-byte OKM).
271    ///
272    /// # Panics
273    ///
274    /// Statically unreachable for the same reason as [`Self::chacha_key`].
275    #[allow(
276        clippy::panic,
277        clippy::missing_panics_doc,
278        reason = "panic guard for a const fn that operates on a fixed-size buffer; the # Panics doc explains the guarantee"
279    )]
280    const fn chacha_nonce(&self) -> &[u8; CHACHA_NONCE_BYTES] {
281        let (_, tail) = self.0.split_at(CHACHA_KEY_BYTES);
282        match tail.first_chunk::<CHACHA_NONCE_BYTES>() {
283            Some(arr) => arr,
284            None => panic!("OKM tail is 44 bytes; first 12 always present"),
285        }
286    }
287
288    /// HMAC key slice (bytes 44..76 of the 76-byte OKM).
289    const fn hmac_key(&self) -> &[u8] {
290        let (_, tail) = self.0.split_at(MESSAGE_KEY_HMAC_OFFSET);
291        tail
292    }
293}
294
295impl Drop for MessageKeys {
296    fn drop(&mut self) {
297        self.0.zeroize();
298    }
299}
300
301/// Encrypt `plaintext` to `peer_public_key` and return the base64 NIP-44 v2 payload.
302///
303/// The 32-byte nonce is sourced from the OS entropy pool. To exercise
304/// known-answer test vectors where the nonce must be controlled, use
305/// [`encrypt_with_nonce`].
306///
307/// # Errors
308///
309/// Returns [`Nip44Error::EmptyPlaintext`] for an empty input,
310/// [`Nip44Error::PlaintextTooLong`] for inputs above 65 535 bytes, or
311/// [`Nip44Error::Rng`] if the OS RNG is unavailable.
312#[cfg_attr(
313    feature = "tracing",
314    tracing::instrument(
315        level = "debug",
316        name = "nula.nip44.encrypt",
317        skip(secret, peer_public_key, plaintext),
318        fields(
319            nostr.nip = 44_u16,
320            nostr.encryption.plaintext_size = plaintext.len(),
321        ),
322    )
323)]
324pub fn encrypt(
325    secret: &SecretKey,
326    peer_public_key: &PublicKey,
327    plaintext: &str,
328) -> Result<String, Nip44Error> {
329    let mut nonce = [0u8; NONCE_BYTES];
330    rng::fill_bytes(&mut nonce)?;
331    let conversation_key = ConversationKey::derive(secret, peer_public_key);
332    encrypt_inner(&conversation_key, plaintext, &nonce)
333}
334
335/// Encrypt with an explicit 32-byte nonce.
336///
337/// Reserved for round-tripping known-answer test vectors and for
338/// callers that derive a deterministic per-message nonce from a
339/// higher-level protocol. Production code should call [`encrypt`] and
340/// let the OS RNG pick the nonce.
341///
342/// # Errors
343///
344/// Same as [`encrypt`]: [`Nip44Error::EmptyPlaintext`] /
345/// [`Nip44Error::PlaintextTooLong`].
346#[cfg_attr(
347    feature = "tracing",
348    tracing::instrument(
349        level = "debug",
350        name = "nula.nip44.encrypt_with_nonce",
351        skip(conversation_key, plaintext, nonce),
352        fields(
353            nostr.nip = 44_u16,
354            nostr.encryption.plaintext_size = plaintext.len(),
355        ),
356    )
357)]
358pub fn encrypt_with_nonce(
359    conversation_key: &ConversationKey,
360    plaintext: &str,
361    nonce: &[u8; NONCE_BYTES],
362) -> Result<String, Nip44Error> {
363    encrypt_inner(conversation_key, plaintext, nonce)
364}
365
366fn encrypt_inner(
367    conversation_key: &ConversationKey,
368    plaintext: &str,
369    nonce: &[u8; NONCE_BYTES],
370) -> Result<String, Nip44Error> {
371    let mks = MessageKeys::derive(conversation_key, nonce);
372
373    // Pad. The padded buffer is `prefix(2) || plaintext || zeros`.
374    let mut buffer = pad(plaintext.as_bytes())?;
375
376    // Encrypt in place.
377    let mut cipher = ChaCha20::new(mks.chacha_key().into(), mks.chacha_nonce().into());
378    cipher.apply_keystream(&mut buffer);
379
380    // HMAC over `nonce || ciphertext` (NIP-44 §Encryption step 6 AAD).
381    let hmac = compute_hmac(mks.hmac_key(), nonce, &buffer);
382
383    // Compose `version || nonce || ciphertext || hmac` and base64 it.
384    let mut payload = Vec::with_capacity(VERSION_BYTE + NONCE_BYTES + buffer.len() + HMAC_BYTES);
385    payload.push(VERSION);
386    payload.extend_from_slice(nonce);
387    payload.extend_from_slice(&buffer);
388    payload.extend_from_slice(&hmac);
389
390    Ok(BASE64.encode(payload))
391}
392
393/// Decrypt a NIP-44 v2 payload from `peer_public_key`.
394///
395/// `payload` must be the exact base64-encoded string carried by the
396/// outer event's `content` (or `tags`); do not pre-decode.
397///
398/// # Errors
399///
400/// Returns [`Nip44Error::InvalidMac`] when the payload was tampered with or
401/// when the conversation key is wrong, [`Nip44Error::UnsupportedVersion`]
402/// for any byte other than `0x02`, [`Nip44Error::InvalidPadding`] for a
403/// malformed padded plaintext, and [`Nip44Error::InvalidUtf8`] when the
404/// plaintext is not valid UTF-8.
405#[cfg_attr(
406    feature = "tracing",
407    tracing::instrument(
408        level = "debug",
409        name = "nula.nip44.decrypt",
410        skip(secret, peer_public_key, payload),
411        fields(
412            nostr.nip = 44_u16,
413            nostr.encryption.ciphertext_size = payload.len(),
414        ),
415    )
416)]
417pub fn decrypt(
418    secret: &SecretKey,
419    peer_public_key: &PublicKey,
420    payload: &str,
421) -> Result<String, Nip44Error> {
422    let conversation_key = ConversationKey::derive(secret, peer_public_key);
423    decrypt_with_conversation_key(&conversation_key, payload)
424}
425
426/// Decrypt with a pre-derived conversation key.
427///
428/// Use this when the same `(secret, peer)` pair is reused across many
429/// messages and re-deriving the conversation key per call is wasteful.
430///
431/// # Errors
432///
433/// See [`decrypt`].
434///
435/// # Panics
436///
437/// Will not panic in practice: every `expect` inside the body guards a
438/// length invariant that the surrounding bounds checks have already
439/// proved (e.g. a 32-byte slice fed into `[u8; 32]: TryFrom<&[u8]>`).
440#[cfg_attr(
441    feature = "tracing",
442    tracing::instrument(
443        level = "debug",
444        name = "nula.nip44.decrypt_with_conversation_key",
445        skip(conversation_key, payload),
446        fields(
447            nostr.nip = 44_u16,
448            nostr.encryption.ciphertext_size = payload.len(),
449        ),
450    )
451)]
452pub fn decrypt_with_conversation_key(
453    conversation_key: &ConversationKey,
454    payload: &str,
455) -> Result<String, Nip44Error> {
456    let plen = payload.len();
457    if plen < MIN_PAYLOAD_CHARS {
458        return Err(Nip44Error::PayloadTooShort(plen));
459    }
460    if plen > MAX_PAYLOAD_CHARS {
461        return Err(Nip44Error::PayloadTooLong(plen));
462    }
463
464    // NIP-44 §Decryption step 1: a leading `#` flags a future
465    // non-base64 framing. Surface it as `UnsupportedVersion` so callers
466    // can distinguish it from a corrupted base64 string.
467    if payload.starts_with('#') {
468        return Err(Nip44Error::UnsupportedVersion(b'#'));
469    }
470
471    let bytes = BASE64.decode(payload)?;
472    let blen = bytes.len();
473    if blen < MIN_PAYLOAD_BYTES {
474        return Err(Nip44Error::DecodedTooShort(blen));
475    }
476    if blen > MAX_PAYLOAD_BYTES {
477        return Err(Nip44Error::DecodedTooLong(blen));
478    }
479
480    // Carve `version || nonce || ciphertext || mac` from the buffer
481    // using `split_at` chains so every slice index is statically
482    // checked. The length-bounds above prove every split is in-range.
483    let (version_slice, rest) = bytes.split_at(VERSION_BYTE);
484    let version = *version_slice
485        .first()
486        .expect("VERSION_BYTE = 1, slice is non-empty after MIN_PAYLOAD_BYTES check");
487    if version != VERSION {
488        return Err(Nip44Error::UnsupportedVersion(version));
489    }
490
491    let (nonce_slice, body_with_mac) = rest.split_at(NONCE_BYTES);
492    let nonce: [u8; NONCE_BYTES] = nonce_slice
493        .try_into()
494        .expect("NONCE_BYTES = 32 by construction");
495    let mac_start = body_with_mac.len() - HMAC_BYTES;
496    let (ciphertext, mac) = body_with_mac.split_at(mac_start);
497
498    let mks = MessageKeys::derive(conversation_key, &nonce);
499
500    // Verify HMAC in constant time before touching ChaCha20: prevents
501    // padding-oracle / chosen-ciphertext attacks against the cipher
502    // state machine. `verify_hmac` delegates to
503    // [`hmac::Mac::verify_slice`], the standard library idiom for
504    // constant-time MAC comparison.
505    if !verify_hmac(mks.hmac_key(), &nonce, ciphertext, mac) {
506        return Err(Nip44Error::InvalidMac);
507    }
508
509    // Decrypt and unpad.
510    let mut buffer = ciphertext.to_vec();
511    let mut cipher = ChaCha20::new(mks.chacha_key().into(), mks.chacha_nonce().into());
512    cipher.apply_keystream(&mut buffer);
513
514    let unpadded = unpad(&buffer)?;
515    String::from_utf8(unpadded.to_vec()).map_err(|_| Nip44Error::InvalidUtf8)
516}
517
518fn pad(plaintext: &[u8]) -> Result<Vec<u8>, Nip44Error> {
519    let len = plaintext.len();
520    if len < MIN_PLAINTEXT_BYTES {
521        return Err(Nip44Error::EmptyPlaintext);
522    }
523    if len > MAX_PLAINTEXT_BYTES {
524        return Err(Nip44Error::PlaintextTooLong(len));
525    }
526    let padded_len = padded_length(len);
527    let mut out = Vec::with_capacity(2 + padded_len);
528    // `len <= MAX_PLAINTEXT_BYTES = 65_535` proven on the previous line,
529    // and `MAX_PLAINTEXT_BYTES + 1 == u16::MAX + 1`. The cast cannot
530    // truncate.
531    #[allow(
532        clippy::cast_possible_truncation,
533        reason = "len <= 65535 is enforced two lines above"
534    )]
535    let prefix = (len as u16).to_be_bytes();
536    out.extend_from_slice(&prefix);
537    out.extend_from_slice(plaintext);
538    out.resize(2 + padded_len, 0);
539    Ok(out)
540}
541
542fn unpad(padded: &[u8]) -> Result<&[u8], Nip44Error> {
543    let header: &[u8; 2] = padded
544        .first_chunk::<2>()
545        .ok_or(Nip44Error::InvalidPadding)?;
546    let prefix = u16::from_be_bytes(*header) as usize;
547    if prefix < MIN_PLAINTEXT_BYTES {
548        return Err(Nip44Error::InvalidPadding);
549    }
550    if prefix > MAX_PLAINTEXT_BYTES {
551        return Err(Nip44Error::InvalidPadding);
552    }
553    let expected_len = 2 + padded_length(prefix);
554    if padded.len() != expected_len {
555        return Err(Nip44Error::InvalidPadding);
556    }
557    padded.get(2..2 + prefix).ok_or(Nip44Error::InvalidPadding)
558}
559
560/// Compute padded length per NIP-44 v2 spec.
561///
562/// - `len <= 32` → 32 (single chunk)
563/// - else, round up to a chunk size of `next_pow2(len-1) / 8` (or 32 if smaller)
564const fn padded_length(len: usize) -> usize {
565    if len <= 32 {
566        return 32;
567    }
568    let next_power = 1usize << (log2_floor(len - 1) + 1);
569    let chunk = if next_power <= 256 {
570        32
571    } else {
572        next_power / 8
573    };
574    chunk * (((len - 1) / chunk) + 1)
575}
576
577/// `floor(log2(x))` for `x > 0`. Defined as 0 for `x == 0` to keep the
578/// arithmetic in [`padded_length`] total. Equivalent to `(usize::BITS-1) - x.leading_zeros()`.
579const fn log2_floor(x: usize) -> u32 {
580    if x == 0 {
581        0
582    } else {
583        (usize::BITS - 1) - x.leading_zeros()
584    }
585}
586
587fn compute_hmac(key: &[u8], nonce: &[u8], ciphertext: &[u8]) -> [u8; HMAC_BYTES] {
588    // `Hmac::new_from_slice` only fails for keys longer than the block
589    // size of the underlying hash, but HMAC-SHA256 has no upper bound
590    // (it just rehashes oversized keys). 32-byte keys are well below.
591    let mut mac =
592        <Hmac<Sha256> as KeyInit>::new_from_slice(key).expect("HMAC-SHA256 accepts any key length");
593    mac.update(nonce);
594    mac.update(ciphertext);
595    mac.finalize().into_bytes().into()
596}
597
598/// Verify an HMAC-SHA256 tag in constant time against `nonce || ciphertext`.
599///
600/// Returns `true` iff the supplied `tag` matches the freshly-computed
601/// MAC over the same inputs. Internally delegates to
602/// [`hmac::Mac::verify_slice`], which is guaranteed constant-time by
603/// contract — we keep this thin wrapper so the decrypt call site reads
604/// as a single statement and so the compiler never sees an explicit
605/// byte-level comparison loop that vectorisers could short-circuit.
606fn verify_hmac(key: &[u8], nonce: &[u8], ciphertext: &[u8], tag: &[u8]) -> bool {
607    let mut mac =
608        <Hmac<Sha256> as KeyInit>::new_from_slice(key).expect("HMAC-SHA256 accepts any key length");
609    mac.update(nonce);
610    mac.update(ciphertext);
611    mac.verify_slice(tag).is_ok()
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617    use crate::key::Keys;
618
619    fn key_pair_a() -> Keys {
620        Keys::parse("0000000000000000000000000000000000000000000000000000000000000001").unwrap()
621    }
622
623    fn key_pair_b() -> Keys {
624        Keys::parse("0000000000000000000000000000000000000000000000000000000000000002").unwrap()
625    }
626
627    #[test]
628    fn round_trip_short_message() {
629        let a = key_pair_a();
630        let b = key_pair_b();
631        let payload = encrypt(a.secret_key(), b.public_key(), "hello, nostr").unwrap();
632        let recovered = decrypt(b.secret_key(), a.public_key(), &payload).unwrap();
633        assert_eq!(recovered, "hello, nostr");
634    }
635
636    #[test]
637    fn round_trip_min_size() {
638        let a = key_pair_a();
639        let b = key_pair_b();
640        let payload = encrypt(a.secret_key(), b.public_key(), "x").unwrap();
641        let recovered = decrypt(b.secret_key(), a.public_key(), &payload).unwrap();
642        assert_eq!(recovered, "x");
643    }
644
645    #[test]
646    fn round_trip_max_size() {
647        let a = key_pair_a();
648        let b = key_pair_b();
649        let plaintext = "a".repeat(MAX_PLAINTEXT_BYTES);
650        let payload = encrypt(a.secret_key(), b.public_key(), &plaintext).unwrap();
651        let recovered = decrypt(b.secret_key(), a.public_key(), &payload).unwrap();
652        assert_eq!(recovered, plaintext);
653    }
654
655    #[test]
656    fn empty_plaintext_rejected() {
657        let a = key_pair_a();
658        let b = key_pair_b();
659        let err = encrypt(a.secret_key(), b.public_key(), "").unwrap_err();
660        assert!(matches!(err, Nip44Error::EmptyPlaintext));
661    }
662
663    #[test]
664    fn oversize_plaintext_rejected() {
665        let a = key_pair_a();
666        let b = key_pair_b();
667        let plaintext = "a".repeat(MAX_PLAINTEXT_BYTES + 1);
668        let err = encrypt(a.secret_key(), b.public_key(), &plaintext).unwrap_err();
669        assert!(matches!(err, Nip44Error::PlaintextTooLong(_)));
670    }
671
672    #[test]
673    fn plaintext_bound_follows_spec_not_rust_nostr_cap() {
674        // nula follows the NIP-44 spec's `1..=65535` plaintext range (the
675        // maximum the 2-byte length prefix can express), matching
676        // `nostr-tools` and the reference Python implementation.
677        //
678        // `rust-nostr` 0.45 is *stricter*: its `pad()` rejects plaintext
679        // longer than `65536 - 128 = 65408` bytes (`MessageTooLong`).
680        // This test pins nula's spec-faithful behaviour across the
681        // divergence window so the difference stays intentional and
682        // visible: every length in `[65408, 65409, 65535]` must
683        // round-trip in nula even though `rust-nostr` refuses the upper
684        // two.
685        let a = key_pair_a();
686        let b = key_pair_b();
687        for len in [65_408_usize, 65_409, MAX_PLAINTEXT_BYTES] {
688            let plaintext = "a".repeat(len);
689            let payload = encrypt(a.secret_key(), b.public_key(), &plaintext).unwrap_or_else(|e| {
690                panic!(
691                    "nula must accept {len}-byte plaintext (spec max {MAX_PLAINTEXT_BYTES}): {e:?}"
692                )
693            });
694            let recovered = decrypt(b.secret_key(), a.public_key(), &payload).unwrap();
695            assert_eq!(recovered.len(), len);
696        }
697        // One byte past the spec maximum is rejected by nula too.
698        let err = encrypt(
699            a.secret_key(),
700            b.public_key(),
701            &"a".repeat(MAX_PLAINTEXT_BYTES + 1),
702        )
703        .unwrap_err();
704        assert!(matches!(err, Nip44Error::PlaintextTooLong(_)));
705    }
706
707    #[test]
708    fn conversation_key_is_symmetric() {
709        let a = key_pair_a();
710        let b = key_pair_b();
711        let key_ab = ConversationKey::derive(a.secret_key(), b.public_key());
712        let key_ba = ConversationKey::derive(b.secret_key(), a.public_key());
713        assert_eq!(key_ab.as_byte_array(), key_ba.as_byte_array());
714    }
715
716    #[test]
717    fn tampered_mac_is_detected() {
718        let a = key_pair_a();
719        let b = key_pair_b();
720        let payload = encrypt(a.secret_key(), b.public_key(), "secret").unwrap();
721        // Flip a bit in the last char (which lands inside the HMAC after
722        // base64 decoding).
723        let mut bytes: Vec<u8> = payload.into_bytes();
724        let last = bytes.len() - 2;
725        bytes[last] = if bytes[last] == b'A' { b'B' } else { b'A' };
726        let tampered = String::from_utf8(bytes).unwrap();
727        let err = decrypt(b.secret_key(), a.public_key(), &tampered).unwrap_err();
728        // Either the base64 still parses but HMAC fails, or base64 itself
729        // chokes — either way the tamper is caught.
730        assert!(matches!(
731            err,
732            Nip44Error::InvalidMac | Nip44Error::InvalidBase64(_)
733        ));
734    }
735
736    #[test]
737    fn unsupported_version_byte_is_rejected() {
738        // Construct a syntactically-valid base64 payload that decodes to
739        // something starting with `0x01` (reserved-undefined version).
740        let mut bogus = vec![0x01_u8; MIN_PAYLOAD_BYTES];
741        // Fill with non-zero so the length checks pass. The `i & 0xff`
742        // mask makes the truncation cast lossless by construction.
743        #[allow(
744            clippy::cast_possible_truncation,
745            reason = "`i & 0xff` always fits in u8"
746        )]
747        for (i, b) in bogus.iter_mut().enumerate().skip(1) {
748            *b = (i & 0xff) as u8;
749        }
750        let s = BASE64.encode(&bogus);
751        let key = ConversationKey::from_byte_array([0u8; 32]);
752        let err = decrypt_with_conversation_key(&key, &s).unwrap_err();
753        assert!(matches!(err, Nip44Error::UnsupportedVersion(0x01)));
754    }
755
756    #[test]
757    fn padded_length_matches_official_vectors() {
758        // Subset of the official `nip44.vectors.json` `calc_padded_len`
759        // entries — sanity check before the integration-test suite
760        // hammers the full vector set.
761        assert_eq!(padded_length(1), 32);
762        assert_eq!(padded_length(16), 32);
763        assert_eq!(padded_length(32), 32);
764        assert_eq!(padded_length(33), 64);
765        assert_eq!(padded_length(64), 64);
766        assert_eq!(padded_length(65), 96);
767        assert_eq!(padded_length(100), 128);
768        assert_eq!(padded_length(200), 224);
769        assert_eq!(padded_length(250), 256);
770        assert_eq!(padded_length(320), 320);
771        assert_eq!(padded_length(384), 384);
772        assert_eq!(padded_length(400), 448);
773        assert_eq!(padded_length(515), 640);
774        assert_eq!(padded_length(900), 1024);
775        assert_eq!(padded_length(1020), 1024);
776        assert_eq!(padded_length(65_536 - 1), 65_536);
777    }
778
779    #[test]
780    fn payload_too_short_is_rejected() {
781        let key = ConversationKey::from_byte_array([0u8; 32]);
782        let err = decrypt_with_conversation_key(&key, "AAAA").unwrap_err();
783        assert!(matches!(err, Nip44Error::PayloadTooShort(_)));
784    }
785
786    #[test]
787    fn future_framing_marker_is_rejected() {
788        let key = ConversationKey::from_byte_array([0u8; 32]);
789        // First check: the per-payload length cap rejects the input
790        // before even looking at the `#` framing marker.
791        let oversize = format!("#{}", "A".repeat(MAX_PAYLOAD_CHARS));
792        let err = decrypt_with_conversation_key(&key, &oversize).unwrap_err();
793        assert!(matches!(err, Nip44Error::PayloadTooLong(_)));
794        // Second check: a payload that *fits* the length window but
795        // starts with the future-framing marker surfaces as
796        // `UnsupportedVersion(b'#')`.
797        let in_range = format!("#{}", "A".repeat(MIN_PAYLOAD_CHARS - 1));
798        let err_in_range = decrypt_with_conversation_key(&key, &in_range).unwrap_err();
799        assert!(matches!(err_in_range, Nip44Error::UnsupportedVersion(b'#')));
800    }
801}