Skip to main content

nula_core/nips/
nip04.rs

1//! [NIP-04] Encrypted Direct Messages — *deprecated*.
2//!
3//! NIP-04 is the **legacy** direct-message scheme: AES-256-CBC over a
4//! raw secp256k1 ECDH X coordinate (no key-derivation function), wire-
5//! encoded as `<base64(ciphertext)>?iv=<base64(iv)>` and carried in
6//! kind-4 events.
7//!
8//! The scheme is officially superseded by [NIP-17] (which composes
9//! [NIP-44] v2 + [NIP-59] gift wrapping) and is known to:
10//!
11//! - leak conversation graph metadata (recipient pubkey is in the
12//!   public `p` tag, sender pubkey is in the public `pubkey` field, and
13//!   the kind itself signals "this is a DM"),
14//! - expose plaintext lengths via padding-free CBC,
15//! - reuse the unhashed ECDH `X` as a key, which collides on weak
16//!   curves and lets an attacker perform offline correlation across
17//!   conversations.
18//!
19//! `nula-core` keeps the implementation only for **backwards
20//! compatibility** with the existing on-relay corpus and to satisfy
21//! NIP-46 remote-signer requests that still target it. New clients
22//! SHOULD prefer NIP-17.
23//!
24//! # Wire format
25//!
26//! ```text
27//! base64(ciphertext) ?iv= base64(16-byte IV)
28//! ```
29//!
30//! Both halves use the standard (`+`/`/`) base64 alphabet with `=`
31//! padding, per the spec.
32//!
33//! [NIP-04]: https://github.com/nostr-protocol/nips/blob/master/04.md
34//! [NIP-17]: https://github.com/nostr-protocol/nips/blob/master/17.md
35//! [NIP-44]: https://github.com/nostr-protocol/nips/blob/master/44.md
36//! [NIP-59]: https://github.com/nostr-protocol/nips/blob/master/59.md
37
38use aes::Aes256;
39use aes::cipher::block_padding::Pkcs7;
40use aes::cipher::{BlockModeDecrypt, BlockModeEncrypt, KeyIvInit};
41use base64::Engine;
42use base64::engine::general_purpose::STANDARD as BASE64;
43use secp256k1::{Parity, ecdh};
44use thiserror::Error;
45use zeroize::Zeroize;
46
47use crate::key::{PublicKey, SecretKey};
48use crate::util::rng::{self, RngError};
49
50/// AES-256 key length.
51const KEY_BYTES: usize = 32;
52/// CBC initialisation vector length (== AES block size).
53const IV_BYTES: usize = 16;
54/// Wire separator between the ciphertext and the IV.
55const SEPARATOR: &str = "?iv=";
56
57type Aes256CbcEnc = cbc::Encryptor<Aes256>;
58type Aes256CbcDec = cbc::Decryptor<Aes256>;
59
60/// Errors raised by [`encrypt`] and [`decrypt`].
61#[derive(Debug, Error)]
62#[non_exhaustive]
63pub enum Nip04Error {
64    /// The wire payload did not contain the `?iv=` separator.
65    #[error("payload is missing the `?iv=` separator")]
66    MissingIvSeparator,
67    /// The ciphertext or IV failed base64 decoding.
68    #[error("base64 decode failed: {0}")]
69    Base64(#[from] base64::DecodeError),
70    /// The IV had an unexpected length.
71    #[error("IV must be {expected} bytes, got {actual}")]
72    InvalidIvLength {
73        /// Required length per the spec.
74        expected: usize,
75        /// Length actually decoded.
76        actual: usize,
77    },
78    /// AES-256-CBC unpadding failed. The payload was either decrypted
79    /// with the wrong shared key (peer mismatch) or has been tampered
80    /// with — NIP-04 has no MAC, so a bit-flip on the wire surfaces here
81    /// as a padding error rather than as authenticated rejection.
82    #[error("AES-256-CBC unpadding failed (wrong key or tampered payload)")]
83    Unpad,
84    /// The decrypted bytes are not valid UTF-8.
85    #[error("decrypted plaintext is not valid UTF-8")]
86    InvalidUtf8,
87    /// RNG failed to provide entropy for the IV.
88    #[error(transparent)]
89    Rng(#[from] RngError),
90}
91
92/// Compute the unhashed 32-byte ECDH X coordinate used by NIP-04.
93///
94/// Lifts the peer's x-only key with even parity (the cross-impl
95/// convention) and runs `secp256k1` ECDH. Returns the first 32 bytes
96/// of the 64-byte serialized point — i.e. just `X`, with no KDF. This
97/// is the protocol-mandated weakness that NIP-44 v2 fixes via HKDF.
98fn shared_secret_x(secret: &SecretKey, peer: &PublicKey) -> [u8; KEY_BYTES] {
99    let normalized = secp256k1::PublicKey::from_x_only_public_key(*peer.as_inner(), Parity::Even);
100    let ssp = ecdh::shared_secret_point(&normalized, secret.as_inner());
101    let mut x = [0_u8; KEY_BYTES];
102    x.copy_from_slice(&ssp[..KEY_BYTES]);
103    x
104}
105
106/// Encrypt `plaintext` for `peer` using the NIP-04 wire format.
107///
108/// Generates a fresh 16-byte IV from the OS RNG on every call. The
109/// returned string is `base64(ciphertext)?iv=base64(iv)` — caller is
110/// responsible for placing it in a kind-4 event's `content` field.
111///
112/// # Errors
113///
114/// Returns [`Nip04Error::Rng`] if the OS entropy source fails.
115pub fn encrypt(
116    secret: &SecretKey,
117    peer: &PublicKey,
118    plaintext: &str,
119) -> Result<String, Nip04Error> {
120    let mut key = shared_secret_x(secret, peer);
121    let iv = rng::random_bytes::<IV_BYTES>()?;
122
123    let ciphertext = Aes256CbcEnc::new((&key).into(), (&iv).into())
124        .encrypt_padded_vec::<Pkcs7>(plaintext.as_bytes());
125
126    // Best-effort wipe; the compiler may still elide under aggressive
127    // optimisation but `zeroize` issues volatile writes.
128    key.zeroize();
129
130    Ok(format!(
131        "{}{SEPARATOR}{}",
132        BASE64.encode(&ciphertext),
133        BASE64.encode(iv),
134    ))
135}
136
137/// Decrypt a NIP-04 wire payload sent by `peer`.
138///
139/// Expects exactly one `?iv=` separator; an absent or duplicate
140/// separator is rejected. Both halves must be valid standard base64.
141/// The IV must be exactly 16 bytes after decoding.
142///
143/// # Errors
144///
145/// Returns [`Nip04Error::MissingIvSeparator`] / [`Nip04Error::Base64`]
146/// for malformed wire framing, [`Nip04Error::InvalidIvLength`] when the
147/// IV is not 16 bytes, [`Nip04Error::Unpad`] when AES rejects the
148/// padding (wrong peer or tampered ciphertext — NIP-04 has no MAC), and
149/// [`Nip04Error::InvalidUtf8`] when the recovered bytes are not UTF-8.
150pub fn decrypt(secret: &SecretKey, peer: &PublicKey, payload: &str) -> Result<String, Nip04Error> {
151    let (ciphertext_b64, iv_b64) = payload
152        .split_once(SEPARATOR)
153        .ok_or(Nip04Error::MissingIvSeparator)?;
154
155    let mut ciphertext = BASE64.decode(ciphertext_b64)?;
156    let iv_bytes = BASE64.decode(iv_b64)?;
157    let iv: [u8; IV_BYTES] =
158        iv_bytes
159            .as_slice()
160            .try_into()
161            .map_err(|_| Nip04Error::InvalidIvLength {
162                expected: IV_BYTES,
163                actual: iv_bytes.len(),
164            })?;
165
166    let mut key = shared_secret_x(secret, peer);
167    let plaintext = Aes256CbcDec::new((&key).into(), (&iv).into())
168        .decrypt_padded::<Pkcs7>(&mut ciphertext)
169        .map_err(|_| Nip04Error::Unpad)?;
170
171    let result = std::str::from_utf8(plaintext)
172        .map_err(|_| Nip04Error::InvalidUtf8)?
173        .to_owned();
174
175    key.zeroize();
176    Ok(result)
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::key::Keys;
183
184    fn keys_alice() -> Keys {
185        Keys::parse("000000000000000000000000000000000000000000000000000000000000a1ce").unwrap()
186    }
187
188    fn keys_bob() -> Keys {
189        Keys::parse("00000000000000000000000000000000000000000000000000000000000000b0").unwrap()
190    }
191
192    #[test]
193    fn round_trip_short_message() {
194        let alice = keys_alice();
195        let bob = keys_bob();
196        let ciphertext = encrypt(alice.secret_key(), bob.public_key(), "hello").unwrap();
197        let recovered = decrypt(bob.secret_key(), alice.public_key(), &ciphertext).unwrap();
198        assert_eq!(recovered, "hello");
199    }
200
201    #[test]
202    fn round_trip_unicode_payload() {
203        // Multi-byte UTF-8 boundaries — a regression that bit early
204        // ports of NIP-04 because PKCS7 unpadding is byte-wise.
205        let alice = keys_alice();
206        let bob = keys_bob();
207        let msg = "你好,nostr 🦀";
208        let ciphertext = encrypt(alice.secret_key(), bob.public_key(), msg).unwrap();
209        let recovered = decrypt(bob.secret_key(), alice.public_key(), &ciphertext).unwrap();
210        assert_eq!(recovered, msg);
211    }
212
213    #[test]
214    fn fresh_iv_per_call_yields_distinct_ciphertexts() {
215        // Encrypting the same plaintext twice must produce different
216        // wire payloads — proves the IV is sampled per call.
217        let alice = keys_alice();
218        let bob = keys_bob();
219        let a = encrypt(alice.secret_key(), bob.public_key(), "same").unwrap();
220        let b = encrypt(alice.secret_key(), bob.public_key(), "same").unwrap();
221        assert_ne!(a, b, "two encryptions of the same plaintext must differ");
222    }
223
224    #[test]
225    fn empty_plaintext_round_trip() {
226        // PKCS7 unambiguously encodes the empty string as one full
227        // padding block — confirm the round trip handles it.
228        let alice = keys_alice();
229        let bob = keys_bob();
230        let ciphertext = encrypt(alice.secret_key(), bob.public_key(), "").unwrap();
231        let recovered = decrypt(bob.secret_key(), alice.public_key(), &ciphertext).unwrap();
232        assert_eq!(recovered, "");
233    }
234
235    #[test]
236    fn missing_separator_is_rejected() {
237        let alice = keys_alice();
238        let bob = keys_bob();
239        let err = decrypt(alice.secret_key(), bob.public_key(), "no-separator-here").unwrap_err();
240        assert!(matches!(err, Nip04Error::MissingIvSeparator));
241    }
242
243    #[test]
244    fn malformed_base64_is_rejected() {
245        let alice = keys_alice();
246        let bob = keys_bob();
247        let err = decrypt(
248            alice.secret_key(),
249            bob.public_key(),
250            "!!not-base64!!?iv=!!neither!!",
251        )
252        .unwrap_err();
253        assert!(matches!(err, Nip04Error::Base64(_)));
254    }
255
256    #[test]
257    fn wrong_iv_length_is_rejected() {
258        let alice = keys_alice();
259        let bob = keys_bob();
260        // 8-byte IV, 16 expected.
261        let payload = format!(
262            "{}{SEPARATOR}{}",
263            BASE64.encode([0_u8; 32]),
264            BASE64.encode([0_u8; 8]),
265        );
266        let err = decrypt(alice.secret_key(), bob.public_key(), &payload).unwrap_err();
267        assert!(matches!(
268            err,
269            Nip04Error::InvalidIvLength {
270                expected: 16,
271                actual: 8,
272            }
273        ));
274    }
275
276    #[test]
277    fn wrong_peer_yields_unpad_error() {
278        // Symmetric ECDH means the *only* way to fail is a totally
279        // unrelated peer. The error surface should be `Unpad`, not a
280        // panic, because NIP-04 has no MAC.
281        let alice = keys_alice();
282        let bob = keys_bob();
283        let mallory =
284            Keys::parse("00000000000000000000000000000000000000000000000000000000000ca800")
285                .unwrap();
286
287        let ciphertext = encrypt(alice.secret_key(), bob.public_key(), "for bob").unwrap();
288        let err = decrypt(mallory.secret_key(), alice.public_key(), &ciphertext).unwrap_err();
289        // Either Unpad (most common) or InvalidUtf8 (occasional, when
290        // garbage bytes happen to pad-validate); both are acceptable
291        // failures, but never a panic.
292        assert!(matches!(err, Nip04Error::Unpad | Nip04Error::InvalidUtf8));
293    }
294}