1#![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
81pub const VERSION: u8 = 2;
83
84const HKDF_SALT: &[u8] = b"nip44-v2";
86
87const MIN_PLAINTEXT_BYTES: usize = 1;
89const MAX_PLAINTEXT_BYTES: usize = 65_535;
90
91const NONCE_BYTES: usize = 32;
93const HMAC_BYTES: usize = 32;
94const VERSION_BYTE: usize = 1;
95
96const MIN_PAYLOAD_BYTES: usize = 99;
98const MAX_PAYLOAD_BYTES: usize = 65_603;
99
100const MIN_PAYLOAD_CHARS: usize = 132;
102const MAX_PAYLOAD_CHARS: usize = 87_472;
103
104const 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#[derive(Debug, Error)]
115#[non_exhaustive]
116pub enum Nip44Error {
117 #[error("plaintext is empty")]
119 EmptyPlaintext,
120 #[error("plaintext too long: {0} bytes (max {MAX_PLAINTEXT_BYTES})")]
122 PlaintextTooLong(usize),
123 #[error("payload too short: {0} characters (min {MIN_PAYLOAD_CHARS})")]
125 PayloadTooShort(usize),
126 #[error("payload too long: {0} characters (max {MAX_PAYLOAD_CHARS})")]
128 PayloadTooLong(usize),
129 #[error("decoded payload too short: {0} bytes")]
132 DecodedTooShort(usize),
133 #[error("decoded payload too long: {0} bytes")]
135 DecodedTooLong(usize),
136 #[error("unsupported NIP-44 version byte: {0:#04x}")]
139 UnsupportedVersion(u8),
140 #[error("invalid base64: {0}")]
142 InvalidBase64(#[from] base64::DecodeError),
143 #[error("invalid MAC")]
146 InvalidMac,
147 #[error("invalid padding")]
149 InvalidPadding,
150 #[error("plaintext is not valid UTF-8")]
154 InvalidUtf8,
155 #[error("entropy source failed: {0}")]
157 Rng(#[from] rng::RngError),
158}
159
160#[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 #[must_use]
187 pub fn derive(secret: &SecretKey, peer_public: &PublicKey) -> Self {
188 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 let mut shared_x = [0u8; 32];
200 shared_x.copy_from_slice(&ssp[..32]);
201
202 let (prk, _) = Hkdf::<Sha256>::extract(Some(HKDF_SALT), &shared_x);
205
206 shared_x.zeroize();
210
211 let mut bytes = [0u8; 32];
212 bytes.copy_from_slice(prk.as_slice());
213 Self(bytes)
214 }
215
216 #[must_use]
223 pub const fn from_byte_array(bytes: [u8; 32]) -> Self {
224 Self(bytes)
225 }
226
227 #[must_use]
229 pub const fn as_byte_array(&self) -> &[u8; 32] {
230 &self.0
231 }
232}
233
234struct 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 #[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 #[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 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#[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#[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 let mut buffer = pad(plaintext.as_bytes())?;
375
376 let mut cipher = ChaCha20::new(mks.chacha_key().into(), mks.chacha_nonce().into());
378 cipher.apply_keystream(&mut buffer);
379
380 let hmac = compute_hmac(mks.hmac_key(), nonce, &buffer);
382
383 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#[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#[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 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 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 if !verify_hmac(mks.hmac_key(), &nonce, ciphertext, mac) {
506 return Err(Nip44Error::InvalidMac);
507 }
508
509 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 #[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
560const 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
577const 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 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
598fn 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 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 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 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 assert!(matches!(
731 err,
732 Nip44Error::InvalidMac | Nip44Error::InvalidBase64(_)
733 ));
734 }
735
736 #[test]
737 fn unsupported_version_byte_is_rejected() {
738 let mut bogus = vec![0x01_u8; MIN_PAYLOAD_BYTES];
741 #[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 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 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 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}