#![allow(
clippy::expect_used,
clippy::unwrap_in_result,
reason = "see module-level comment above the attribute: every expect \
guards a precondition the surrounding code has already \
checked; replacing them with `?` would force every caller \
to handle errors that cannot occur in practice."
)]
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use chacha20::ChaCha20;
use chacha20::cipher::{KeyIvInit, StreamCipher};
use hkdf::Hkdf;
use hmac::digest::KeyInit;
use hmac::{Hmac, Mac};
use secp256k1::{Parity, ecdh};
use sha2::Sha256;
use thiserror::Error;
use zeroize::{Zeroize, ZeroizeOnDrop};
use crate::key::{PublicKey, SecretKey};
use crate::util::rng;
pub const VERSION: u8 = 2;
const HKDF_SALT: &[u8] = b"nip44-v2";
const MIN_PLAINTEXT_BYTES: usize = 1;
const MAX_PLAINTEXT_BYTES: usize = 65_535;
const NONCE_BYTES: usize = 32;
const HMAC_BYTES: usize = 32;
const VERSION_BYTE: usize = 1;
const MIN_PAYLOAD_BYTES: usize = 99;
const MAX_PAYLOAD_BYTES: usize = 65_603;
const MIN_PAYLOAD_CHARS: usize = 132;
const MAX_PAYLOAD_CHARS: usize = 87_472;
const MESSAGE_KEYS_BYTES: usize = 76;
const CHACHA_KEY_BYTES: usize = 32;
const CHACHA_NONCE_BYTES: usize = 12;
const MESSAGE_KEY_HMAC_OFFSET: usize = CHACHA_KEY_BYTES + CHACHA_NONCE_BYTES;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum Nip44Error {
#[error("plaintext is empty")]
EmptyPlaintext,
#[error("plaintext too long: {0} bytes (max {MAX_PLAINTEXT_BYTES})")]
PlaintextTooLong(usize),
#[error("payload too short: {0} characters (min {MIN_PAYLOAD_CHARS})")]
PayloadTooShort(usize),
#[error("payload too long: {0} characters (max {MAX_PAYLOAD_CHARS})")]
PayloadTooLong(usize),
#[error("decoded payload too short: {0} bytes")]
DecodedTooShort(usize),
#[error("decoded payload too long: {0} bytes")]
DecodedTooLong(usize),
#[error("unsupported NIP-44 version byte: {0:#04x}")]
UnsupportedVersion(u8),
#[error("invalid base64: {0}")]
InvalidBase64(#[from] base64::DecodeError),
#[error("invalid MAC")]
InvalidMac,
#[error("invalid padding")]
InvalidPadding,
#[error("plaintext is not valid UTF-8")]
InvalidUtf8,
#[error("entropy source failed: {0}")]
Rng(#[from] rng::RngError),
}
#[derive(Clone, ZeroizeOnDrop)]
pub struct ConversationKey([u8; 32]);
impl std::fmt::Debug for ConversationKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("ConversationKey(<redacted>)")
}
}
impl ConversationKey {
#[must_use]
pub fn derive(secret: &SecretKey, peer_public: &PublicKey) -> Self {
let normalized =
secp256k1::PublicKey::from_x_only_public_key(*peer_public.as_inner(), Parity::Even);
let ssp = ecdh::shared_secret_point(&normalized, secret.as_inner());
let mut shared_x = [0u8; 32];
shared_x.copy_from_slice(&ssp[..32]);
let (prk, _) = Hkdf::<Sha256>::extract(Some(HKDF_SALT), &shared_x);
shared_x.zeroize();
let mut bytes = [0u8; 32];
bytes.copy_from_slice(prk.as_slice());
Self(bytes)
}
#[must_use]
pub const fn from_byte_array(bytes: [u8; 32]) -> Self {
Self(bytes)
}
#[must_use]
pub const fn as_byte_array(&self) -> &[u8; 32] {
&self.0
}
}
struct MessageKeys([u8; MESSAGE_KEYS_BYTES]);
impl MessageKeys {
fn derive(conversation_key: &ConversationKey, nonce: &[u8; NONCE_BYTES]) -> Self {
let hk = Hkdf::<Sha256>::from_prk(conversation_key.as_byte_array())
.expect("PRK is exactly 32 bytes — HKDF::from_prk only fails on length");
let mut okm = [0u8; MESSAGE_KEYS_BYTES];
hk.expand(nonce, &mut okm)
.expect("76 bytes <= 255*32 — Hkdf::expand only fails when the OKM exceeds that");
Self(okm)
}
#[allow(
clippy::panic,
clippy::missing_panics_doc,
reason = "panic guard for a const fn that operates on a fixed-size buffer; the # Panics doc explains the guarantee"
)]
const fn chacha_key(&self) -> &[u8; CHACHA_KEY_BYTES] {
match self.0.first_chunk::<CHACHA_KEY_BYTES>() {
Some(arr) => arr,
None => panic!("OKM is 76 bytes; first 32 always present"),
}
}
#[allow(
clippy::panic,
clippy::missing_panics_doc,
reason = "panic guard for a const fn that operates on a fixed-size buffer; the # Panics doc explains the guarantee"
)]
const fn chacha_nonce(&self) -> &[u8; CHACHA_NONCE_BYTES] {
let (_, tail) = self.0.split_at(CHACHA_KEY_BYTES);
match tail.first_chunk::<CHACHA_NONCE_BYTES>() {
Some(arr) => arr,
None => panic!("OKM tail is 44 bytes; first 12 always present"),
}
}
const fn hmac_key(&self) -> &[u8] {
let (_, tail) = self.0.split_at(MESSAGE_KEY_HMAC_OFFSET);
tail
}
}
impl Drop for MessageKeys {
fn drop(&mut self) {
self.0.zeroize();
}
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(
level = "debug",
name = "nula.nip44.encrypt",
skip(secret, peer_public_key, plaintext),
fields(
nostr.nip = 44_u16,
nostr.encryption.plaintext_size = plaintext.len(),
),
)
)]
pub fn encrypt(
secret: &SecretKey,
peer_public_key: &PublicKey,
plaintext: &str,
) -> Result<String, Nip44Error> {
let mut nonce = [0u8; NONCE_BYTES];
rng::fill_bytes(&mut nonce)?;
let conversation_key = ConversationKey::derive(secret, peer_public_key);
encrypt_inner(&conversation_key, plaintext, &nonce)
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(
level = "debug",
name = "nula.nip44.encrypt_with_nonce",
skip(conversation_key, plaintext, nonce),
fields(
nostr.nip = 44_u16,
nostr.encryption.plaintext_size = plaintext.len(),
),
)
)]
pub fn encrypt_with_nonce(
conversation_key: &ConversationKey,
plaintext: &str,
nonce: &[u8; NONCE_BYTES],
) -> Result<String, Nip44Error> {
encrypt_inner(conversation_key, plaintext, nonce)
}
fn encrypt_inner(
conversation_key: &ConversationKey,
plaintext: &str,
nonce: &[u8; NONCE_BYTES],
) -> Result<String, Nip44Error> {
let mks = MessageKeys::derive(conversation_key, nonce);
let mut buffer = pad(plaintext.as_bytes())?;
let mut cipher = ChaCha20::new(mks.chacha_key().into(), mks.chacha_nonce().into());
cipher.apply_keystream(&mut buffer);
let hmac = compute_hmac(mks.hmac_key(), nonce, &buffer);
let mut payload = Vec::with_capacity(VERSION_BYTE + NONCE_BYTES + buffer.len() + HMAC_BYTES);
payload.push(VERSION);
payload.extend_from_slice(nonce);
payload.extend_from_slice(&buffer);
payload.extend_from_slice(&hmac);
Ok(BASE64.encode(payload))
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(
level = "debug",
name = "nula.nip44.decrypt",
skip(secret, peer_public_key, payload),
fields(
nostr.nip = 44_u16,
nostr.encryption.ciphertext_size = payload.len(),
),
)
)]
pub fn decrypt(
secret: &SecretKey,
peer_public_key: &PublicKey,
payload: &str,
) -> Result<String, Nip44Error> {
let conversation_key = ConversationKey::derive(secret, peer_public_key);
decrypt_with_conversation_key(&conversation_key, payload)
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(
level = "debug",
name = "nula.nip44.decrypt_with_conversation_key",
skip(conversation_key, payload),
fields(
nostr.nip = 44_u16,
nostr.encryption.ciphertext_size = payload.len(),
),
)
)]
pub fn decrypt_with_conversation_key(
conversation_key: &ConversationKey,
payload: &str,
) -> Result<String, Nip44Error> {
let plen = payload.len();
if plen < MIN_PAYLOAD_CHARS {
return Err(Nip44Error::PayloadTooShort(plen));
}
if plen > MAX_PAYLOAD_CHARS {
return Err(Nip44Error::PayloadTooLong(plen));
}
if payload.starts_with('#') {
return Err(Nip44Error::UnsupportedVersion(b'#'));
}
let bytes = BASE64.decode(payload)?;
let blen = bytes.len();
if blen < MIN_PAYLOAD_BYTES {
return Err(Nip44Error::DecodedTooShort(blen));
}
if blen > MAX_PAYLOAD_BYTES {
return Err(Nip44Error::DecodedTooLong(blen));
}
let (version_slice, rest) = bytes.split_at(VERSION_BYTE);
let version = *version_slice
.first()
.expect("VERSION_BYTE = 1, slice is non-empty after MIN_PAYLOAD_BYTES check");
if version != VERSION {
return Err(Nip44Error::UnsupportedVersion(version));
}
let (nonce_slice, body_with_mac) = rest.split_at(NONCE_BYTES);
let nonce: [u8; NONCE_BYTES] = nonce_slice
.try_into()
.expect("NONCE_BYTES = 32 by construction");
let mac_start = body_with_mac.len() - HMAC_BYTES;
let (ciphertext, mac) = body_with_mac.split_at(mac_start);
let mks = MessageKeys::derive(conversation_key, &nonce);
if !verify_hmac(mks.hmac_key(), &nonce, ciphertext, mac) {
return Err(Nip44Error::InvalidMac);
}
let mut buffer = ciphertext.to_vec();
let mut cipher = ChaCha20::new(mks.chacha_key().into(), mks.chacha_nonce().into());
cipher.apply_keystream(&mut buffer);
let unpadded = unpad(&buffer)?;
String::from_utf8(unpadded.to_vec()).map_err(|_| Nip44Error::InvalidUtf8)
}
fn pad(plaintext: &[u8]) -> Result<Vec<u8>, Nip44Error> {
let len = plaintext.len();
if len < MIN_PLAINTEXT_BYTES {
return Err(Nip44Error::EmptyPlaintext);
}
if len > MAX_PLAINTEXT_BYTES {
return Err(Nip44Error::PlaintextTooLong(len));
}
let padded_len = padded_length(len);
let mut out = Vec::with_capacity(2 + padded_len);
#[allow(
clippy::cast_possible_truncation,
reason = "len <= 65535 is enforced two lines above"
)]
let prefix = (len as u16).to_be_bytes();
out.extend_from_slice(&prefix);
out.extend_from_slice(plaintext);
out.resize(2 + padded_len, 0);
Ok(out)
}
fn unpad(padded: &[u8]) -> Result<&[u8], Nip44Error> {
let header: &[u8; 2] = padded
.first_chunk::<2>()
.ok_or(Nip44Error::InvalidPadding)?;
let prefix = u16::from_be_bytes(*header) as usize;
if prefix < MIN_PLAINTEXT_BYTES {
return Err(Nip44Error::InvalidPadding);
}
if prefix > MAX_PLAINTEXT_BYTES {
return Err(Nip44Error::InvalidPadding);
}
let expected_len = 2 + padded_length(prefix);
if padded.len() != expected_len {
return Err(Nip44Error::InvalidPadding);
}
padded.get(2..2 + prefix).ok_or(Nip44Error::InvalidPadding)
}
const fn padded_length(len: usize) -> usize {
if len <= 32 {
return 32;
}
let next_power = 1usize << (log2_floor(len - 1) + 1);
let chunk = if next_power <= 256 {
32
} else {
next_power / 8
};
chunk * (((len - 1) / chunk) + 1)
}
const fn log2_floor(x: usize) -> u32 {
if x == 0 {
0
} else {
(usize::BITS - 1) - x.leading_zeros()
}
}
fn compute_hmac(key: &[u8], nonce: &[u8], ciphertext: &[u8]) -> [u8; HMAC_BYTES] {
let mut mac =
<Hmac<Sha256> as KeyInit>::new_from_slice(key).expect("HMAC-SHA256 accepts any key length");
mac.update(nonce);
mac.update(ciphertext);
mac.finalize().into_bytes().into()
}
fn verify_hmac(key: &[u8], nonce: &[u8], ciphertext: &[u8], tag: &[u8]) -> bool {
let mut mac =
<Hmac<Sha256> as KeyInit>::new_from_slice(key).expect("HMAC-SHA256 accepts any key length");
mac.update(nonce);
mac.update(ciphertext);
mac.verify_slice(tag).is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::key::Keys;
fn key_pair_a() -> Keys {
Keys::parse("0000000000000000000000000000000000000000000000000000000000000001").unwrap()
}
fn key_pair_b() -> Keys {
Keys::parse("0000000000000000000000000000000000000000000000000000000000000002").unwrap()
}
#[test]
fn round_trip_short_message() {
let a = key_pair_a();
let b = key_pair_b();
let payload = encrypt(a.secret_key(), b.public_key(), "hello, nostr").unwrap();
let recovered = decrypt(b.secret_key(), a.public_key(), &payload).unwrap();
assert_eq!(recovered, "hello, nostr");
}
#[test]
fn round_trip_min_size() {
let a = key_pair_a();
let b = key_pair_b();
let payload = encrypt(a.secret_key(), b.public_key(), "x").unwrap();
let recovered = decrypt(b.secret_key(), a.public_key(), &payload).unwrap();
assert_eq!(recovered, "x");
}
#[test]
fn round_trip_max_size() {
let a = key_pair_a();
let b = key_pair_b();
let plaintext = "a".repeat(MAX_PLAINTEXT_BYTES);
let payload = encrypt(a.secret_key(), b.public_key(), &plaintext).unwrap();
let recovered = decrypt(b.secret_key(), a.public_key(), &payload).unwrap();
assert_eq!(recovered, plaintext);
}
#[test]
fn empty_plaintext_rejected() {
let a = key_pair_a();
let b = key_pair_b();
let err = encrypt(a.secret_key(), b.public_key(), "").unwrap_err();
assert!(matches!(err, Nip44Error::EmptyPlaintext));
}
#[test]
fn oversize_plaintext_rejected() {
let a = key_pair_a();
let b = key_pair_b();
let plaintext = "a".repeat(MAX_PLAINTEXT_BYTES + 1);
let err = encrypt(a.secret_key(), b.public_key(), &plaintext).unwrap_err();
assert!(matches!(err, Nip44Error::PlaintextTooLong(_)));
}
#[test]
fn plaintext_bound_follows_spec_not_rust_nostr_cap() {
let a = key_pair_a();
let b = key_pair_b();
for len in [65_408_usize, 65_409, MAX_PLAINTEXT_BYTES] {
let plaintext = "a".repeat(len);
let payload = encrypt(a.secret_key(), b.public_key(), &plaintext).unwrap_or_else(|e| {
panic!(
"nula must accept {len}-byte plaintext (spec max {MAX_PLAINTEXT_BYTES}): {e:?}"
)
});
let recovered = decrypt(b.secret_key(), a.public_key(), &payload).unwrap();
assert_eq!(recovered.len(), len);
}
let err = encrypt(
a.secret_key(),
b.public_key(),
&"a".repeat(MAX_PLAINTEXT_BYTES + 1),
)
.unwrap_err();
assert!(matches!(err, Nip44Error::PlaintextTooLong(_)));
}
#[test]
fn conversation_key_is_symmetric() {
let a = key_pair_a();
let b = key_pair_b();
let key_ab = ConversationKey::derive(a.secret_key(), b.public_key());
let key_ba = ConversationKey::derive(b.secret_key(), a.public_key());
assert_eq!(key_ab.as_byte_array(), key_ba.as_byte_array());
}
#[test]
fn tampered_mac_is_detected() {
let a = key_pair_a();
let b = key_pair_b();
let payload = encrypt(a.secret_key(), b.public_key(), "secret").unwrap();
let mut bytes: Vec<u8> = payload.into_bytes();
let last = bytes.len() - 2;
bytes[last] = if bytes[last] == b'A' { b'B' } else { b'A' };
let tampered = String::from_utf8(bytes).unwrap();
let err = decrypt(b.secret_key(), a.public_key(), &tampered).unwrap_err();
assert!(matches!(
err,
Nip44Error::InvalidMac | Nip44Error::InvalidBase64(_)
));
}
#[test]
fn unsupported_version_byte_is_rejected() {
let mut bogus = vec![0x01_u8; MIN_PAYLOAD_BYTES];
#[allow(
clippy::cast_possible_truncation,
reason = "`i & 0xff` always fits in u8"
)]
for (i, b) in bogus.iter_mut().enumerate().skip(1) {
*b = (i & 0xff) as u8;
}
let s = BASE64.encode(&bogus);
let key = ConversationKey::from_byte_array([0u8; 32]);
let err = decrypt_with_conversation_key(&key, &s).unwrap_err();
assert!(matches!(err, Nip44Error::UnsupportedVersion(0x01)));
}
#[test]
fn padded_length_matches_official_vectors() {
assert_eq!(padded_length(1), 32);
assert_eq!(padded_length(16), 32);
assert_eq!(padded_length(32), 32);
assert_eq!(padded_length(33), 64);
assert_eq!(padded_length(64), 64);
assert_eq!(padded_length(65), 96);
assert_eq!(padded_length(100), 128);
assert_eq!(padded_length(200), 224);
assert_eq!(padded_length(250), 256);
assert_eq!(padded_length(320), 320);
assert_eq!(padded_length(384), 384);
assert_eq!(padded_length(400), 448);
assert_eq!(padded_length(515), 640);
assert_eq!(padded_length(900), 1024);
assert_eq!(padded_length(1020), 1024);
assert_eq!(padded_length(65_536 - 1), 65_536);
}
#[test]
fn payload_too_short_is_rejected() {
let key = ConversationKey::from_byte_array([0u8; 32]);
let err = decrypt_with_conversation_key(&key, "AAAA").unwrap_err();
assert!(matches!(err, Nip44Error::PayloadTooShort(_)));
}
#[test]
fn future_framing_marker_is_rejected() {
let key = ConversationKey::from_byte_array([0u8; 32]);
let oversize = format!("#{}", "A".repeat(MAX_PAYLOAD_CHARS));
let err = decrypt_with_conversation_key(&key, &oversize).unwrap_err();
assert!(matches!(err, Nip44Error::PayloadTooLong(_)));
let in_range = format!("#{}", "A".repeat(MIN_PAYLOAD_CHARS - 1));
let err_in_range = decrypt_with_conversation_key(&key, &in_range).unwrap_err();
assert!(matches!(err_in_range, Nip44Error::UnsupportedVersion(b'#')));
}
}