Skip to main content

wire/
nip44.rs

1//! RFC-007 D3.3: NIP-44 v2 encrypted payloads — the Nostr DM encryption.
2//!
3//! Consumes RFC-006's reserved `enc` slot with a *vetted* spec instead of
4//! bespoke crypto (the `reuse > build` principle). NIP-44 v2 encrypts between
5//! two secp256k1 keys — here, the D3.1 Nostr **transport** keys — so a wire DM
6//! sent over Nostr is confidential to the relay.
7//!
8//! ## Construction (NIP-44 v2)
9//!
10//! - **conversation key** = `HKDF-Extract(salt = "nip44-v2", IKM = ecdh_x)` where
11//!   `ecdh_x` is the x-coordinate of the secp256k1 ECDH point between my secret
12//!   and their (x-only, even-y) public key. Symmetric: both parties derive the
13//!   same key.
14//! - **per-message keys** = `HKDF-Expand(conversation_key, info = nonce, 76)` →
15//!   `chacha_key[32] ‖ chacha_nonce[12] ‖ hmac_key[32]`.
16//! - **padding** — the plaintext is length-prefixed (2-byte BE) and zero-padded
17//!   to a power-of-two-ish boundary so ciphertext length leaks only a coarse
18//!   bucket, not the exact message size.
19//! - **cipher** = ChaCha20 (stream) over the padded plaintext.
20//! - **MAC** = `HMAC-SHA256(hmac_key, nonce ‖ ciphertext)`, verified in constant
21//!   time before decryption.
22//! - **payload** = `base64(0x02 ‖ nonce[32] ‖ ciphertext ‖ mac[32])`.
23//!
24//! Interop: validated **byte-exact against the official NIP-44 v2 vectors**
25//! (`testdata/nip44_official_vectors.json` — the `valid` subset from the
26//! reference implementation, public domain). All 35 conversation-key, 10
27//! encrypt/decrypt (including exact ciphertext payloads), and 24 padded-length
28//! cases pass, so wire's NIP-44 interoperates with other implementations — plus
29//! round-trip / ECDH-symmetry / tamper / wrong-key unit tests.
30
31use base64::Engine as _;
32use base64::engine::general_purpose::STANDARD as B64;
33use chacha20::ChaCha20;
34use chacha20::cipher::{KeyIvInit, StreamCipher};
35use hkdf::Hkdf;
36use hmac::{Hmac, Mac};
37use secp256k1::{Parity, PublicKey, SecretKey, XOnlyPublicKey};
38use sha2::Sha256;
39
40type HmacSha256 = Hmac<Sha256>;
41
42const VERSION: u8 = 0x02;
43const SALT: &[u8] = b"nip44-v2";
44const MIN_PLAINTEXT: usize = 1;
45const MAX_PLAINTEXT: usize = 65535;
46
47#[derive(Debug, PartialEq, Eq)]
48pub enum Nip44Error {
49    /// A secp256k1 key was malformed, or ECDH failed.
50    Key,
51    /// Plaintext length is outside `1..=65535`.
52    PlaintextLen,
53    /// Payload base64 / structure / version was invalid.
54    BadPayload,
55    /// The MAC did not verify (wrong key or tampered ciphertext).
56    Mac,
57    /// The decrypted padding was malformed.
58    Padding,
59    /// Decrypted bytes were not valid UTF-8.
60    Utf8,
61}
62
63impl std::fmt::Display for Nip44Error {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        let s = match self {
66            Nip44Error::Key => "invalid secp256k1 key / ECDH failure",
67            Nip44Error::PlaintextLen => "plaintext length out of range (1..=65535)",
68            Nip44Error::BadPayload => "malformed NIP-44 payload",
69            Nip44Error::Mac => "NIP-44 MAC verification failed",
70            Nip44Error::Padding => "malformed NIP-44 padding",
71            Nip44Error::Utf8 => "decrypted bytes are not valid UTF-8",
72        };
73        write!(f, "{s}")
74    }
75}
76
77/// Derive the symmetric conversation key between my secret key and their x-only
78/// public key. `HKDF-Extract(salt="nip44-v2", IKM = ecdh_x)`.
79pub fn conversation_key(
80    my_secp_sk: &[u8; 32],
81    their_xonly: &[u8; 32],
82) -> Result<[u8; 32], Nip44Error> {
83    let sk = SecretKey::from_byte_array(*my_secp_sk).map_err(|_| Nip44Error::Key)?;
84    let xonly = XOnlyPublicKey::from_byte_array(*their_xonly).map_err(|_| Nip44Error::Key)?;
85    // NIP-44 lifts the x-only key to even-y for ECDH.
86    let pk = PublicKey::from_x_only_public_key(xonly, Parity::Even);
87    // shared_secret_point returns the 64-byte (x ‖ y); NIP-44 uses x only.
88    let point = secp256k1::ecdh::shared_secret_point(&pk, &sk);
89    let (prk, _) = Hkdf::<Sha256>::extract(Some(SALT), &point[..32]);
90    let mut ck = [0u8; 32];
91    ck.copy_from_slice(&prk);
92    Ok(ck)
93}
94
95/// NIP-44 padded length for an unpadded plaintext length (excludes the 2-byte
96/// length prefix). Powers-of-two-ish bucketing so the ciphertext size leaks only
97/// a coarse bucket.
98pub fn calc_padded_len(unpadded: usize) -> usize {
99    if unpadded <= 32 {
100        return 32;
101    }
102    // 2^(floor(log2(unpadded-1)) + 1)
103    let next_power = 1usize << ((unpadded - 1).ilog2() + 1);
104    let chunk = if next_power <= 256 {
105        32
106    } else {
107        next_power / 8
108    };
109    chunk * ((unpadded - 1) / chunk + 1)
110}
111
112/// `[u16 BE unpadded_len] ‖ plaintext ‖ zero-pad` to `2 + calc_padded_len`.
113fn pad(plaintext: &[u8]) -> Result<Vec<u8>, Nip44Error> {
114    let n = plaintext.len();
115    if !(MIN_PLAINTEXT..=MAX_PLAINTEXT).contains(&n) {
116        return Err(Nip44Error::PlaintextLen);
117    }
118    let total = 2 + calc_padded_len(n);
119    let mut buf = vec![0u8; total];
120    buf[0..2].copy_from_slice(&(n as u16).to_be_bytes());
121    buf[2..2 + n].copy_from_slice(plaintext);
122    Ok(buf)
123}
124
125/// Reverse [`pad`]: validate the prefix + total length, return the plaintext.
126fn unpad(buf: &[u8]) -> Result<Vec<u8>, Nip44Error> {
127    if buf.len() < 2 {
128        return Err(Nip44Error::Padding);
129    }
130    let n = u16::from_be_bytes([buf[0], buf[1]]) as usize;
131    if !(MIN_PLAINTEXT..=MAX_PLAINTEXT).contains(&n) {
132        return Err(Nip44Error::Padding);
133    }
134    if buf.len() != 2 + calc_padded_len(n) {
135        return Err(Nip44Error::Padding);
136    }
137    Ok(buf[2..2 + n].to_vec())
138}
139
140/// HKDF-Expand the per-message keys: `chacha_key[32] ‖ chacha_nonce[12] ‖
141/// hmac_key[32]`.
142fn message_keys(conversation_key: &[u8; 32], nonce: &[u8; 32]) -> ([u8; 32], [u8; 12], [u8; 32]) {
143    let hk = Hkdf::<Sha256>::from_prk(conversation_key).expect("32-byte PRK is valid");
144    let mut okm = [0u8; 76];
145    hk.expand(nonce, &mut okm).expect("76 < 255*32");
146    let mut ck = [0u8; 32];
147    let mut cn = [0u8; 12];
148    let mut hm = [0u8; 32];
149    ck.copy_from_slice(&okm[0..32]);
150    cn.copy_from_slice(&okm[32..44]);
151    hm.copy_from_slice(&okm[44..76]);
152    (ck, cn, hm)
153}
154
155fn hmac(hmac_key: &[u8; 32], nonce: &[u8; 32], ciphertext: &[u8]) -> [u8; 32] {
156    let mut mac = HmacSha256::new_from_slice(hmac_key).expect("hmac accepts any key length");
157    mac.update(nonce);
158    mac.update(ciphertext);
159    let out = mac.finalize().into_bytes();
160    let mut t = [0u8; 32];
161    t.copy_from_slice(&out);
162    t
163}
164
165/// Encrypt `plaintext` under `conversation_key` with an explicit 32-byte
166/// `nonce`. Returns the base64 NIP-44 payload. (Production callers use
167/// [`encrypt`], which supplies a random nonce.)
168pub fn encrypt_with_nonce(
169    conversation_key: &[u8; 32],
170    nonce: &[u8; 32],
171    plaintext: &str,
172) -> Result<String, Nip44Error> {
173    let (ck, cn, hm) = message_keys(conversation_key, nonce);
174    let mut buf = pad(plaintext.as_bytes())?;
175    ChaCha20::new(&ck.into(), &cn.into()).apply_keystream(&mut buf);
176    let mac = hmac(&hm, nonce, &buf);
177
178    let mut payload = Vec::with_capacity(1 + 32 + buf.len() + 32);
179    payload.push(VERSION);
180    payload.extend_from_slice(nonce);
181    payload.extend_from_slice(&buf);
182    payload.extend_from_slice(&mac);
183    Ok(B64.encode(&payload))
184}
185
186/// Encrypt `plaintext` under `conversation_key` with a fresh random nonce.
187pub fn encrypt(conversation_key: &[u8; 32], plaintext: &str) -> Result<String, Nip44Error> {
188    use rand::RngCore;
189    let mut nonce = [0u8; 32];
190    rand::thread_rng().fill_bytes(&mut nonce);
191    encrypt_with_nonce(conversation_key, &nonce, plaintext)
192}
193
194/// Decrypt a base64 NIP-44 payload under `conversation_key`. Constant-time MAC
195/// check before decryption; fail-closed on any structural / MAC / padding error.
196pub fn decrypt(conversation_key: &[u8; 32], payload_b64: &str) -> Result<String, Nip44Error> {
197    let payload = B64
198        .decode(payload_b64.as_bytes())
199        .map_err(|_| Nip44Error::BadPayload)?;
200    // version(1) + nonce(32) + ciphertext(>=34) + mac(32). The minimum
201    // ciphertext is 2-byte prefix + 32-byte min pad = 34.
202    if payload.len() < 1 + 32 + 34 + 32 || payload[0] != VERSION {
203        return Err(Nip44Error::BadPayload);
204    }
205    let nonce: [u8; 32] = payload[1..33].try_into().unwrap();
206    let mac_start = payload.len() - 32;
207    let ciphertext = &payload[33..mac_start];
208    let their_mac = &payload[mac_start..];
209
210    let (ck, cn, hm) = message_keys(conversation_key, &nonce);
211    // Constant-time MAC verification (hmac crate's verify is constant-time).
212    let mut mac = HmacSha256::new_from_slice(&hm).expect("hmac accepts any key length");
213    mac.update(&nonce);
214    mac.update(ciphertext);
215    mac.verify_slice(their_mac).map_err(|_| Nip44Error::Mac)?;
216
217    let mut buf = ciphertext.to_vec();
218    ChaCha20::new(&ck.into(), &cn.into()).apply_keystream(&mut buf);
219    let plaintext = unpad(&buf)?;
220    String::from_utf8(plaintext).map_err(|_| Nip44Error::Utf8)
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::nostr_key::generate_transport_key;
227
228    #[test]
229    fn conversation_key_is_symmetric() {
230        // a's (sk_a, pub_b) and b's (sk_b, pub_a) derive the same key.
231        let (sk_a, pub_a) = generate_transport_key();
232        let (sk_b, pub_b) = generate_transport_key();
233        let ck_ab = conversation_key(&sk_a, &pub_b).unwrap();
234        let ck_ba = conversation_key(&sk_b, &pub_a).unwrap();
235        assert_eq!(ck_ab, ck_ba, "ECDH conversation key must be symmetric");
236    }
237
238    #[test]
239    fn encrypt_decrypt_roundtrip() {
240        let (sk_a, _pa) = generate_transport_key();
241        let (_sb, pub_b) = generate_transport_key();
242        let ck = conversation_key(&sk_a, &pub_b).unwrap();
243        for msg in ["x", "hello over nostr", &"A".repeat(1000)] {
244            let ct = encrypt(&ck, msg).unwrap();
245            assert_eq!(decrypt(&ck, &ct).unwrap(), msg);
246        }
247    }
248
249    #[test]
250    fn the_other_party_decrypts() {
251        // a encrypts to b; b decrypts with its own (sk_b, pub_a) — the real DM path.
252        let (sk_a, pub_a) = generate_transport_key();
253        let (sk_b, pub_b) = generate_transport_key();
254        let ck_a = conversation_key(&sk_a, &pub_b).unwrap();
255        let ck_b = conversation_key(&sk_b, &pub_a).unwrap();
256        let ct = encrypt(&ck_a, "private to bob").unwrap();
257        assert_eq!(decrypt(&ck_b, &ct).unwrap(), "private to bob");
258    }
259
260    #[test]
261    fn deterministic_with_fixed_nonce() {
262        let (sk_a, _pa) = generate_transport_key();
263        let (_sb, pub_b) = generate_transport_key();
264        let ck = conversation_key(&sk_a, &pub_b).unwrap();
265        let nonce = [7u8; 32];
266        assert_eq!(
267            encrypt_with_nonce(&ck, &nonce, "same").unwrap(),
268            encrypt_with_nonce(&ck, &nonce, "same").unwrap()
269        );
270    }
271
272    #[test]
273    fn tampered_ciphertext_fails_mac() {
274        let (sk_a, _pa) = generate_transport_key();
275        let (_sb, pub_b) = generate_transport_key();
276        let ck = conversation_key(&sk_a, &pub_b).unwrap();
277        let ct = encrypt(&ck, "tamperme").unwrap();
278        let mut raw = B64.decode(&ct).unwrap();
279        let n = raw.len();
280        raw[n - 40] ^= 0xff; // flip a ciphertext byte (before the 32-byte MAC)
281        let bad = B64.encode(&raw);
282        assert_eq!(decrypt(&ck, &bad), Err(Nip44Error::Mac));
283    }
284
285    #[test]
286    fn wrong_key_fails_mac() {
287        let (sk_a, _pa) = generate_transport_key();
288        let (_sb, pub_b) = generate_transport_key();
289        let (sk_c, _pc) = generate_transport_key();
290        let (_sd, pub_d) = generate_transport_key();
291        let ck = conversation_key(&sk_a, &pub_b).unwrap();
292        let other = conversation_key(&sk_c, &pub_d).unwrap();
293        let ct = encrypt(&ck, "secret").unwrap();
294        assert_eq!(decrypt(&other, &ct), Err(Nip44Error::Mac));
295    }
296
297    #[test]
298    fn rejects_bad_version_and_short_payload() {
299        let ck = [9u8; 32];
300        assert_eq!(
301            decrypt(&ck, &B64.encode([0x01u8; 200])),
302            Err(Nip44Error::BadPayload)
303        );
304        assert_eq!(decrypt(&ck, "!!notbase64"), Err(Nip44Error::BadPayload));
305        assert_eq!(
306            decrypt(&ck, &B64.encode([0x02u8; 10])),
307            Err(Nip44Error::BadPayload)
308        );
309    }
310
311    #[test]
312    fn empty_and_oversize_plaintext_rejected() {
313        let ck = [3u8; 32];
314        assert_eq!(encrypt(&ck, ""), Err(Nip44Error::PlaintextLen));
315        let huge = "A".repeat(MAX_PLAINTEXT + 1);
316        assert_eq!(encrypt(&ck, &huge), Err(Nip44Error::PlaintextLen));
317    }
318
319    #[test]
320    fn padded_len_matches_spec_examples() {
321        // Hand-verified against the NIP-44 algorithm.
322        for (unpadded, expected) in [
323            (1, 32),
324            (16, 32),
325            (32, 32),
326            (33, 64),
327            (37, 64),
328            (65, 96),
329            (100, 128),
330        ] {
331            assert_eq!(calc_padded_len(unpadded), expected, "len {unpadded}");
332        }
333        // Invariants for a sweep: result >= unpadded, multiple of 32, monotonic.
334        let mut prev = 0;
335        for n in 1..2000usize {
336            let p = calc_padded_len(n);
337            assert!(p >= n, "padded {p} < unpadded {n}");
338            assert_eq!(p % 32, 0, "padded {p} not a multiple of 32");
339            assert!(p >= prev, "padded len must be monotonic");
340            prev = p;
341        }
342    }
343
344    // ───────────────────────── Official NIP-44 v2 vectors ─────────────────────
345    //
346    // The canonical conformance vectors from the NIP-44 reference
347    // (github.com/paulmillr/nip44 → nip44.vectors.json; the `valid` v2 subset,
348    // which the repo marks public-domain, copied from nostr-protocol/nips).
349    // Byte-exact agreement here is what proves wire's NIP-44 interoperates with
350    // every other implementation — closing the gap D3.3 flagged.
351
352    const OFFICIAL_VECTORS: &str = include_str!("testdata/nip44_official_vectors.json");
353
354    fn hex32(s: &str) -> [u8; 32] {
355        let v = hex::decode(s).expect("vector hex");
356        v.as_slice().try_into().expect("vector is 32 bytes")
357    }
358
359    #[test]
360    fn official_get_conversation_key_vectors() {
361        let v: serde_json::Value = serde_json::from_str(OFFICIAL_VECTORS).unwrap();
362        let cases = v["get_conversation_key"].as_array().unwrap();
363        assert!(cases.len() >= 30, "expected the full vector set");
364        for (i, c) in cases.iter().enumerate() {
365            let sec1 = hex32(c["sec1"].as_str().unwrap());
366            let pub2 = hex32(c["pub2"].as_str().unwrap());
367            let expected = hex32(c["conversation_key"].as_str().unwrap());
368            assert_eq!(
369                conversation_key(&sec1, &pub2).unwrap(),
370                expected,
371                "get_conversation_key vector #{i}"
372            );
373        }
374    }
375
376    #[test]
377    fn official_encrypt_decrypt_vectors() {
378        let v: serde_json::Value = serde_json::from_str(OFFICIAL_VECTORS).unwrap();
379        let cases = v["encrypt_decrypt"].as_array().unwrap();
380        assert!(!cases.is_empty());
381        for (i, c) in cases.iter().enumerate() {
382            let sec1 = hex32(c["sec1"].as_str().unwrap());
383            let sec2 = hex32(c["sec2"].as_str().unwrap());
384            let ck = hex32(c["conversation_key"].as_str().unwrap());
385            let nonce = hex32(c["nonce"].as_str().unwrap());
386            let plaintext = c["plaintext"].as_str().unwrap();
387            let payload = c["payload"].as_str().unwrap();
388
389            // conversation key derives from sec1 + pub(sec2), and equals the vector.
390            let pub2 = crate::nostr_key::xonly_from_secret(&sec2).unwrap();
391            assert_eq!(
392                conversation_key(&sec1, &pub2).unwrap(),
393                ck,
394                "ck derivation #{i}"
395            );
396            // Byte-exact ciphertext under the fixed nonce — the interop proof.
397            assert_eq!(
398                encrypt_with_nonce(&ck, &nonce, plaintext).unwrap(),
399                payload,
400                "encrypt vector #{i}"
401            );
402            // And decrypt of the official payload recovers the plaintext.
403            assert_eq!(
404                decrypt(&ck, payload).unwrap(),
405                plaintext,
406                "decrypt vector #{i}"
407            );
408        }
409    }
410
411    #[test]
412    fn official_calc_padded_len_vectors() {
413        let v: serde_json::Value = serde_json::from_str(OFFICIAL_VECTORS).unwrap();
414        for pair in v["calc_padded_len"].as_array().unwrap() {
415            let unpadded = pair[0].as_u64().unwrap() as usize;
416            let expected = pair[1].as_u64().unwrap() as usize;
417            assert_eq!(
418                calc_padded_len(unpadded),
419                expected,
420                "calc_padded_len({unpadded})"
421            );
422        }
423    }
424}