Fix NIP-44 to spec-compliant v2: raw-X ECDH, HKDF(salt=nip44-v2), ChaCha20+HMAC, padding, versioned payload
The previous implementation used libsecp256k1's hashed ECDH, a non-standard HKDF info string, ChaCha20-Poly1305 with a 12-byte nonce, no padding, and no version byte. It could not decrypt real NIP-44 v2 payloads, causing the remote signer to reject nostr_nip44_decrypt with invalid_params (-32602). Rewrote to match the NIP-44 v2 spec and added known-answer tests using the official test vectors (calc_padded_len, get_conversation_key, get_message_keys, encrypt_decrypt).
This commit is contained in:
Generated
+10
-9
@@ -385,7 +385,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "event-signer"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"nostr-core",
|
||||
"nostr-nips",
|
||||
@@ -868,7 +868,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "integration-tests"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"nostr-core",
|
||||
"nostr-nips",
|
||||
@@ -910,7 +910,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "keypair-generator"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"nostr-core",
|
||||
]
|
||||
@@ -1041,12 +1041,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nostr-core"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
"bech32",
|
||||
"block-modes",
|
||||
"chacha20",
|
||||
"chacha20poly1305",
|
||||
"chrono",
|
||||
"hex",
|
||||
@@ -1063,7 +1064,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nostr-core-umbrella"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"nostr-core",
|
||||
"nostr-nips",
|
||||
@@ -1074,7 +1075,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nostr-nips"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"block-modes",
|
||||
@@ -1096,7 +1097,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nostr-relay"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"nostr-core",
|
||||
@@ -1114,7 +1115,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nostr-services"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"nostr-core",
|
||||
"nostr-relay",
|
||||
@@ -1129,7 +1130,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nostr-signer"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"cbc",
|
||||
|
||||
@@ -24,6 +24,7 @@ sha2 = "0.10"
|
||||
hmac = "0.12"
|
||||
hex = "0.4"
|
||||
chacha20poly1305 = "0.10"
|
||||
chacha20 = "0.9"
|
||||
aes = "0.8"
|
||||
block-modes = "0.9"
|
||||
rand = "0.8"
|
||||
|
||||
@@ -10,6 +10,7 @@ sha2.workspace = true
|
||||
hmac.workspace = true
|
||||
hex.workspace = true
|
||||
chacha20poly1305.workspace = true
|
||||
chacha20.workspace = true
|
||||
aes.workspace = true
|
||||
block-modes.workspace = true
|
||||
rand.workspace = true
|
||||
|
||||
+349
-104
@@ -1,47 +1,163 @@
|
||||
//! NIP-44 encryption: ChaCha20 + HMAC-SHA256 AEAD.
|
||||
//! NIP-44 v2 encryption: ChaCha20 + HMAC-SHA256 AEAD.
|
||||
//!
|
||||
//! This implements the NIP-44 encryption scheme using:
|
||||
//! - ECDH for shared secret derivation
|
||||
//! - HKDF for key derivation
|
||||
//! - ChaCha20 for encryption
|
||||
//! - HMAC-SHA256 for authentication
|
||||
//! This is a spec-compliant implementation of NIP-44 version 2
|
||||
//! (<https://github.com/nostr-protocol/nips/blob/master/44.md>).
|
||||
//!
|
||||
//! The scheme is:
|
||||
//! 1. `shared_x = x(ECDH(sec1, pub2))` — the raw X coordinate of the shared
|
||||
//! point, **not** hashed (libsecp256k1's default ECDH hashes the point, so
|
||||
//! we use [`ecdh_shared_secret_raw_x`]).
|
||||
//! 2. `conversation_key = HKDF-Extract(salt = "nip44-v2", ikm = shared_x)`.
|
||||
//! 3. `message_keys = HKDF-Expand(conversation_key, info = nonce, L = 76)`
|
||||
//! split into `chacha_key(32) || chacha_nonce(12) || hmac_key(32)`.
|
||||
//! 4. Plaintext is padded per the spec, encrypted with ChaCha20 (counter 0),
|
||||
//! and authenticated with HMAC-SHA256 over `nonce || ciphertext`.
|
||||
//! 5. The payload is `base64(0x02 || nonce(32) || ciphertext || mac(32))`.
|
||||
//!
|
||||
//! The public API ([`nip44_encrypt`] / [`nip44_decrypt`]) operates on the raw
|
||||
//! binary payload (version byte + nonce + ciphertext + mac), matching the
|
||||
//! convention used by the rest of this crate (e.g. NIP-59 gift wrapping).
|
||||
|
||||
use chacha20poly1305::{
|
||||
aead::{Aead, KeyInit},
|
||||
ChaCha20Poly1305, Nonce,
|
||||
};
|
||||
use chacha20::cipher::{KeyIvInit, StreamCipher};
|
||||
use chacha20::ChaCha20;
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
|
||||
use crate::crypto::hmac::hkdf;
|
||||
use crate::crypto::keys::ecdh_shared_secret_both;
|
||||
use crate::crypto::hmac::{hkdf_expand, hkdf_extract, hmac_sha256};
|
||||
use crate::crypto::keys::ecdh_shared_secret_raw_x;
|
||||
use crate::error::NostrError;
|
||||
use crate::types::{PublicKey, SecretKey};
|
||||
use crate::NostrResult;
|
||||
|
||||
/// NIP-44 conversation key size (32 bytes for ChaCha20 key).
|
||||
const CONVERSATION_KEY_SIZE: usize = 32;
|
||||
/// NIP-44 version byte (v2).
|
||||
const VERSION: u8 = 0x02;
|
||||
|
||||
/// NIP-44 nonce size (12 bytes for ChaCha20-Poly1305).
|
||||
const NONCE_SIZE: usize = 12;
|
||||
/// Nonce size (32 bytes).
|
||||
const NONCE_SIZE: usize = 32;
|
||||
|
||||
/// Maximum plaintext size for NIP-44 (64KB - 1).
|
||||
/// MAC size (32 bytes).
|
||||
const MAC_SIZE: usize = 32;
|
||||
|
||||
/// Minimum payload size: version(1) + nonce(32) + mac(32) + 1 byte ciphertext.
|
||||
const MIN_PAYLOAD_SIZE: usize = 1 + NONCE_SIZE + MAC_SIZE + 1;
|
||||
|
||||
/// Maximum plaintext size for NIP-44 (64 KiB - 1).
|
||||
pub const MAX_PLAINTEXT_SIZE: usize = 65535;
|
||||
|
||||
/// Derive a NIP-44 conversation key from an ECDH shared secret.
|
||||
/// NIP-44 padding: round the unpadded length up to the next chunk boundary.
|
||||
///
|
||||
/// The conversation key is derived as:
|
||||
/// `HKDF(salt=empty, ikm=ecdh_secret, info="nip44-conversation-key", L=32)`
|
||||
fn derive_conversation_key(shared_secret: &[u8; 32]) -> [u8; CONVERSATION_KEY_SIZE] {
|
||||
let mut result = [0u8; CONVERSATION_KEY_SIZE];
|
||||
let derived = hkdf(&[], shared_secret, b"nip44-conversation-key", CONVERSATION_KEY_SIZE);
|
||||
result.copy_from_slice(&derived);
|
||||
result
|
||||
/// Mirrors the reference implementation:
|
||||
/// - `<= 32` bytes → 32
|
||||
/// - otherwise round up to the next multiple of `chunk`, where
|
||||
/// `chunk = 32` for lengths up to 256 and `chunk = next_power_of_two / 8`
|
||||
/// beyond that.
|
||||
pub fn calc_padded_len(unpadded_len: usize) -> usize {
|
||||
if unpadded_len <= 32 {
|
||||
return 32;
|
||||
}
|
||||
let mut next_power = 1usize;
|
||||
while next_power < unpadded_len {
|
||||
next_power <<= 1;
|
||||
}
|
||||
let chunk = if next_power <= 256 { 32 } else { next_power / 8 };
|
||||
chunk * ((unpadded_len - 1) / chunk + 1)
|
||||
}
|
||||
|
||||
/// Encrypt a plaintext using NIP-44 scheme.
|
||||
/// Pad plaintext per NIP-44: `u16_be(len) || plaintext || zero padding`.
|
||||
fn pad_plaintext(plaintext: &[u8]) -> NostrResult<Vec<u8>> {
|
||||
if plaintext.len() > MAX_PLAINTEXT_SIZE {
|
||||
return Err(NostrError::Nip44BufferTooSmall);
|
||||
}
|
||||
let padded_len = calc_padded_len(plaintext.len());
|
||||
let mut out = Vec::with_capacity(2 + padded_len);
|
||||
out.extend_from_slice(&(plaintext.len() as u16).to_be_bytes());
|
||||
out.extend_from_slice(plaintext);
|
||||
out.resize(2 + padded_len, 0);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Remove NIP-44 padding, validating the length prefix.
|
||||
fn unpad_plaintext(padded: &[u8]) -> NostrResult<Vec<u8>> {
|
||||
if padded.len() < 2 {
|
||||
return Err(NostrError::Nip44InvalidFormat);
|
||||
}
|
||||
let unpadded_len = ((padded[0] as usize) << 8) | (padded[1] as usize);
|
||||
let expected = calc_padded_len(unpadded_len);
|
||||
if padded.len() != expected + 2 {
|
||||
return Err(NostrError::Nip44InvalidFormat);
|
||||
}
|
||||
if unpadded_len > padded.len() - 2 {
|
||||
return Err(NostrError::Nip44InvalidFormat);
|
||||
}
|
||||
Ok(padded[2..2 + unpadded_len].to_vec())
|
||||
}
|
||||
|
||||
/// Derive the NIP-44 conversation key from a secret key and peer public key.
|
||||
///
|
||||
/// Returns the ciphertext as bytes (nonce || encrypted_data || tag).
|
||||
/// `conversation_key = HKDF-Extract(salt = "nip44-v2", ikm = x(ECDH(sk, pk)))`.
|
||||
pub fn get_conversation_key(
|
||||
secret_key: &SecretKey,
|
||||
public_key: &PublicKey,
|
||||
) -> NostrResult<[u8; 32]> {
|
||||
let shared_x = ecdh_shared_secret_raw_x(secret_key, public_key)?;
|
||||
Ok(hkdf_extract(b"nip44-v2", &shared_x))
|
||||
}
|
||||
|
||||
/// Derive the per-message keys from a conversation key and nonce.
|
||||
///
|
||||
/// Returns `(chacha_key, chacha_nonce, hmac_key)`.
|
||||
fn get_message_keys(
|
||||
conversation_key: &[u8; 32],
|
||||
nonce: &[u8; NONCE_SIZE],
|
||||
) -> ([u8; 32], [u8; 12], [u8; 32]) {
|
||||
let keys = hkdf_expand(conversation_key, nonce, 76);
|
||||
let mut chacha_key = [0u8; 32];
|
||||
let mut chacha_nonce = [0u8; 12];
|
||||
let mut hmac_key = [0u8; 32];
|
||||
chacha_key.copy_from_slice(&keys[0..32]);
|
||||
chacha_nonce.copy_from_slice(&keys[32..44]);
|
||||
hmac_key.copy_from_slice(&keys[44..76]);
|
||||
(chacha_key, chacha_nonce, hmac_key)
|
||||
}
|
||||
|
||||
/// Encrypt a plaintext using NIP-44 v2 with a caller-supplied nonce.
|
||||
///
|
||||
/// Returns the raw binary payload: `version || nonce || ciphertext || mac`.
|
||||
/// This is primarily useful for reproducible tests; production callers should
|
||||
/// use [`nip44_encrypt`].
|
||||
pub fn nip44_encrypt_with_nonce(
|
||||
sender_sk: &SecretKey,
|
||||
recipient_pk: &PublicKey,
|
||||
plaintext: &[u8],
|
||||
nonce: &[u8; NONCE_SIZE],
|
||||
) -> NostrResult<Vec<u8>> {
|
||||
let conversation_key = get_conversation_key(sender_sk, recipient_pk)?;
|
||||
let (chacha_key, chacha_nonce, hmac_key) = get_message_keys(&conversation_key, nonce);
|
||||
|
||||
let padded = pad_plaintext(plaintext)?;
|
||||
|
||||
// ChaCha20 (counter 0) — symmetric stream cipher, so the same call decrypts.
|
||||
let mut ciphertext = padded.clone();
|
||||
let mut cipher = ChaCha20::new(&chacha_key.into(), &chacha_nonce.into());
|
||||
cipher.apply_keystream(&mut ciphertext);
|
||||
|
||||
// HMAC-SHA256 over nonce || ciphertext.
|
||||
let mut aad = Vec::with_capacity(NONCE_SIZE + ciphertext.len());
|
||||
aad.extend_from_slice(nonce);
|
||||
aad.extend_from_slice(&ciphertext);
|
||||
let mac = hmac_sha256(&hmac_key, &aad);
|
||||
|
||||
let mut payload = Vec::with_capacity(1 + NONCE_SIZE + ciphertext.len() + MAC_SIZE);
|
||||
payload.push(VERSION);
|
||||
payload.extend_from_slice(nonce);
|
||||
payload.extend_from_slice(&ciphertext);
|
||||
payload.extend_from_slice(&mac);
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
/// Encrypt a plaintext using NIP-44 v2.
|
||||
///
|
||||
/// Returns the raw binary payload: `version || nonce || ciphertext || mac`.
|
||||
pub fn nip44_encrypt(
|
||||
sender_sk: &SecretKey,
|
||||
recipient_pk: &PublicKey,
|
||||
@@ -50,91 +166,63 @@ pub fn nip44_encrypt(
|
||||
if plaintext.len() > MAX_PLAINTEXT_SIZE {
|
||||
return Err(NostrError::Nip44BufferTooSmall);
|
||||
}
|
||||
|
||||
// Derive shared secret via ECDH (try both parities)
|
||||
let (shared_even, _shared_odd) = ecdh_shared_secret_both(sender_sk, recipient_pk)?;
|
||||
// Use even parity by default (most common in Nostr)
|
||||
let conv_key = derive_conversation_key(&shared_even);
|
||||
|
||||
// Encrypt with ChaCha20-Poly1305
|
||||
let cipher = ChaCha20Poly1305::new_from_slice(&conv_key)
|
||||
.map_err(|_| NostrError::Nip44InvalidFormat)?;
|
||||
|
||||
// Generate random nonce
|
||||
let mut nonce_bytes = [0u8; NONCE_SIZE];
|
||||
OsRng.fill_bytes(&mut nonce_bytes);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let ciphertext = cipher
|
||||
.encrypt(nonce, plaintext)
|
||||
.map_err(|_| NostrError::Nip44InvalidFormat)?;
|
||||
|
||||
// Prepend nonce to ciphertext
|
||||
let mut result = Vec::with_capacity(NONCE_SIZE + ciphertext.len());
|
||||
result.extend_from_slice(&nonce_bytes);
|
||||
result.extend_from_slice(&ciphertext);
|
||||
|
||||
Ok(result)
|
||||
let mut nonce = [0u8; NONCE_SIZE];
|
||||
OsRng.fill_bytes(&mut nonce);
|
||||
nip44_encrypt_with_nonce(sender_sk, recipient_pk, plaintext, &nonce)
|
||||
}
|
||||
|
||||
/// Decrypt a NIP-44 encrypted message.
|
||||
/// Decrypt a NIP-44 v2 payload.
|
||||
///
|
||||
/// Expects ciphertext in the format: nonce (12 bytes) || encrypted_data || tag.
|
||||
/// Expects the raw binary payload: `version || nonce || ciphertext || mac`.
|
||||
pub fn nip44_decrypt(
|
||||
recipient_sk: &SecretKey,
|
||||
sender_pk: &PublicKey,
|
||||
ciphertext: &[u8],
|
||||
payload: &[u8],
|
||||
) -> NostrResult<Vec<u8>> {
|
||||
if ciphertext.len() < NONCE_SIZE + 16 {
|
||||
// Minimum: nonce + tag
|
||||
if payload.len() < MIN_PAYLOAD_SIZE {
|
||||
return Err(NostrError::Nip44InvalidFormat);
|
||||
}
|
||||
if payload[0] != VERSION {
|
||||
return Err(NostrError::Nip44InvalidFormat);
|
||||
}
|
||||
|
||||
// Derive shared secret via ECDH (try both parities)
|
||||
let (shared_even, shared_odd) = ecdh_shared_secret_both(recipient_sk, sender_pk)?;
|
||||
let nonce: [u8; NONCE_SIZE] = payload[1..1 + NONCE_SIZE]
|
||||
.try_into()
|
||||
.map_err(|_| NostrError::Nip44InvalidFormat)?;
|
||||
let mac_start = payload.len() - MAC_SIZE;
|
||||
let ciphertext = &payload[1 + NONCE_SIZE..mac_start];
|
||||
let received_mac = &payload[mac_start..];
|
||||
|
||||
// Try even parity first, then odd
|
||||
for shared_secret in [shared_even, shared_odd] {
|
||||
let conv_key = derive_conversation_key(&shared_secret);
|
||||
let cipher = match ChaCha20Poly1305::new_from_slice(&conv_key) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let nonce = Nonce::from_slice(&ciphertext[..NONCE_SIZE]);
|
||||
let encrypted = &ciphertext[NONCE_SIZE..];
|
||||
if let Ok(plaintext) = cipher.decrypt(nonce, encrypted) {
|
||||
return Ok(plaintext);
|
||||
}
|
||||
let conversation_key = get_conversation_key(recipient_sk, sender_pk)?;
|
||||
let (chacha_key, chacha_nonce, hmac_key) = get_message_keys(&conversation_key, &nonce);
|
||||
|
||||
// Verify HMAC over nonce || ciphertext (constant-time).
|
||||
let mut aad = Vec::with_capacity(NONCE_SIZE + ciphertext.len());
|
||||
aad.extend_from_slice(&nonce);
|
||||
aad.extend_from_slice(ciphertext);
|
||||
let computed_mac = hmac_sha256(&hmac_key, &aad);
|
||||
if !constant_time_eq(&computed_mac, received_mac) {
|
||||
return Err(NostrError::Nip44DecryptFailed);
|
||||
}
|
||||
Err(NostrError::Nip44DecryptFailed)
|
||||
|
||||
// Decrypt with ChaCha20 (counter 0).
|
||||
let mut padded = ciphertext.to_vec();
|
||||
let mut cipher = ChaCha20::new(&chacha_key.into(), &chacha_nonce.into());
|
||||
cipher.apply_keystream(&mut padded);
|
||||
|
||||
unpad_plaintext(&padded)
|
||||
}
|
||||
|
||||
/// Encrypt with a specific nonce (for testing/reproducibility).
|
||||
pub fn nip44_encrypt_with_nonce(
|
||||
sender_sk: &SecretKey,
|
||||
recipient_pk: &PublicKey,
|
||||
plaintext: &[u8],
|
||||
nonce: &[u8; NONCE_SIZE],
|
||||
) -> NostrResult<Vec<u8>> {
|
||||
if plaintext.len() > MAX_PLAINTEXT_SIZE {
|
||||
return Err(NostrError::Nip44BufferTooSmall);
|
||||
/// Constant-time byte comparison.
|
||||
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let (shared_even, _) = ecdh_shared_secret_both(sender_sk, recipient_pk)?;
|
||||
let conv_key = derive_conversation_key(&shared_even);
|
||||
|
||||
let cipher = ChaCha20Poly1305::new_from_slice(&conv_key)
|
||||
.map_err(|_| NostrError::Nip44InvalidFormat)?;
|
||||
|
||||
let ciphertext = cipher
|
||||
.encrypt(Nonce::from_slice(nonce), plaintext)
|
||||
.map_err(|_| NostrError::Nip44InvalidFormat)?;
|
||||
|
||||
let mut result = Vec::with_capacity(NONCE_SIZE + ciphertext.len());
|
||||
result.extend_from_slice(nonce);
|
||||
result.extend_from_slice(&ciphertext);
|
||||
|
||||
Ok(result)
|
||||
let mut diff = 0u8;
|
||||
for (x, y) in a.iter().zip(b.iter()) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -142,20 +230,169 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::crypto::keys::generate_keypair;
|
||||
|
||||
fn hex32(s: &str) -> [u8; 32] {
|
||||
let v = hex::decode(s).unwrap();
|
||||
let mut a = [0u8; 32];
|
||||
a.copy_from_slice(&v);
|
||||
a
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nip44_encrypt_decrypt() {
|
||||
fn test_calc_padded_len_vectors() {
|
||||
// Official NIP-44 v2 test vectors.
|
||||
let vectors: &[(usize, usize)] = &[
|
||||
(16, 32),
|
||||
(32, 32),
|
||||
(33, 64),
|
||||
(37, 64),
|
||||
(45, 64),
|
||||
(49, 64),
|
||||
(64, 64),
|
||||
(65, 96),
|
||||
(100, 128),
|
||||
(111, 128),
|
||||
(200, 224),
|
||||
(250, 256),
|
||||
(320, 320),
|
||||
(383, 384),
|
||||
(384, 384),
|
||||
(400, 448),
|
||||
(500, 512),
|
||||
(512, 512),
|
||||
(515, 640),
|
||||
(700, 768),
|
||||
(800, 896),
|
||||
(900, 1024),
|
||||
(1020, 1024),
|
||||
(65536, 65536),
|
||||
];
|
||||
for (input, expected) in vectors {
|
||||
assert_eq!(calc_padded_len(*input), *expected, "input {}", input);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_conversation_key_vectors() {
|
||||
// Official NIP-44 v2 test vectors.
|
||||
let vectors: &[(&str, &str, &str)] = &[
|
||||
(
|
||||
"315e59ff51cb9209768cf7da80791ddcaae56ac9775eb25b6dee1234bc5d2268",
|
||||
"c2f9d9948dc8c7c38321e4b85c8558872eafa0641cd269db76848a6073e69133",
|
||||
"3dfef0ce2a4d80a25e7a328accf73448ef67096f65f79588e358d9a0eb9013f1",
|
||||
),
|
||||
(
|
||||
"a1e37752c9fdc1273be53f68c5f74be7c8905728e8de75800b94262f9497c86e",
|
||||
"03bb7947065dde12ba991ea045132581d0954f042c84e06d8c00066e23c1a800",
|
||||
"4d14f36e81b8452128da64fe6f1eae873baae2f444b02c950b90e43553f2178b",
|
||||
),
|
||||
(
|
||||
"98a5902fd67518a0c900f0fb62158f278f94a21d6f9d33d30cd3091195500311",
|
||||
"aae65c15f98e5e677b5050de82e3aba47a6fe49b3dab7863cf35d9478ba9f7d1",
|
||||
"9c00b769d5f54d02bf175b7284a1cbd28b6911b06cda6666b2243561ac96bad7",
|
||||
),
|
||||
];
|
||||
for (sec1, pub2, expected) in vectors {
|
||||
let sk = SecretKey::from_bytes(hex32(sec1));
|
||||
let pk = PublicKey::from_bytes(hex32(pub2));
|
||||
let ck = get_conversation_key(&sk, &pk).unwrap();
|
||||
assert_eq!(hex::encode(ck), *expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_message_keys_vector() {
|
||||
// Official NIP-44 v2 test vector.
|
||||
let conversation_key = hex32("a1a3d60f3470a8612633924e91febf96dc5366ce130f658b1f0fc652c20b3b54");
|
||||
let nonce = hex32("e1e6f880560d6d149ed83dcc7e5861ee62a5ee051f7fde9975fe5d25d2a02d72");
|
||||
let (chacha_key, chacha_nonce, hmac_key) = get_message_keys(&conversation_key, &nonce);
|
||||
assert_eq!(
|
||||
hex::encode(chacha_key),
|
||||
"f145f3bed47cb70dbeaac07f3a3fe683e822b3715edb7c4fe310829014ce7d76"
|
||||
);
|
||||
assert_eq!(hex::encode(chacha_nonce), "c4ad129bb01180c0933a160c");
|
||||
assert_eq!(
|
||||
hex::encode(hmac_key),
|
||||
"027c1db445f05e2eee864a0975b0ddef5b7110583c8c192de3732571ca5838c4"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_vectors() {
|
||||
// Official NIP-44 v2 test vectors (sec1, sec2, nonce, plaintext, payload).
|
||||
let vectors: &[(&str, &str, &str, &str, &str)] = &[
|
||||
(
|
||||
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"a",
|
||||
"AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb",
|
||||
),
|
||||
(
|
||||
"0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"f00000000000000000000000000000f00000000000000000000000000000000f",
|
||||
"🍕🫃",
|
||||
"AvAAAAAAAAAAAAAAAAAAAPAAAAAAAAAAAAAAAAAAAAAPSKSK6is9ngkX2+cSq85Th16oRTISAOfhStnixqZziKMDvB0QQzgFZdjLTPicCJaV8nDITO+QfaQ61+KbWQIOO2Yj",
|
||||
),
|
||||
];
|
||||
for (sec1, sec2, nonce, plaintext, payload_b64) in vectors {
|
||||
let sk1 = SecretKey::from_bytes(hex32(sec1));
|
||||
let sk2 = SecretKey::from_bytes(hex32(sec2));
|
||||
let pk1 = crate::crypto::keys::public_key_from_secret_key(&sk1).unwrap();
|
||||
let pk2 = crate::crypto::keys::public_key_from_secret_key(&sk2).unwrap();
|
||||
let nonce_arr = hex32(nonce);
|
||||
|
||||
// Encrypt must reproduce the exact payload.
|
||||
let produced =
|
||||
nip44_encrypt_with_nonce(&sk1, &pk2, plaintext.as_bytes(), &nonce_arr).unwrap();
|
||||
assert_eq!(
|
||||
crate::util::base64_encode(&produced),
|
||||
*payload_b64,
|
||||
"encrypt mismatch for plaintext {:?}",
|
||||
plaintext
|
||||
);
|
||||
|
||||
// Decrypt the reference payload.
|
||||
let raw = crate::util::base64_decode(payload_b64).unwrap();
|
||||
let decrypted = nip44_decrypt(&sk2, &pk1, &raw).unwrap();
|
||||
assert_eq!(String::from_utf8(decrypted).unwrap(), *plaintext);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip() {
|
||||
let (sk_a, pk_a) = generate_keypair();
|
||||
let (sk_b, pk_b) = generate_keypair();
|
||||
|
||||
let plaintext = b"Hello, Nostr! This is a secret message.";
|
||||
let encrypted = nip44_encrypt(&sk_a, &pk_b, plaintext).unwrap();
|
||||
let decrypted = nip44_decrypt(&sk_b, &pk_a, &encrypted).unwrap();
|
||||
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nip44_max_plaintext() {
|
||||
fn test_roundtrip_unicode() {
|
||||
let (sk_a, pk_a) = generate_keypair();
|
||||
let (sk_b, pk_b) = generate_keypair();
|
||||
|
||||
let plaintext = "Hello 🌍 World! 🚀".as_bytes();
|
||||
let encrypted = nip44_encrypt(&sk_a, &pk_b, plaintext).unwrap();
|
||||
let decrypted = nip44_decrypt(&sk_b, &pk_a, &encrypted).unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_empty() {
|
||||
let (sk_a, pk_a) = generate_keypair();
|
||||
let (sk_b, pk_b) = generate_keypair();
|
||||
|
||||
let encrypted = nip44_encrypt(&sk_a, &pk_b, b"").unwrap();
|
||||
let decrypted = nip44_decrypt(&sk_b, &pk_a, &encrypted).unwrap();
|
||||
assert_eq!(decrypted, b"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_plaintext() {
|
||||
let (sk_a, pk_a) = generate_keypair();
|
||||
let (sk_b, pk_b) = generate_keypair();
|
||||
|
||||
@@ -166,21 +403,29 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nip44_overflow() {
|
||||
fn test_overflow() {
|
||||
let (sk, pk) = generate_keypair();
|
||||
let plaintext = vec![0x42u8; MAX_PLAINTEXT_SIZE + 1];
|
||||
assert!(nip44_encrypt(&sk, &pk, &plaintext).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nip44_wrong_key() {
|
||||
fn test_wrong_key() {
|
||||
let (sk_a, _) = generate_keypair();
|
||||
let (sk_b, pk_b) = generate_keypair();
|
||||
let (_sk_b, pk_b) = generate_keypair();
|
||||
let (sk_c, _) = generate_keypair();
|
||||
|
||||
let plaintext = b"secret";
|
||||
let encrypted = nip44_encrypt(&sk_a, &pk_b, plaintext).unwrap();
|
||||
// Decrypting with wrong key should fail
|
||||
assert!(nip44_decrypt(&sk_c, &pk_b, &encrypted).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bad_version() {
|
||||
let (sk_a, pk_a) = generate_keypair();
|
||||
let (sk_b, pk_b) = generate_keypair();
|
||||
let mut encrypted = nip44_encrypt(&sk_a, &pk_b, b"hi").unwrap();
|
||||
encrypted[0] = 0x01;
|
||||
assert!(nip44_decrypt(&sk_b, &pk_a, &encrypted).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user