Skip to main content

nostro2_nips/
nip_44.rs

1use base64::engine::{Engine as _, general_purpose};
2use chacha20::cipher::{KeyIvInit, StreamCipher};
3use hmac::{KeyInit, Mac};
4use zeroize::Zeroize;
5
6#[derive(Debug)]
7pub enum Nip44Error {
8    SharedSecretError,
9    FromHexError(nostro2_traits::hex::HexError),
10    NostrNoteError(nostro2::errors::NostrErrors),
11    InvalidLength,
12    Base64DecodingError(base64::DecodeError),
13    FromUtf8Error(std::str::Utf8Error),
14    HkdfError,
15    HmacError,
16    SliceError(chacha20::cipher::InvalidLength),
17    InvalidPrefixLen,
18    FromArrayError(std::array::TryFromSliceError),
19    BufferTooSmall,
20    FromIntError(std::num::TryFromIntError),
21    /// The payload's version byte is not one this library can decrypt.
22    UnknownVersion(u8),
23    /// The authentication tag did not match — payload is forged or corrupt.
24    MacMismatch,
25}
26
27impl std::fmt::Display for Nip44Error {
28    #[allow(unknown_lints, crappy)]
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self {
31            Self::SharedSecretError => f.write_str("shared secret error"),
32            Self::FromHexError(e) => write!(f, "hex decoding error: {e}"),
33            Self::NostrNoteError(e) => write!(f, "{e}"),
34            Self::InvalidLength => f.write_str("invalid input length"),
35            Self::Base64DecodingError(e) => write!(f, "base64 decoding error: {e}"),
36            Self::FromUtf8Error(e) => write!(f, "UTF-8 conversion error: {e}"),
37            Self::HkdfError => f.write_str("HKDF key derivation failed"),
38            Self::HmacError => f.write_str("HMAC failure"),
39            Self::SliceError(e) => write!(f, "ChaCha20 slice error: {e}"),
40            Self::InvalidPrefixLen => f.write_str("invalid length prefix"),
41            Self::FromArrayError(e) => write!(f, "decryption error: {e}"),
42            Self::BufferTooSmall => f.write_str("buffer too small"),
43            Self::FromIntError(e) => write!(f, "encryption error: {e}"),
44            Self::UnknownVersion(v) => write!(f, "unsupported NIP-44 version byte: {v:#04x}"),
45            Self::MacMismatch => f.write_str("MAC mismatch: payload not authentic"),
46        }
47    }
48}
49
50impl std::error::Error for Nip44Error {
51    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
52        match self {
53            Self::FromHexError(e) => Some(e),
54            Self::NostrNoteError(e) => Some(e),
55            Self::Base64DecodingError(e) => Some(e),
56            Self::FromUtf8Error(e) => Some(e),
57            Self::FromArrayError(e) => Some(e),
58            Self::FromIntError(e) => Some(e),
59            _ => None,
60        }
61    }
62}
63
64impl From<nostro2_traits::hex::HexError> for Nip44Error {
65    fn from(e: nostro2_traits::hex::HexError) -> Self {
66        Self::FromHexError(e)
67    }
68}
69impl From<nostro2::errors::NostrErrors> for Nip44Error {
70    fn from(e: nostro2::errors::NostrErrors) -> Self {
71        Self::NostrNoteError(e)
72    }
73}
74impl From<base64::DecodeError> for Nip44Error {
75    fn from(e: base64::DecodeError) -> Self {
76        Self::Base64DecodingError(e)
77    }
78}
79impl From<std::str::Utf8Error> for Nip44Error {
80    fn from(e: std::str::Utf8Error) -> Self {
81        Self::FromUtf8Error(e)
82    }
83}
84impl From<chacha20::cipher::InvalidLength> for Nip44Error {
85    fn from(e: chacha20::cipher::InvalidLength) -> Self {
86        Self::SliceError(e)
87    }
88}
89impl From<std::array::TryFromSliceError> for Nip44Error {
90    fn from(e: std::array::TryFromSliceError) -> Self {
91        Self::FromArrayError(e)
92    }
93}
94impl From<std::num::TryFromIntError> for Nip44Error {
95    fn from(e: std::num::TryFromIntError) -> Self {
96        Self::FromIntError(e)
97    }
98}
99
100/// NIP-44 version 2 — the spec-compliant format (secp256k1 ECDH, HKDF,
101/// `ChaCha20`, HMAC-SHA256). Interoperates with Primal, Iris, and every other
102/// conformant client.
103pub const VERSION_V2: u8 = 0x02;
104
105/// Legacy nostro2 format (`b"1"`).
106///
107/// Produced by nostro2-nips <= 0.3.0. **Not** spec-compliant and not
108/// interoperable, but retained for decrypt so existing stored data and old
109/// peers keep working. New messages are never written in this format.
110pub const VERSION_LEGACY: u8 = 0x31;
111
112/// Per-message key material derived from the conversation key and nonce
113/// (NIP-44 `get_message_keys`).
114pub struct MessageKeys {
115    chacha_key: zeroize::Zeroizing<[u8; 32]>,
116    chacha_nonce: zeroize::Zeroizing<[u8; 12]>,
117    hmac_key: zeroize::Zeroizing<[u8; 32]>,
118}
119
120pub trait Nip44: nostro2::NostrKeypair {
121    /// Computes the shared secret used for encryption and decryption.
122    ///
123    /// # Errors
124    /// Returns `Nip44Error::SharedSecretError` if the ECDH computation fails.
125    fn shared_secret(&self, peer_pubkey: &str) -> Result<zeroize::Zeroizing<[u8; 32]>, Nip44Error> {
126        Ok(nostro2::NostrKeypair::shared_point(self, peer_pubkey)
127            .map_err(|_| Nip44Error::SharedSecretError)?
128            .into())
129    }
130
131    /// Encrypts a note's content in place using NIP-44 v2.
132    ///
133    /// # Errors
134    /// Propagates any failure from [`nip_44_encrypt`](Self::nip_44_encrypt).
135    fn nip44_encrypt_note<'a>(
136        &self,
137        note: &'a mut nostro2::NostrNote,
138        peer_pubkey: &'a str,
139    ) -> Result<(), Nip44Error> {
140        note.content = self.nip_44_encrypt(&note.content, peer_pubkey)?.to_string();
141        Ok(())
142    }
143
144    /// Decrypts a note's NIP-44 content, auto-detecting the version.
145    ///
146    /// # Errors
147    /// Propagates any failure from [`nip_44_decrypt`](Self::nip_44_decrypt).
148    fn nip44_decrypt_note<'a>(
149        &self,
150        note: &'a nostro2::NostrNote,
151        peer_pubkey: &'a str,
152    ) -> Result<std::borrow::Cow<'a, str>, Nip44Error> {
153        self.nip_44_decrypt(&note.content, peer_pubkey)
154    }
155
156    /// Encrypts `plaintext` for `peer_pubkey` using **NIP-44 version 2**.
157    ///
158    /// The output is a base64 payload beginning with version byte `0x02`,
159    /// interoperable with any spec-compliant NIP-44 implementation.
160    ///
161    /// # Errors
162    /// - `SharedSecretError` / `HkdfError`: key derivation failure.
163    /// - `InvalidLength`: plaintext empty or longer than 65535 bytes.
164    /// - `HmacError` / `SliceError`: MAC or cipher construction failure.
165    fn nip_44_encrypt<'a>(
166        &self,
167        plaintext: &'a str,
168        peer_pubkey: &'a str,
169    ) -> Result<std::borrow::Cow<'a, str>, Nip44Error> {
170        let shared = self.shared_secret(peer_pubkey)?;
171        let conversation_key = Self::conversation_key_v2(shared)?;
172        let nonce = Self::generate_nonce_32();
173        let payload = Self::encrypt_v2(&conversation_key, &nonce, plaintext.as_bytes())?;
174        Ok(payload.into())
175    }
176
177    /// Decrypts a NIP-44 payload from `peer_pubkey`, dispatching on the
178    /// version byte: `0x02` (spec v2) or `0x31` (legacy nostro2).
179    ///
180    /// # Errors
181    /// - `UnknownVersion`: leading `#` flag or an unrecognised version byte.
182    /// - `MacMismatch`: v2 authentication tag did not verify.
183    /// - `Base64DecodingError` / `InvalidLength` / `InvalidPrefixLen`: malformed payload.
184    /// - `FromUtf8Error`: decrypted bytes are not valid UTF-8.
185    fn nip_44_decrypt<'a>(
186        &self,
187        payload: &'a str,
188        peer_pubkey: &'a str,
189    ) -> Result<std::borrow::Cow<'a, str>, Nip44Error> {
190        // A leading '#' is the spec's reserved flag for a future non-base64
191        // encoding; we must report it as unsupported rather than a b64 error.
192        if payload.as_bytes().first() == Some(&b'#') {
193            return Err(Nip44Error::UnknownVersion(b'#'));
194        }
195        let mut decoded = zeroize::Zeroizing::new(general_purpose::STANDARD.decode(payload)?);
196        let version = *decoded.first().ok_or(Nip44Error::InvalidLength)?;
197        let shared = self.shared_secret(peer_pubkey)?;
198
199        let plaintext = match version {
200            VERSION_V2 => {
201                let conversation_key = Self::conversation_key_v2(shared)?;
202                Self::decrypt_v2(&conversation_key, &decoded)?
203            }
204            VERSION_LEGACY => {
205                let conversation_key = Self::derive_conversation_key_legacy(shared, b"nip44-v2")?;
206                Self::decrypt_legacy(&conversation_key, &decoded)?
207            }
208            other => {
209                decoded.zeroize();
210                return Err(Nip44Error::UnknownVersion(other));
211            }
212        };
213        decoded.zeroize();
214        Ok(plaintext.into())
215    }
216
217    // ── NIP-44 v2 (spec-compliant) ────────────────────────────────────
218
219    /// `get_conversation_key`: HKDF-extract over the unhashed ECDH x-coordinate
220    /// with `salt = utf8("nip44-v2")`. The extract output (PRK) **is** the
221    /// conversation key — no expand step.
222    ///
223    /// # Errors
224    /// Never fails; returns `Result` for signature symmetry.
225    fn conversation_key_v2(
226        mut shared_x: zeroize::Zeroizing<[u8; 32]>,
227    ) -> Result<zeroize::Zeroizing<[u8; 32]>, Nip44Error> {
228        let (prk, _hk) =
229            hkdf::Hkdf::<sha2::Sha256>::extract(Some(b"nip44-v2"), shared_x.as_slice());
230        shared_x.zeroize();
231        let mut key = zeroize::Zeroizing::new([0_u8; 32]);
232        key.copy_from_slice(&prk);
233        Ok(key)
234    }
235
236    /// `get_message_keys`: HKDF-expand `PRK = conversation_key`, `info = nonce`,
237    /// `L = 76` → `chacha_key`(32) ‖ `chacha_nonce`(12) ‖ `hmac_key`(32).
238    ///
239    /// # Errors
240    /// `HkdfError` if expansion fails (never for L = 76).
241    fn get_message_keys(
242        conversation_key: &[u8; 32],
243        nonce: &[u8; 32],
244    ) -> Result<MessageKeys, Nip44Error> {
245        let hkdf = hkdf::Hkdf::<sha2::Sha256>::from_prk(conversation_key)
246            .map_err(|_| Nip44Error::HkdfError)?;
247        let mut okm = zeroize::Zeroizing::new([0_u8; 76]);
248        hkdf.expand(nonce, okm.as_mut_slice())
249            .map_err(|_| Nip44Error::HkdfError)?;
250        let mut chacha_key = zeroize::Zeroizing::new([0_u8; 32]);
251        chacha_key.copy_from_slice(&okm[0..32]);
252        let mut chacha_nonce = zeroize::Zeroizing::new([0_u8; 12]);
253        chacha_nonce.copy_from_slice(&okm[32..44]);
254        let mut hmac_key = zeroize::Zeroizing::new([0_u8; 32]);
255        hmac_key.copy_from_slice(&okm[44..76]);
256        Ok(MessageKeys {
257            chacha_key,
258            chacha_nonce,
259            hmac_key,
260        })
261    }
262
263    /// Encrypts already-derived `conversation_key` material with an explicit
264    /// `nonce`. Split out from [`nip_44_encrypt`](Self::nip_44_encrypt) so the
265    /// official test vectors (fixed nonce) can be exercised deterministically.
266    ///
267    /// # Errors
268    /// - `InvalidLength`: plaintext empty or > 65535 bytes.
269    /// - `HkdfError` / `HmacError` / `SliceError`: derivation or cipher failure.
270    fn encrypt_v2(
271        conversation_key: &[u8; 32],
272        nonce: &[u8; 32],
273        plaintext: &[u8],
274    ) -> Result<String, Nip44Error> {
275        let keys = Self::get_message_keys(conversation_key, nonce)?;
276        let mut padded = Self::pad_v2(plaintext)?;
277
278        let mut cipher = chacha20::ChaCha20::new_from_slices(
279            keys.chacha_key.as_slice(),
280            keys.chacha_nonce.as_slice(),
281        )?;
282        cipher.apply_keystream(padded.as_mut_slice());
283        // `padded` is now the ciphertext.
284
285        let mac = Self::hmac_with_aad(keys.hmac_key.as_slice(), padded.as_slice(), nonce)?;
286        let payload = Self::base64_encode_params(&[VERSION_V2], nonce, padded.as_slice(), &mac);
287        padded.zeroize();
288        Ok(payload)
289    }
290
291    /// Decrypts a decoded v2 payload (`decoded[0] == 0x02`), verifying the MAC
292    /// in constant time **before** decrypting.
293    ///
294    /// # Errors
295    /// - `InvalidLength`: decoded payload outside the 99..=65603 byte range.
296    /// - `MacMismatch`: authentication tag mismatch.
297    /// - `InvalidPrefixLen`: padding/length-prefix inconsistency.
298    /// - `FromUtf8Error`: plaintext not valid UTF-8.
299    fn decrypt_v2(conversation_key: &[u8; 32], decoded: &[u8]) -> Result<String, Nip44Error> {
300        let dlen = decoded.len();
301        // version(1) + nonce(32) + ciphertext(>=34) + mac(32)
302        if !(99..=65603).contains(&dlen) {
303            return Err(Nip44Error::InvalidLength);
304        }
305        let nonce: [u8; 32] = decoded[1..33].try_into()?;
306        let ciphertext = &decoded[33..dlen - 32];
307        let mac = &decoded[dlen - 32..];
308
309        let keys = Self::get_message_keys(conversation_key, &nonce)?;
310        Self::verify_hmac_with_aad(keys.hmac_key.as_slice(), ciphertext, &nonce, mac)?;
311
312        let mut buffer = zeroize::Zeroizing::new(ciphertext.to_vec());
313        let mut cipher = chacha20::ChaCha20::new_from_slices(
314            keys.chacha_key.as_slice(),
315            keys.chacha_nonce.as_slice(),
316        )?;
317        cipher.apply_keystream(buffer.as_mut_slice());
318
319        let plaintext = Self::unpad_v2(&buffer)?.to_string();
320        buffer.zeroize();
321        Ok(plaintext)
322    }
323
324    /// Like [`decrypt_v2`](Self::decrypt_v2) but returns the raw plaintext
325    /// **bytes** instead of a `String`. Used by the NIP-104 double ratchet,
326    /// whose payloads are opaque (not guaranteed UTF-8 at this layer).
327    ///
328    /// # Errors
329    /// - `InvalidLength`: decoded payload outside the 99..=65603 byte range.
330    /// - `MacMismatch`: authentication tag mismatch.
331    /// - `InvalidPrefixLen`: padding/length-prefix inconsistency.
332    fn decrypt_v2_bytes(
333        conversation_key: &[u8; 32],
334        decoded: &[u8],
335    ) -> Result<Vec<u8>, Nip44Error> {
336        let dlen = decoded.len();
337        if !(99..=65603).contains(&dlen) {
338            return Err(Nip44Error::InvalidLength);
339        }
340        let nonce: [u8; 32] = decoded[1..33].try_into()?;
341        let ciphertext = &decoded[33..dlen - 32];
342        let mac = &decoded[dlen - 32..];
343
344        let keys = Self::get_message_keys(conversation_key, &nonce)?;
345        Self::verify_hmac_with_aad(keys.hmac_key.as_slice(), ciphertext, &nonce, mac)?;
346
347        let mut buffer = zeroize::Zeroizing::new(ciphertext.to_vec());
348        let mut cipher = chacha20::ChaCha20::new_from_slices(
349            keys.chacha_key.as_slice(),
350            keys.chacha_nonce.as_slice(),
351        )?;
352        cipher.apply_keystream(buffer.as_mut_slice());
353
354        // Validate the length prefix exactly as `unpad_v2` does, then return
355        // the plaintext bytes (without forcing UTF-8).
356        if buffer.len() < 2 {
357            return Err(Nip44Error::InvalidLength);
358        }
359        let unpadded_len = u16::from_be_bytes([buffer[0], buffer[1]]) as usize;
360        if unpadded_len == 0
361            || 2 + unpadded_len > buffer.len()
362            || buffer.len() != 2 + Self::calc_padded_len(unpadded_len)
363        {
364            return Err(Nip44Error::InvalidPrefixLen);
365        }
366        let out = buffer[2..2 + unpadded_len].to_vec();
367        buffer.zeroize();
368        Ok(out)
369    }
370
371    /// `calc_padded_len`: NIP-44 power-of-two-chunked padding size for an
372    /// unpadded plaintext length (excludes the 2-byte length prefix).
373    #[must_use]
374    fn calc_padded_len(unpadded_len: usize) -> usize {
375        if unpadded_len <= 32 {
376            return 32;
377        }
378        let next_power = 1_usize << ((unpadded_len - 1).ilog2() + 1);
379        let chunk = if next_power <= 256 {
380            32
381        } else {
382            next_power / 8
383        };
384        chunk * ((unpadded_len - 1) / chunk + 1)
385    }
386
387    /// `pad`: `[u16_be(len)][plaintext][zeros]`, total = `2 + calc_padded_len`.
388    ///
389    /// # Errors
390    /// `InvalidLength` if plaintext is empty or longer than 65535 bytes.
391    fn pad_v2(plaintext: &[u8]) -> Result<zeroize::Zeroizing<Vec<u8>>, Nip44Error> {
392        let len = plaintext.len();
393        if !(1..=65535).contains(&len) {
394            return Err(Nip44Error::InvalidLength);
395        }
396        let total = 2 + Self::calc_padded_len(len);
397        let mut buf = zeroize::Zeroizing::new(vec![0_u8; total]);
398        buf[..2].copy_from_slice(&u16::try_from(len)?.to_be_bytes());
399        buf[2..2 + len].copy_from_slice(plaintext);
400        Ok(buf)
401    }
402
403    /// `unpad`: validate the length prefix and that the total padding matches
404    /// what encryption would have produced, then return the plaintext slice.
405    ///
406    /// # Errors
407    /// - `InvalidLength`: padded blob shorter than 2 bytes.
408    /// - `InvalidPrefixLen`: zero length, truncation, or padding mismatch.
409    /// - `FromUtf8Error`: plaintext not valid UTF-8.
410    fn unpad_v2(padded: &[u8]) -> Result<&str, Nip44Error> {
411        if padded.len() < 2 {
412            return Err(Nip44Error::InvalidLength);
413        }
414        let unpadded_len = u16::from_be_bytes([padded[0], padded[1]]) as usize;
415        if unpadded_len == 0
416            || 2 + unpadded_len > padded.len()
417            || padded.len() != 2 + Self::calc_padded_len(unpadded_len)
418        {
419            return Err(Nip44Error::InvalidPrefixLen);
420        }
421        Ok(std::str::from_utf8(&padded[2..2 + unpadded_len])?)
422    }
423
424    /// `hmac_aad`: HMAC-SHA256 over `concat(aad, message)`, where `aad` (the
425    /// 32-byte nonce) is prepended to the ciphertext per NIP-44.
426    ///
427    /// # Errors
428    /// `HmacError` if the key is rejected (never for a 32-byte key).
429    fn hmac_with_aad(key: &[u8], message: &[u8], aad: &[u8; 32]) -> Result<[u8; 32], Nip44Error> {
430        let mut mac =
431            hmac::Hmac::<sha2::Sha256>::new_from_slice(key).map_err(|_| Nip44Error::HmacError)?;
432        mac.update(aad);
433        mac.update(message);
434        Ok(mac.finalize().into_bytes().into())
435    }
436
437    /// Constant-time verification of an `hmac_aad` tag.
438    ///
439    /// # Errors
440    /// `MacMismatch` if the tag is wrong; `HmacError` on key rejection.
441    fn verify_hmac_with_aad(
442        key: &[u8],
443        message: &[u8],
444        aad: &[u8; 32],
445        expected: &[u8],
446    ) -> Result<(), Nip44Error> {
447        let mut mac =
448            hmac::Hmac::<sha2::Sha256>::new_from_slice(key).map_err(|_| Nip44Error::HmacError)?;
449        mac.update(aad);
450        mac.update(message);
451        mac.verify_slice(expected)
452            .map_err(|_| Nip44Error::MacMismatch)
453    }
454
455    #[must_use]
456    fn generate_nonce_32() -> zeroize::Zeroizing<[u8; 32]> {
457        let mut nonce = [0_u8; 32];
458        getrandom::fill(&mut nonce).expect("getrandom failed");
459        nonce.into()
460    }
461
462    // ── Legacy (decrypt-only, frozen) ─────────────────────────────────
463
464    /// Legacy conversation-key derivation as shipped in nostro2-nips <= 0.3.0:
465    /// HKDF-extract **then** expand with empty `info`. Not spec-correct, but
466    /// reproduced verbatim so legacy payloads still decrypt.
467    ///
468    /// # Errors
469    /// `HkdfError` if expansion fails.
470    fn derive_conversation_key_legacy(
471        mut shared_secret: zeroize::Zeroizing<[u8; 32]>,
472        salt: &[u8],
473    ) -> Result<zeroize::Zeroizing<[u8; 32]>, Nip44Error> {
474        let hkdf = hkdf::Hkdf::<sha2::Sha256>::new(Some(salt), shared_secret.as_slice());
475        shared_secret.zeroize();
476        let mut okm = [0_u8; 32];
477        hkdf.expand(&[], &mut okm)
478            .map_err(|_| Nip44Error::HkdfError)?;
479        Ok(okm.into())
480    }
481
482    /// Decrypts a decoded legacy payload (`decoded[0] == 0x31`): 12-byte nonce,
483    /// conversation key used directly as the `ChaCha20` key. The trailing
484    /// 32 bytes are an HMAC-SHA256(conversation_key, ciphertext) tag, verified
485    /// in constant time before decrypting (no AAD, unlike v2).
486    ///
487    /// # Errors
488    /// - `InvalidLength`: payload too short.
489    /// - `MacMismatch`: authentication tag mismatch.
490    /// - `InvalidPrefixLen`: length prefix exceeds available bytes.
491    /// - `FromUtf8Error`: plaintext not valid UTF-8.
492    fn decrypt_legacy(conversation_key: &[u8; 32], decoded: &[u8]) -> Result<String, Nip44Error> {
493        if decoded.len() < 1 + 12 + 32 {
494            return Err(Nip44Error::InvalidLength);
495        }
496        let nonce: [u8; 12] = decoded[1..13].try_into()?;
497        let ciphertext = &decoded[13..decoded.len() - 32];
498        let tag = &decoded[decoded.len() - 32..];
499
500        let mut mac = hmac::Hmac::<sha2::Sha256>::new_from_slice(conversation_key)
501            .map_err(|_| Nip44Error::HmacError)?;
502        mac.update(ciphertext);
503        mac.verify_slice(tag).map_err(|_| Nip44Error::MacMismatch)?;
504
505        let mut buffer = zeroize::Zeroizing::new(ciphertext.to_vec());
506        let mut cipher = chacha20::ChaCha20::new_from_slices(conversation_key, &nonce)?;
507        cipher.apply_keystream(buffer.as_mut_slice());
508
509        if buffer.len() < 2 {
510            return Err(Nip44Error::InvalidLength);
511        }
512        let len = u16::from_be_bytes([buffer[0], buffer[1]]) as usize;
513        if len > buffer.len() - 2 {
514            return Err(Nip44Error::InvalidPrefixLen);
515        }
516        Ok(std::str::from_utf8(&buffer[2..2 + len])?.to_string())
517    }
518
519    #[must_use]
520    fn base64_encode_params(version: &[u8], nonce: &[u8], ciphertext: &[u8], mac: &[u8]) -> String {
521        let mut buf =
522            Vec::with_capacity(version.len() + nonce.len() + ciphertext.len() + mac.len());
523        buf.extend_from_slice(version);
524        buf.extend_from_slice(nonce);
525        buf.extend_from_slice(ciphertext);
526        buf.extend_from_slice(mac);
527
528        let mut out = String::with_capacity((buf.len() * 4).div_ceil(3));
529        general_purpose::STANDARD.encode_string(&buf, &mut out);
530        out
531    }
532}
533
534impl<T: nostro2::NostrKeypair + ?Sized> Nip44 for T {}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539    use nostro2::{NostrKeypair, NostrSigner};
540
541    type Tester = crate::tests::NipTester;
542
543    #[test]
544    fn test_encrypt_decrypt_success() {
545        let sender = Tester::generate();
546        let receiver = Tester::generate();
547
548        let plaintext = "Hello NIP-44 encryption!";
549        let receiver_pk = receiver.public_key();
550        let sender_pk = sender.public_key();
551        let ciphertext = sender.nip_44_encrypt(plaintext, &receiver_pk).unwrap();
552        // New ciphertext must be spec v2 (version byte 0x02).
553        let raw = general_purpose::STANDARD
554            .decode(ciphertext.as_ref())
555            .unwrap();
556        assert_eq!(raw[0], VERSION_V2, "expected v2 (0x02) payload");
557        let decrypted = receiver.nip_44_decrypt(&ciphertext, &sender_pk).unwrap();
558        assert_eq!(decrypted, plaintext);
559    }
560
561    #[test]
562    fn test_invalid_decryption_key() {
563        let sender = Tester::generate();
564        let receiver = Tester::generate();
565        let wrong_receiver = Tester::generate();
566
567        let plaintext = "Hello NIP-44 encryption!";
568        let receiver_pk = receiver.public_key();
569        let sender_pk = sender.public_key();
570        let ciphertext = sender.nip_44_encrypt(plaintext, &receiver_pk).unwrap();
571        // Wrong key now fails on MAC verification, not garbage UTF-8.
572        let result = wrong_receiver.nip_44_decrypt(&ciphertext, &sender_pk);
573        assert!(result.is_err());
574    }
575
576    // ── Official NIP-44 v2 test vectors (paulmillr/nip44) ──────────────
577
578    /// `valid.get_conversation_key`: sec1 = …01, sec2 = …02.
579    #[test]
580    fn vector_conversation_key() {
581        let sec1 = "0000000000000000000000000000000000000000000000000000000000000001";
582        let sec2 = "0000000000000000000000000000000000000000000000000000000000000002";
583        let a = Tester::from_hex(sec1).unwrap();
584        let b = Tester::from_hex(sec2).unwrap();
585
586        let shared = a.shared_secret(&b.public_key()).unwrap();
587        let conv = Tester::conversation_key_v2(shared).unwrap();
588        let hex = nostro2_traits::hex::Hexable::to_hex(conv.as_slice());
589        assert_eq!(
590            hex,
591            "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d"
592        );
593    }
594
595    /// `valid.encrypt_decrypt`: fixed `conversation_key` + nonce + "a" → payload.
596    #[test]
597    fn vector_known_answer_payload() {
598        use nostro2_traits::hex::FromHex as _;
599        let conv: [u8; 32] = "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d"
600            .decode_hex()
601            .unwrap()
602            .try_into()
603            .unwrap();
604        let nonce: [u8; 32] = "0000000000000000000000000000000000000000000000000000000000000001"
605            .decode_hex()
606            .unwrap()
607            .try_into()
608            .unwrap();
609        let payload = Tester::encrypt_v2(&conv, &nonce, b"a").unwrap();
610        assert_eq!(
611            payload,
612            "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb"
613        );
614    }
615
616    /// `valid.calc_padded_len` spot checks.
617    #[test]
618    fn vector_calc_padded_len() {
619        let cases = [
620            (1_usize, 32_usize),
621            (32, 32),
622            (33, 64),
623            (37, 64),
624            (45, 64),
625            (49, 64),
626            (64, 64),
627            (65, 96),
628            (100, 128),
629            (111, 128),
630            (200, 224),
631            (250, 256),
632            (320, 320),
633            (361, 384),
634            (512, 512),
635            (1000, 1024),
636            (1024, 1024),
637            (1025, 1280),
638            (65535, 65536),
639        ];
640        for (unpadded, expected) in cases {
641            assert_eq!(
642                Tester::calc_padded_len(unpadded),
643                expected,
644                "calc_padded_len({unpadded})"
645            );
646        }
647    }
648
649    /// Tampering with the ciphertext must be caught by the MAC.
650    #[test]
651    fn v2_mac_rejects_tampering() {
652        let sender = Tester::generate();
653        let receiver = Tester::generate();
654        let receiver_pk = receiver.public_key();
655        let sender_pk = sender.public_key();
656        let ct = sender.nip_44_encrypt("authentic", &receiver_pk).unwrap();
657        // Flip a byte in the middle of the base64 payload.
658        let mut bytes = general_purpose::STANDARD.decode(ct.as_ref()).unwrap();
659        let mid = bytes.len() / 2;
660        bytes[mid] ^= 0x01;
661        let tampered = general_purpose::STANDARD.encode(&bytes);
662        let result = receiver.nip_44_decrypt(&tampered, &sender_pk);
663        assert!(matches!(result, Err(Nip44Error::MacMismatch)));
664    }
665
666    /// Legacy (`0x31`) payloads must still decrypt for backward compatibility.
667    #[test]
668    fn legacy_payload_still_decrypts() {
669        // Reproduce a legacy payload exactly as the old code would have:
670        // 12-byte nonce, conversation key used directly, MAC over ciphertext.
671        let sender = Tester::generate();
672        let receiver = Tester::generate();
673        let receiver_pk = receiver.public_key();
674        let sender_pk = sender.public_key();
675        let shared = sender.shared_secret(&receiver_pk).unwrap();
676        let conv = Tester::derive_conversation_key_legacy(shared, b"nip44-v2").unwrap();
677
678        let plaintext = b"legacy data at rest";
679        // pad like the old next_power_of_two scheme
680        let total = (plaintext.len() + 2).next_power_of_two().max(32);
681        let mut padded = vec![0_u8; total];
682        padded[..2].copy_from_slice(&u16::try_from(plaintext.len()).unwrap().to_be_bytes());
683        padded[2..2 + plaintext.len()].copy_from_slice(plaintext);
684
685        let mut nonce = [0_u8; 12];
686        getrandom::fill(&mut nonce).unwrap();
687        let mut cipher = chacha20::ChaCha20::new_from_slices(conv.as_slice(), &nonce).unwrap();
688        cipher.apply_keystream(&mut padded);
689        let mut mac = hmac::Hmac::<sha2::Sha256>::new_from_slice(conv.as_slice()).unwrap();
690        mac.update(&padded);
691        let tag: [u8; 32] = mac.finalize().into_bytes().into();
692        let legacy_payload = Tester::base64_encode_params(b"1", &nonce, &padded, &tag);
693
694        // The receiver decrypts it through the dispatching API.
695        let out = receiver
696            .nip_44_decrypt(&legacy_payload, &sender_pk)
697            .unwrap();
698        assert_eq!(out, "legacy data at rest");
699    }
700
701    /// Legacy (`0x31`) payloads carry a trailing HMAC tag that must actually
702    /// be verified: a bit-flipped ciphertext must be rejected, not silently
703    /// decrypted into garbage plaintext.
704    #[test]
705    fn legacy_payload_tampering_is_rejected() {
706        let sender = Tester::generate();
707        let receiver = Tester::generate();
708        let receiver_pk = receiver.public_key();
709        let sender_pk = sender.public_key();
710        let shared = sender.shared_secret(&receiver_pk).unwrap();
711        let conv = Tester::derive_conversation_key_legacy(shared, b"nip44-v2").unwrap();
712
713        let plaintext = b"legacy data at rest";
714        let total = (plaintext.len() + 2).next_power_of_two().max(32);
715        let mut padded = vec![0_u8; total];
716        padded[..2].copy_from_slice(&u16::try_from(plaintext.len()).unwrap().to_be_bytes());
717        padded[2..2 + plaintext.len()].copy_from_slice(plaintext);
718
719        let mut nonce = [0_u8; 12];
720        getrandom::fill(&mut nonce).unwrap();
721        let mut cipher = chacha20::ChaCha20::new_from_slices(conv.as_slice(), &nonce).unwrap();
722        cipher.apply_keystream(&mut padded);
723        let mut mac = hmac::Hmac::<sha2::Sha256>::new_from_slice(conv.as_slice()).unwrap();
724        mac.update(&padded);
725        let tag: [u8; 32] = mac.finalize().into_bytes().into();
726        let legacy_payload = Tester::base64_encode_params(b"1", &nonce, &padded, &tag);
727
728        let mut bytes = general_purpose::STANDARD.decode(&legacy_payload).unwrap();
729        let ct_start = 1 + 12;
730        bytes[ct_start] ^= 0x01;
731        let tampered = general_purpose::STANDARD.encode(&bytes);
732
733        let result = receiver.nip_44_decrypt(&tampered, &sender_pk);
734        assert!(matches!(result, Err(Nip44Error::MacMismatch)));
735    }
736
737    #[test]
738    fn unknown_version_is_reported() {
739        let sender = Tester::generate();
740        let receiver = Tester::generate();
741        // version byte 0x09, then enough filler bytes
742        let mut raw = vec![0x09_u8];
743        raw.extend_from_slice(&[0_u8; 80]);
744        let payload = general_purpose::STANDARD.encode(&raw);
745        let sender_pk = sender.public_key();
746        let result = receiver.nip_44_decrypt(&payload, &sender_pk);
747        assert!(matches!(result, Err(Nip44Error::UnknownVersion(0x09))));
748    }
749
750    #[test]
751    fn hash_flag_is_unsupported() {
752        let sender = Tester::generate();
753        let receiver = Tester::generate();
754        let sender_pk = sender.public_key();
755        let result = receiver.nip_44_decrypt("#unsupported", &sender_pk);
756        assert!(matches!(result, Err(Nip44Error::UnknownVersion(b'#'))));
757    }
758
759    use std::fmt::Write as _;
760    #[test]
761    fn encrypt_very_large_note() {
762        let sender = Tester::generate();
763        let receiver = Tester::generate();
764
765        let mut plaintext = String::new();
766        for i in 0..15329 {
767            let _ = write!(plaintext, "{i}");
768        }
769        let receiver_pk = receiver.public_key();
770        let sender_pk = sender.public_key();
771        let ciphertext = sender.nip_44_encrypt(&plaintext, &receiver_pk).unwrap();
772        let decrypted = receiver.nip_44_decrypt(&ciphertext, &sender_pk).unwrap();
773        assert_eq!(decrypted, plaintext);
774    }
775
776    fn utf8_err() -> std::str::Utf8Error {
777        let bad = [0xff_u8];
778        std::str::from_utf8(bad.as_slice()).unwrap_err()
779    }
780
781    fn slice_err() -> std::array::TryFromSliceError {
782        <[u8; 4]>::try_from([0_u8; 3].as_slice()).unwrap_err()
783    }
784
785    fn int_err() -> std::num::TryFromIntError {
786        u8::try_from(256_u16).unwrap_err()
787    }
788
789    #[test]
790    fn error_display_covers_all_variants() {
791        let cases: Vec<Nip44Error> = vec![
792            Nip44Error::SharedSecretError,
793            Nip44Error::FromHexError(nostro2_traits::hex::HexError::OddLength),
794            Nip44Error::NostrNoteError(nostro2::errors::NostrErrors::MissingId),
795            Nip44Error::InvalidLength,
796            Nip44Error::Base64DecodingError(
797                base64::engine::general_purpose::STANDARD
798                    .decode("!!!")
799                    .unwrap_err(),
800            ),
801            Nip44Error::FromUtf8Error(utf8_err()),
802            Nip44Error::HkdfError,
803            Nip44Error::HmacError,
804            Nip44Error::InvalidPrefixLen,
805            Nip44Error::FromArrayError(slice_err()),
806            Nip44Error::BufferTooSmall,
807            Nip44Error::FromIntError(int_err()),
808            Nip44Error::UnknownVersion(0x09),
809            Nip44Error::MacMismatch,
810        ];
811        for err in &cases {
812            let msg = format!("{err}");
813            assert!(!msg.is_empty(), "Display empty for {err:?}");
814        }
815    }
816
817    #[test]
818    fn error_source_delegates_correctly() {
819        use std::error::Error;
820
821        assert!(Nip44Error::SharedSecretError.source().is_none());
822        assert!(Nip44Error::InvalidLength.source().is_none());
823        assert!(Nip44Error::HkdfError.source().is_none());
824        assert!(Nip44Error::HmacError.source().is_none());
825        assert!(Nip44Error::InvalidPrefixLen.source().is_none());
826        assert!(Nip44Error::BufferTooSmall.source().is_none());
827        assert!(Nip44Error::UnknownVersion(0x09).source().is_none());
828        assert!(Nip44Error::MacMismatch.source().is_none());
829
830        assert!(
831            Nip44Error::FromHexError(nostro2_traits::hex::HexError::OddLength)
832                .source()
833                .is_some()
834        );
835        assert!(
836            Nip44Error::NostrNoteError(nostro2::errors::NostrErrors::MissingId)
837                .source()
838                .is_some()
839        );
840        assert!(
841            Nip44Error::Base64DecodingError(
842                base64::engine::general_purpose::STANDARD
843                    .decode("!!!")
844                    .unwrap_err()
845            )
846            .source()
847            .is_some()
848        );
849        assert!(Nip44Error::FromUtf8Error(utf8_err()).source().is_some());
850        assert!(Nip44Error::FromArrayError(slice_err()).source().is_some());
851        assert!(Nip44Error::FromIntError(int_err()).source().is_some());
852    }
853
854    mod proptests {
855        use super::*;
856        use proptest::prelude::*;
857
858        proptest! {
859            #[test]
860            fn encrypt_decrypt_round_trip(plaintext in ".{1,256}") {
861                let sender = Tester::generate();
862                let receiver = Tester::generate();
863                let receiver_pk = receiver.public_key();
864                let sender_pk = sender.public_key();
865
866                let ciphertext = sender.nip_44_encrypt(&plaintext, &receiver_pk).unwrap();
867                let decrypted = receiver.nip_44_decrypt(&ciphertext, &sender_pk).unwrap();
868                prop_assert_eq!(&plaintext, decrypted.as_ref());
869            }
870
871            #[test]
872            fn encrypt_is_non_deterministic(plaintext in ".{1,64}") {
873                let sender = Tester::generate();
874                let receiver = Tester::generate();
875                let receiver_pk = receiver.public_key();
876
877                let a = sender.nip_44_encrypt(&plaintext, &receiver_pk).unwrap();
878                let b = sender.nip_44_encrypt(&plaintext, &receiver_pk).unwrap();
879                prop_assert_ne!(a, b, "same plaintext must produce different ciphertexts");
880            }
881
882            #[test]
883            fn padded_len_is_valid(unpadded in 1_usize..65535) {
884                let padded = Tester::calc_padded_len(unpadded);
885                prop_assert!(padded >= 32);
886                prop_assert!(padded >= unpadded);
887                // padded sizes are multiples of 32
888                prop_assert_eq!(padded % 32, 0);
889            }
890        }
891    }
892}