Fix NIP-04/NIP-44 interop gaps and NIP-42 id verification

The 0.1.1 and 0.1.2 fixes passed the test suite because every crypto test
only round-tripped our own output, which a wrong-but-symmetric
implementation also passes. Verified all paths against `nak` and found a
third instance of the same class of bug still live in the released code.

- Signer (CRITICAL): LocalSigner::nip04_encrypt/decrypt still used a private
  copy of the pre-0.1.1 NIP-04 code (SHA-256-hashed ECDH key, base64(iv||ct)
  layout), so it could neither read nor produce standard NIP-04 DMs. NIP-04
  now lives only in nostr_core::crypto::nip04; nostr_nips::nip004 re-exports
  it and LocalSigner delegates to it.
- NIP-44: reject empty plaintext on encrypt and on decrypt (length prefix 0)
  per spec; bound payload length to 99..=65603. We previously emitted
  payloads that nak rejects with "invalid padding".
- NIP-42 (security): verify_auth_event now recomputes the event id. Tags and
  content could be altered after signing and the event still verified,
  because only the signature over the claimed id was checked.
- Docs: keys.rs claimed NIP-44 uses the hashed ECDH output, the exact
  mix-up behind the 0.1.2 fix. Corrected, and ecdh_shared_secret now warns
  that no NIP uses it.

Tests:
- nak-generated known-answer vectors for NIP-04, NIP-44 and LocalSigner; the
  integration NIP-04 "known_vectors" test now checks real ciphertext
  byte-for-byte instead of round-tripping.
- Official NIP-06 spec vectors; real RFC 5869 HKDF, HMAC-SHA512 and PBKDF2
  values (these previously asserted only output length, and the HMAC-SHA512
  expected constant was fabricated and never compared).
- Fixed mistyped BIP-32 chain codes in NIP-06 test comments.
- Negative tests: tampered NIP-44 MAC/ciphertext, zero length prefix,
  malformed NIP-04 input, NIP-42 tampered body / bad sig / wrong relay /
  stale timestamp.
- Documented the rule in tests/src/lib.rs: every encryption scheme needs at
  least one vector from an independent implementation.
This commit is contained in:
Laan Tungir
2026-09-27 18:10:37 -04:00
parent 855f999c7a
commit 5681f17f64
15 changed files with 643 additions and 418 deletions
+24
View File
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- **Signer** (CRITICAL): `LocalSigner::nip04_encrypt/decrypt` still used a
private copy of the pre-0.1.1 NIP-04 code (SHA-256-hashed ECDH key,
`base64(iv||ct)` layout), so it could neither read nor produce standard
NIP-04 DMs. NIP-04 now lives in one place (`nostr_core::crypto::nip04`);
`nostr_nips::nip004` re-exports it and `LocalSigner` delegates to it.
- **NIP-44**: Empty plaintext is now rejected on encrypt and on decrypt
(length prefix 0), per spec. Previously we emitted payloads `nak` rejects
with "invalid padding". Payload length is now bounded to the spec range
(99..=65603 bytes).
- **NIP-42** (security): `verify_auth_event` now recomputes the event id.
Previously tags/content could be altered after signing and the event still
verified, because only the signature over the *claimed* id was checked.
### Tests
- Added `nak`-generated known-answer vectors for NIP-04, NIP-44 and the
`LocalSigner`; the integration NIP-04 "known_vectors" test (which only
round-tripped) now checks real ciphertext byte-for-byte.
- Added official NIP-06 spec vectors, RFC 5869 HKDF and real HMAC-SHA512 /
PBKDF2 values (these previously asserted only output length); fixed
mistyped BIP-32 chain codes in test comments.
- Added negative tests: tampered MAC/ciphertext, malformed NIP-04 input,
NIP-42 tampered body / bad sig / wrong relay / stale timestamp.
## [0.1.2] - 2026-09-27
## [0.1.1] - 2026-09-24
Generated
+10 -12
View File
@@ -385,7 +385,7 @@ dependencies = [
[[package]]
name = "event-signer"
version = "0.1.1"
version = "0.1.2"
dependencies = [
"nostr-core",
"nostr-nips",
@@ -868,7 +868,7 @@ dependencies = [
[[package]]
name = "integration-tests"
version = "0.1.1"
version = "0.1.2"
dependencies = [
"nostr-core",
"nostr-nips",
@@ -910,7 +910,7 @@ dependencies = [
[[package]]
name = "keypair-generator"
version = "0.1.1"
version = "0.1.2"
dependencies = [
"nostr-core",
]
@@ -1041,12 +1041,13 @@ dependencies = [
[[package]]
name = "nostr-core"
version = "0.1.1"
version = "0.1.2"
dependencies = [
"aes",
"base64",
"bech32",
"block-modes",
"cbc",
"chacha20",
"chacha20poly1305",
"chrono",
@@ -1064,7 +1065,7 @@ dependencies = [
[[package]]
name = "nostr-core-umbrella"
version = "0.1.1"
version = "0.1.2"
dependencies = [
"nostr-core",
"nostr-nips",
@@ -1075,7 +1076,7 @@ dependencies = [
[[package]]
name = "nostr-nips"
version = "0.1.1"
version = "0.1.2"
dependencies = [
"aes",
"block-modes",
@@ -1097,7 +1098,7 @@ dependencies = [
[[package]]
name = "nostr-relay"
version = "0.1.1"
version = "0.1.2"
dependencies = [
"futures-util",
"nostr-core",
@@ -1115,7 +1116,7 @@ dependencies = [
[[package]]
name = "nostr-services"
version = "0.1.1"
version = "0.1.2"
dependencies = [
"nostr-core",
"nostr-relay",
@@ -1130,10 +1131,8 @@ dependencies = [
[[package]]
name = "nostr-signer"
version = "0.1.1"
version = "0.1.2"
dependencies = [
"aes",
"cbc",
"hex",
"libc",
"nostr-core",
@@ -1141,7 +1140,6 @@ dependencies = [
"serde",
"serde_json",
"serialport",
"sha2",
"thiserror 2.0.20",
"tokio",
"tracing",
+1
View File
@@ -12,6 +12,7 @@ hex.workspace = true
chacha20poly1305.workspace = true
chacha20.workspace = true
aes.workspace = true
cbc = "0.1"
block-modes.workspace = true
rand.workspace = true
zeroize.workspace = true
+35 -21
View File
@@ -124,31 +124,45 @@ mod tests {
assert_eq!(hex::encode(result), expected);
}
// NOTE: these previously asserted only output *length*, and the HMAC-512
// "expected" constant was fabricated and never compared. Values below are
// cross-checked against Python's hashlib/hmac.
#[test]
fn test_hmac_sha512() {
let key = b"key";
let data = b"The quick brown fox jumps over the lazy dog";
let result = hmac_sha512(key, data);
let expected = "b42af09057bac1e2d41708e48a902e3b9967c217f3f7b3e3c5f9d9b0c8c7d5e6\
8c3c7d5e6b42af09057bac1e2d41708e48a902e3b9967c217f3f7b3e3c5f9d9b0";
// Just verify it's 64 bytes
assert_eq!(result.len(), 64);
let result = hmac_sha512(b"key", b"The quick brown fox jumps over the lazy dog");
assert_eq!(
hex::encode(result),
"b42af09057bac1e2d41708e48a902e09b5ff7f12ab428a4fe86653c73dd248fb\
82f948a549f7b791a5b41915ee4d1ec3935357e4e2317250d0372afa2ebeeb3a"
);
}
/// RFC 5869, Test Case 1.
#[test]
fn test_hkdf_rfc5869_case1() {
let ikm = [0x0bu8; 22];
let salt: Vec<u8> = (0x00..=0x0c).collect();
let info: Vec<u8> = (0xf0..=0xf9).collect();
let prk = hkdf_extract(&salt, &ikm);
assert_eq!(
hex::encode(prk),
"077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5"
);
let okm = hkdf(&salt, &ikm, &info, 42);
assert_eq!(
hex::encode(okm),
"3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865"
);
}
#[test]
fn test_hkdf() {
let salt = b"salt";
let ikm = b"input key material";
let info = b"some context";
let okm = hkdf(salt, ikm, info, 42);
assert_eq!(okm.len(), 42);
}
#[test]
fn test_pbkdf2() {
let password = b"password";
let salt = b"salt";
let result = pbkdf2_hmac_sha512(password, salt, 1, 64);
assert_eq!(result.len(), 64);
fn test_pbkdf2_hmac_sha512_known_answer() {
let result = pbkdf2_hmac_sha512(b"password", b"salt", 1, 64);
assert_eq!(
hex::encode(result),
"867f70cf1ade02cff3752599a3a53dc4af34c7a669815ae5d513554e1c8cf252\
c02d470a285a0501bad999bfe943c08f050235d7d68b1da55e63f73b60a57fce"
);
}
}
+59 -22
View File
@@ -73,11 +73,16 @@ pub fn schnorr_verify(
}
}
/// Compute ECDH shared secret between a secret key and a public key.
/// libsecp256k1's default ECDH: `SHA256(compressed(secret_key * public_key))`.
///
/// Returns both possible shared secrets (one for even parity, one for odd).
/// The caller should try both to determine which one works for their use case.
/// In Nostr, both parties must agree on the same parity for ECDH to work.
/// **This is NOT the key used by NIP-04 or NIP-44.** Both of those use the
/// raw, unhashed X coordinate — see [`ecdh_shared_secret_raw_x`]. Using this
/// function for either NIP produces ciphertext no other client can read
/// (this was the root cause of the 0.1.1 and 0.1.2 fixes).
///
/// The x-only public key is lifted with even parity. Note that the hashed
/// output *does* depend on parity (the compressed prefix byte is hashed), so
/// the two sides of a conversation may disagree unless both use even-Y keys.
pub fn ecdh_shared_secret(
secret_key: &SecretKey,
public_key: &PublicKey,
@@ -94,25 +99,18 @@ pub fn ecdh_shared_secret(
let mut result_even = [0u8; 32];
result_even.copy_from_slice(&shared_even.secret_bytes());
// Try odd parity
let pk_odd = secp256k1::PublicKey::from_x_only_public_key(xonly, secp256k1::Parity::Odd);
let shared_odd = secp256k1::ecdh::SharedSecret::new(&pk_odd, &sk);
let mut result_odd = [0u8; 32];
result_odd.copy_from_slice(&shared_odd.secret_bytes());
// Return even parity by default (most common in Nostr)
// The caller can use ecdh_shared_secret_both() if they need to try both
Ok(result_even)
}
/// Compute the raw X coordinate of the ECDH shared point, unhashed.
///
/// This is the key derivation required by NIP-04 (legacy encrypted direct
/// messages): the AES-256 key is the X coordinate of `secret_key *
/// public_key` as a curve point, with no hashing applied. Note that
/// [`ecdh_shared_secret`] and [`ecdh_shared_secret_both`] apply libsecp256k1's
/// default SHA256 hash over the compressed point and are used by NIP-44; do
/// not mix the two derivations.
/// This is the shared-secret derivation for **both** NIP-04 (used directly
/// as the AES-256 key) and NIP-44 (used as HKDF input keying material).
/// The X coordinate of `k * P` is identical for `P` and `-P`, so this is
/// parity-independent and always symmetric between the two parties.
///
/// Do not confuse with [`ecdh_shared_secret`] / [`ecdh_shared_secret_both`],
/// which return libsecp256k1's *hashed* output and are not used by any NIP.
pub fn ecdh_shared_secret_raw_x(
secret_key: &SecretKey,
public_key: &PublicKey,
@@ -123,9 +121,7 @@ pub fn ecdh_shared_secret_raw_x(
let xonly = XOnlyPublicKey::from_slice(public_key.as_bytes())
.map_err(|_| NostrError::InvalidInput)?;
// NIP-04 does not specify a parity convention; the shared point is
// computed against the full public key reconstructed with even parity
// (the standard x-only reconstruction used throughout this crate).
// Parity choice is irrelevant: x(k*P) == x(k*(-P)).
let pk = secp256k1::PublicKey::from_x_only_public_key(xonly, secp256k1::Parity::Even);
// Returns the raw (x, y) coordinates of the shared point, 32 bytes each,
@@ -136,8 +132,10 @@ pub fn ecdh_shared_secret_raw_x(
Ok(result)
}
/// Compute both possible ECDH shared secrets (even and odd parity).
/// libsecp256k1 hashed ECDH for both parity liftings of an x-only key.
/// Returns (even_parity_result, odd_parity_result).
///
/// Not used by NIP-04/NIP-44 — see [`ecdh_shared_secret_raw_x`].
pub fn ecdh_shared_secret_both(
secret_key: &SecretKey,
public_key: &PublicKey,
@@ -184,6 +182,45 @@ mod tests {
assert!(!schnorr_verify(&pk, &wrong_msg, &sig).unwrap());
}
/// Pins the raw-X derivation against the official NIP-44 v2
/// `get_conversation_key` vector (sec1=…01, pub2=pub(…02)): raw-X must
/// equal x(2·G·1) = x(2G), a well-known constant.
#[test]
fn test_ecdh_raw_x_known_answer() {
let sk = SecretKey::from_bytes({
let mut b = [0u8; 32];
b[31] = 1;
b
});
let mut two = [0u8; 32];
two[31] = 2;
let pk2 = public_key_from_secret_key(&SecretKey::from_bytes(two)).unwrap();
let x = ecdh_shared_secret_raw_x(&sk, &pk2).unwrap();
// x-coordinate of 2G on secp256k1.
assert_eq!(
hex::encode(x),
"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"
);
assert_eq!(pk2.to_hex(), hex::encode(x), "x-only pubkey of 2 is x(2G)");
}
/// Raw-X ECDH must agree for both parties, including peers whose real
/// public key has odd Y (sk=3 → 3G has odd Y).
#[test]
fn test_ecdh_raw_x_symmetric_with_odd_y_peer() {
let mut three = [0u8; 32];
three[31] = 3;
let sk_odd = SecretKey::from_bytes(three);
let pk_odd = public_key_from_secret_key(&sk_odd).unwrap();
for _ in 0..8 {
let (sk, pk) = generate_keypair();
assert_eq!(
ecdh_shared_secret_raw_x(&sk, &pk_odd).unwrap(),
ecdh_shared_secret_raw_x(&sk_odd, &pk).unwrap()
);
}
}
#[test]
fn test_ecdh() {
let (sk_a, pk_a) = generate_keypair();
+4 -3
View File
@@ -3,13 +3,14 @@
//! This module provides:
//! - Key generation, Schnorr signing/verification (secp256k1)
//! - SHA-256 hashing (streaming and single-shot)
//! - HMAC-SHA256/512
//! - ChaCha20-Poly1305 AEAD (NIP-44)
//! - AES-256-CBC (NIP-04 legacy)
//! - HMAC-SHA256/512, HKDF, PBKDF2
//! - NIP-44 v2 (ChaCha20 + HMAC-SHA256)
//! - NIP-04 legacy DMs (AES-256-CBC)
pub mod keys;
pub mod sha256;
pub mod hmac;
pub mod nip04;
pub mod nip44;
pub use keys::*;
+234
View File
@@ -0,0 +1,234 @@
//! NIP-04: Legacy Encrypted Direct Messages (AES-256-CBC).
//!
//! This is the **single** NIP-04 implementation in the workspace. Both
//! `nostr_nips::nip004` and `nostr_signer::local::LocalSigner` delegate here;
//! do not re-implement the scheme elsewhere (a stale duplicate in the local
//! signer is exactly how the pre-0.1.1 bug survived the 0.1.1 fix).
//!
//! Implements the standard NIP-04 scheme as deployed by normal Nostr
//! clients:
//!
//! * The AES-256 key is the **raw X coordinate** of the ECDH shared point
//! (`secret_key * recipient_public_key`), unhashed.
//! * The wire format is `base64(ciphertext)?iv=base64(iv)` — the ciphertext
//! first, then a `?iv=` separator, then the base64-encoded IV.
//!
//! Any deviation from this (e.g. hashing the shared secret, or packing the
//! IV in front of the ciphertext) produces messages that only the deviating
//! implementation can read. See the known-answer tests below, generated with
//! `nak`, for pinned interop vectors.
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit};
use cbc::{Decryptor, Encryptor};
use rand::rngs::OsRng;
use rand::RngCore;
use crate::crypto::keys::ecdh_shared_secret_raw_x;
use crate::error::NostrError;
use crate::types::{PublicKey, SecretKey};
use crate::NostrResult;
type Aes256CbcEnc = Encryptor<aes::Aes256>;
type Aes256CbcDec = Decryptor<aes::Aes256>;
const IV_SIZE: usize = 16;
/// Separator between the base64 ciphertext and the base64 IV, per NIP-04.
const IV_SEPARATOR: &str = "?iv=";
/// Encrypt a plaintext using the standard NIP-04 scheme.
///
/// Returns `base64(ciphertext)?iv=base64(iv)`.
pub fn nip04_encrypt(
sender_sk: &SecretKey,
recipient_pk: &PublicKey,
plaintext: &str,
) -> NostrResult<String> {
let mut iv = [0u8; IV_SIZE];
OsRng.fill_bytes(&mut iv);
nip04_encrypt_with_iv(sender_sk, recipient_pk, plaintext, &iv)
}
/// Encrypt with a caller-supplied IV. Intended for reproducible known-answer
/// tests only — production callers must use [`nip04_encrypt`].
pub fn nip04_encrypt_with_iv(
sender_sk: &SecretKey,
recipient_pk: &PublicKey,
plaintext: &str,
iv: &[u8; IV_SIZE],
) -> NostrResult<String> {
// Raw X coordinate of the shared point, unhashed — the NIP-04 AES key.
// The X coordinate is parity-independent, so no parity handling needed.
let key = ecdh_shared_secret_raw_x(sender_sk, recipient_pk)?;
let mut buf = vec![0u8; plaintext.len() + 16];
buf[..plaintext.len()].copy_from_slice(plaintext.as_bytes());
let cipher =
Aes256CbcEnc::new_from_slices(&key, iv).map_err(|_| NostrError::Nip04InvalidFormat)?;
let encrypted = cipher
.encrypt_padded_mut::<Pkcs7>(&mut buf, plaintext.len())
.map_err(|_| NostrError::Nip04InvalidFormat)?;
Ok(format!(
"{}{}{}",
crate::util::base64_encode(encrypted),
IV_SEPARATOR,
crate::util::base64_encode(iv)
))
}
/// Decrypt a standard NIP-04 encrypted message.
///
/// Expects `base64(ciphertext)?iv=base64(iv)`. Returns
/// [`NostrError::Nip04InvalidFormat`] if the `?iv=` separator is missing or
/// either part fails to base64-decode.
pub fn nip04_decrypt(
recipient_sk: &SecretKey,
sender_pk: &PublicKey,
ciphertext_b64: &str,
) -> NostrResult<String> {
let (ct_b64, iv_b64) = ciphertext_b64
.split_once(IV_SEPARATOR)
.ok_or(NostrError::Nip04InvalidFormat)?;
let encrypted =
crate::util::base64_decode(ct_b64).map_err(|_| NostrError::Nip04InvalidFormat)?;
let iv = crate::util::base64_decode(iv_b64).map_err(|_| NostrError::Nip04InvalidFormat)?;
if iv.len() != IV_SIZE || encrypted.is_empty() || encrypted.len() % 16 != 0 {
return Err(NostrError::Nip04InvalidFormat);
}
// Raw X coordinate of the shared point, unhashed — the NIP-04 AES key.
let key = ecdh_shared_secret_raw_x(recipient_sk, sender_pk)?;
let cipher =
Aes256CbcDec::new_from_slices(&key, &iv).map_err(|_| NostrError::Nip04InvalidFormat)?;
let mut buf = encrypted;
let decrypted = cipher
.decrypt_padded_mut::<Pkcs7>(&mut buf)
.map_err(|_| NostrError::Nip04DecryptFailed)?;
String::from_utf8(decrypted.to_vec()).map_err(|_| NostrError::Nip04DecryptFailed)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::keys::{ecdh_shared_secret, generate_keypair};
// Fixed test keys (NOT used for anything real).
const TEST_SK: [u8; 32] = [0x11; 32];
// `nak key public 1111...11`
const TEST_PEER_PK: &str = "4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa";
// Generated with:
// nak encrypt --nip04 --sec 1111...11 -p 4f355bdc...71aa "nak vector"
const NAK_PLAINTEXT: &str = "nak vector";
const NAK_CT_B64: &str = "H3hKxnfd4MX/a9Rl0cvmFg==";
const NAK_IV_B64: &str = "y8Ngg9dcK8XinwERJpzNsA==";
const NAK_CIPHERTEXT: &str = "H3hKxnfd4MX/a9Rl0cvmFg==?iv=y8Ngg9dcK8XinwERJpzNsA==";
fn test_keys() -> (SecretKey, PublicKey) {
(SecretKey::from_bytes(TEST_SK), TEST_PEER_PK.parse().unwrap())
}
#[test]
fn test_nip04_encrypt_decrypt() {
let (sk_a, pk_a) = generate_keypair();
let (sk_b, pk_b) = generate_keypair();
let plaintext = "Hello, Nostr! This is a secret DM.";
let encrypted = nip04_encrypt(&sk_a, &pk_b, plaintext).unwrap();
let decrypted = nip04_decrypt(&sk_b, &pk_a, &encrypted).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_nip04_wrong_key() {
let (sk_a, pk_a) = generate_keypair();
let (_sk_b, pk_b) = generate_keypair();
let (sk_c, _) = generate_keypair();
let encrypted = nip04_encrypt(&sk_a, &pk_b, "secret").unwrap();
// Third party using the correct sender pubkey must not be able to read it.
assert!(nip04_decrypt(&sk_c, &pk_a, &encrypted).is_err());
}
#[test]
fn test_nip04_standard_format() {
let (sk_a, _) = generate_keypair();
let (_, pk_b) = generate_keypair();
let encrypted = nip04_encrypt(&sk_a, &pk_b, "format check").unwrap();
let (ct_b64, iv_b64) = encrypted
.split_once(IV_SEPARATOR)
.expect("output must contain the ?iv= separator");
assert_eq!(crate::util::base64_decode(iv_b64).unwrap().len(), IV_SIZE);
let ct = crate::util::base64_decode(ct_b64).unwrap();
assert_eq!(ct.len(), 16, "12-byte plaintext pads to exactly one block");
}
#[test]
fn test_nip04_rejects_legacy_packed_layout() {
let (sk, pk) = test_keys();
// Legacy non-standard layout base64(IV || ct) — must be rejected.
let legacy = crate::util::base64_encode(&[0u8; 48]);
assert_eq!(nip04_decrypt(&sk, &pk, &legacy), Err(NostrError::Nip04InvalidFormat));
}
#[test]
fn test_nip04_rejects_malformed() {
let (sk, pk) = test_keys();
for bad in [
"?iv=y8Ngg9dcK8XinwERJpzNsA==", // empty ciphertext
"H3hKxnfd4MX/a9Rl0cvmFg==?iv=", // empty iv
"H3hKxnfd4MX/a9Rl0cvmFg==?iv=AAAA", // short iv
"H3hKxnfd4MX/a9Rl0cvm?iv=y8Ngg9dcK8XinwERJpzNsA==", // not a block multiple
"!!!!?iv=y8Ngg9dcK8XinwERJpzNsA==", // bad base64
] {
assert_eq!(
nip04_decrypt(&sk, &pk, bad),
Err(NostrError::Nip04InvalidFormat),
"input {:?}",
bad
);
}
}
// ── Known-answer vectors generated with `nak` (authoritative) ───────────
#[test]
fn test_nip04_nak_known_answer_decrypt() {
let (sk, peer) = test_keys();
assert_eq!(nip04_decrypt(&sk, &peer, NAK_CIPHERTEXT).unwrap(), NAK_PLAINTEXT);
}
#[test]
fn test_nip04_nak_known_answer_encrypt() {
// Pin the full public encrypt path byte-for-byte: with nak's IV, our
// output must be exactly nak's output.
let (sk, peer) = test_keys();
let iv: [u8; 16] = crate::util::base64_decode(NAK_IV_B64).unwrap().try_into().unwrap();
let out = nip04_encrypt_with_iv(&sk, &peer, NAK_PLAINTEXT, &iv).unwrap();
assert_eq!(out, NAK_CIPHERTEXT);
assert!(out.starts_with(NAK_CT_B64));
}
#[test]
fn test_nip04_raw_x_key_symmetric_and_unhashed() {
let (sk_a, pk_a) = generate_keypair();
let (sk_b, pk_b) = generate_keypair();
let key_ab = ecdh_shared_secret_raw_x(&sk_a, &pk_b).unwrap();
let key_ba = ecdh_shared_secret_raw_x(&sk_b, &pk_a).unwrap();
assert_eq!(key_ab, key_ba);
// Regression guard: the raw-X key must NOT equal libsecp256k1's
// hashed ECDH output (the pre-0.1.1 bug used the hashed value).
assert_ne!(key_ab, ecdh_shared_secret(&sk_a, &pk_b).unwrap());
}
}
+88 -10
View File
@@ -38,8 +38,16 @@ const NONCE_SIZE: usize = 32;
/// 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;
/// Minimum raw payload size per spec: version(1) + nonce(32) +
/// padded(2 + 32) + mac(32) = 99.
const MIN_PAYLOAD_SIZE: usize = 1 + NONCE_SIZE + 2 + 32 + MAC_SIZE;
/// Maximum raw payload size per spec: version(1) + nonce(32) +
/// padded(2 + 65536) + mac(32) = 65603.
const MAX_PAYLOAD_SIZE: usize = 1 + NONCE_SIZE + 2 + 65536 + MAC_SIZE;
/// Minimum plaintext size for NIP-44 (the spec forbids empty messages).
pub const MIN_PLAINTEXT_SIZE: usize = 1;
/// Maximum plaintext size for NIP-44 (64 KiB - 1).
pub const MAX_PLAINTEXT_SIZE: usize = 65535;
@@ -64,7 +72,13 @@ pub fn calc_padded_len(unpadded_len: usize) -> usize {
}
/// Pad plaintext per NIP-44: `u16_be(len) || plaintext || zero padding`.
///
/// The spec requires `1 <= len <= 65535`; empty plaintexts are rejected
/// (reference implementations refuse to encrypt or decrypt them).
fn pad_plaintext(plaintext: &[u8]) -> NostrResult<Vec<u8>> {
if plaintext.len() < MIN_PLAINTEXT_SIZE {
return Err(NostrError::Nip44InvalidFormat);
}
if plaintext.len() > MAX_PLAINTEXT_SIZE {
return Err(NostrError::Nip44BufferTooSmall);
}
@@ -82,6 +96,9 @@ fn unpad_plaintext(padded: &[u8]) -> NostrResult<Vec<u8>> {
return Err(NostrError::Nip44InvalidFormat);
}
let unpadded_len = ((padded[0] as usize) << 8) | (padded[1] as usize);
if unpadded_len < MIN_PLAINTEXT_SIZE {
return Err(NostrError::Nip44InvalidFormat);
}
let expected = calc_padded_len(unpadded_len);
if padded.len() != expected + 2 {
return Err(NostrError::Nip44InvalidFormat);
@@ -163,9 +180,6 @@ pub fn nip44_encrypt(
recipient_pk: &PublicKey,
plaintext: &[u8],
) -> NostrResult<Vec<u8>> {
if plaintext.len() > MAX_PLAINTEXT_SIZE {
return Err(NostrError::Nip44BufferTooSmall);
}
let mut nonce = [0u8; NONCE_SIZE];
OsRng.fill_bytes(&mut nonce);
nip44_encrypt_with_nonce(sender_sk, recipient_pk, plaintext, &nonce)
@@ -179,7 +193,7 @@ pub fn nip44_decrypt(
sender_pk: &PublicKey,
payload: &[u8],
) -> NostrResult<Vec<u8>> {
if payload.len() < MIN_PAYLOAD_SIZE {
if payload.len() < MIN_PAYLOAD_SIZE || payload.len() > MAX_PAYLOAD_SIZE {
return Err(NostrError::Nip44InvalidFormat);
}
if payload[0] != VERSION {
@@ -381,14 +395,78 @@ mod tests {
assert_eq!(decrypted, plaintext);
}
/// Regression: the spec forbids empty plaintext. We used to encrypt it,
/// producing payloads that `nak` rejects with "invalid padding" — and our
/// own round-trip test asserted the non-compliant behaviour as correct.
#[test]
fn test_roundtrip_empty() {
fn test_empty_plaintext_rejected() {
let (sk_a, _) = generate_keypair();
let (_, pk_b) = generate_keypair();
assert_eq!(nip44_encrypt(&sk_a, &pk_b, b""), Err(NostrError::Nip44InvalidFormat));
}
/// A correctly MAC'd payload whose padded length prefix is 0 must be
/// rejected on decrypt (spec: "invalid padding").
#[test]
fn test_decrypt_rejects_zero_length_prefix() {
let sk1 = SecretKey::from_bytes([0x11; 32]);
let sk2 = SecretKey::from_bytes([0x22; 32]);
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 = [0x42u8; NONCE_SIZE];
// Hand-build a payload bypassing pad_plaintext: u16(0) || 32 zeros.
let ck = get_conversation_key(&sk1, &pk2).unwrap();
let (ck_key, ck_nonce, hmac_key) = get_message_keys(&ck, &nonce);
let mut ct = vec![0u8; 2 + 32];
ChaCha20::new(&ck_key.into(), &ck_nonce.into()).apply_keystream(&mut ct);
let mut aad = nonce.to_vec();
aad.extend_from_slice(&ct);
let mac = hmac_sha256(&hmac_key, &aad);
let mut payload = vec![VERSION];
payload.extend_from_slice(&nonce);
payload.extend_from_slice(&ct);
payload.extend_from_slice(&mac);
assert_eq!(nip44_decrypt(&sk2, &pk1, &payload), Err(NostrError::Nip44InvalidFormat));
}
#[test]
fn test_decrypt_rejects_bad_lengths() {
let (sk, pk) = generate_keypair();
assert!(nip44_decrypt(&sk, &pk, &[VERSION; MIN_PAYLOAD_SIZE - 1]).is_err());
assert!(nip44_decrypt(&sk, &pk, &vec![VERSION; MAX_PAYLOAD_SIZE + 1]).is_err());
}
#[test]
fn test_decrypt_rejects_tampered_mac_and_ciphertext() {
let (sk_a, pk_a) = generate_keypair();
let (sk_b, pk_b) = generate_keypair();
let enc = nip44_encrypt(&sk_a, &pk_b, b"authenticated").unwrap();
for idx in [1, 1 + NONCE_SIZE, enc.len() - 1] {
let mut t = enc.clone();
t[idx] ^= 0x01;
assert_eq!(
nip44_decrypt(&sk_b, &pk_a, &t),
Err(NostrError::Nip44DecryptFailed),
"flip at {idx}"
);
}
}
let encrypted = nip44_encrypt(&sk_a, &pk_b, b"").unwrap();
let decrypted = nip44_decrypt(&sk_b, &pk_a, &encrypted).unwrap();
assert_eq!(decrypted, b"");
// ── Interop vector generated with `nak` (independent implementation) ──
// nak encrypt --sec 2222...22 -p pub(1111...11) "hello from nak nip44"
#[test]
fn test_nak_known_answer_decrypt() {
let sk_a = SecretKey::from_bytes([0x11; 32]);
let pk_b: PublicKey = "466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f27"
.parse()
.unwrap();
let raw = crate::util::base64_decode(
"AgKFHl+hGEe6OVzGumsGpkArIDajp6E/ovG8V62YHM1V5HMJUB4D+wYBvZq0ghOs0iDtZWCrWsny86xAKCMnc/pXI5lqk2P2x/Tu/FDSF/M9FyzuLSVYBSkjQ1FhvqeHisRl",
)
.unwrap();
assert_eq!(nip44_decrypt(&sk_a, &pk_b, &raw).unwrap(), b"hello from nak nip44");
}
#[test]
+14 -248
View File
@@ -1,260 +1,26 @@
//! NIP-04: Legacy Encrypted Direct Messages (AES-256-CBC).
//!
//! Implements the standard NIP-04 scheme as deployed by normal Nostr
//! clients:
//!
//! * The AES-256 key is the **raw X coordinate** of the ECDH shared point
//! (`secret_key * recipient_public_key`), unhashed.
//! * The wire format is `base64(ciphertext)?iv=base64(iv)` — the ciphertext
//! first, then a `?iv=` separator, then the base64-encoded IV.
//!
//! Any deviation from this (e.g. hashing the shared secret, or packing the
//! IV in front of the ciphertext) produces messages that only the deviating
//! implementation can read. See the known-answer tests below, generated with
//! `nak`, for pinned interop vectors.
//! Re-exports the single workspace implementation in
//! [`nostr_core::crypto::nip04`], which carries the `nak` known-answer tests.
//! Keeping one implementation guarantees `nostr_nips` and
//! `nostr_signer::LocalSigner` can never drift apart again.
use cbc::{Decryptor, Encryptor};
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit};
use rand::rngs::OsRng;
use rand::RngCore;
use nostr_core::crypto::keys::ecdh_shared_secret_raw_x;
use nostr_core::error::NostrError;
use nostr_core::types::{PublicKey, SecretKey};
use nostr_core::NostrResult;
type Aes256CbcEnc = Encryptor<aes::Aes256>;
type Aes256CbcDec = Decryptor<aes::Aes256>;
const IV_SIZE: usize = 16;
/// Separator between the base64 ciphertext and the base64 IV, per NIP-04.
const IV_SEPARATOR: &str = "?iv=";
/// Encrypt a plaintext using the standard NIP-04 scheme.
///
/// Returns `base64(ciphertext)?iv=base64(iv)`.
pub fn nip04_encrypt(
sender_sk: &SecretKey,
recipient_pk: &PublicKey,
plaintext: &str,
) -> NostrResult<String> {
// Raw X coordinate of the shared point, unhashed — the NIP-04 AES key.
// The X coordinate is parity-independent, so no parity handling needed.
let key = ecdh_shared_secret_raw_x(sender_sk, recipient_pk)?;
let mut iv = [0u8; IV_SIZE];
OsRng.fill_bytes(&mut iv);
let mut buf = vec![0u8; plaintext.len() + 16 + 16];
buf[..plaintext.len()].copy_from_slice(plaintext.as_bytes());
let cipher = Aes256CbcEnc::new_from_slices(&key, &iv)
.map_err(|_| NostrError::Nip04InvalidFormat)?;
let encrypted = cipher
.encrypt_padded_mut::<Pkcs7>(&mut buf, plaintext.len())
.map_err(|_| NostrError::Nip04InvalidFormat)?;
Ok(format!(
"{}{}{}",
nostr_core::util::base64_encode(encrypted),
IV_SEPARATOR,
nostr_core::util::base64_encode(&iv)
))
}
/// Decrypt a standard NIP-04 encrypted message.
///
/// Expects `base64(ciphertext)?iv=base64(iv)`. Returns
/// [`NostrError::Nip04InvalidFormat`] if the `?iv=` separator is missing or
/// either part fails to base64-decode.
pub fn nip04_decrypt(
recipient_sk: &SecretKey,
sender_pk: &PublicKey,
ciphertext_b64: &str,
) -> NostrResult<String> {
let (ct_b64, iv_b64) = ciphertext_b64
.split_once(IV_SEPARATOR)
.ok_or(NostrError::Nip04InvalidFormat)?;
let encrypted = nostr_core::util::base64_decode(ct_b64)?;
let iv = nostr_core::util::base64_decode(iv_b64)?;
if iv.len() != IV_SIZE || encrypted.len() < 16 {
return Err(NostrError::Nip04InvalidFormat);
}
// Raw X coordinate of the shared point, unhashed — the NIP-04 AES key.
// Parity-independent, so a single key derivation suffices.
let key = ecdh_shared_secret_raw_x(recipient_sk, sender_pk)?;
let cipher = Aes256CbcDec::new_from_slices(&key, &iv)
.map_err(|_| NostrError::Nip04InvalidFormat)?;
let mut buf = encrypted;
let decrypted = cipher
.decrypt_padded_mut::<Pkcs7>(&mut buf)
.map_err(|_| NostrError::Nip04DecryptFailed)?;
String::from_utf8(decrypted.to_vec()).map_err(|_| NostrError::Nip04DecryptFailed)
}
pub use nostr_core::crypto::nip04::{nip04_decrypt, nip04_encrypt, nip04_encrypt_with_iv};
#[cfg(test)]
mod tests {
use super::*;
use nostr_core::crypto::keys::generate_keypair;
use nostr_core::types::{PublicKey, SecretKey};
// Smoke test that the re-export is the interoperable implementation.
// `nak encrypt --nip04 --sec 1111...11 -p 4f355bdc...71aa "nak vector"`
#[test]
fn test_nip04_encrypt_decrypt() {
let (sk_a, pk_a) = generate_keypair();
let (sk_b, pk_b) = generate_keypair();
let plaintext = "Hello, Nostr! This is a secret DM.";
let encrypted = nip04_encrypt(&sk_a, &pk_b, plaintext).unwrap();
let decrypted = nip04_decrypt(&sk_b, &pk_a, &encrypted).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_nip04_wrong_key() {
let (sk_a, _pk_a) = generate_keypair();
let (_sk_b, pk_b) = generate_keypair();
let (sk_c, _) = generate_keypair();
let plaintext = "secret";
let encrypted = nip04_encrypt(&sk_a, &pk_b, plaintext).unwrap();
assert!(nip04_decrypt(&sk_c, &pk_b, &encrypted).is_err());
}
#[test]
fn test_nip04_standard_format() {
let (sk_a, pk_a) = generate_keypair();
let (sk_b, pk_b) = generate_keypair();
let encrypted = nip04_encrypt(&sk_a, &pk_b, "format check").unwrap();
// Standard NIP-04 wire format: base64(ct)?iv=base64(iv)
let (ct_b64, iv_b64) = encrypted
.split_once(IV_SEPARATOR)
.expect("output must contain the ?iv= separator");
let iv = nostr_core::util::base64_decode(iv_b64).unwrap();
assert_eq!(iv.len(), IV_SIZE);
// Ciphertext must be PKCS7-padded AES blocks (multiple of 16).
let ct = nostr_core::util::base64_decode(ct_b64).unwrap();
assert_eq!(ct.len() % 16, 0);
assert!(!ct.is_empty());
// The reverse direction must decrypt it (ECDH is symmetric).
let decrypted = nip04_decrypt(&sk_b, &pk_a, &encrypted).unwrap();
assert_eq!(decrypted, "format check");
let _ = pk_a;
}
#[test]
fn test_nip04_rejects_missing_iv_separator() {
let (_sk_a, pk_a) = generate_keypair();
let (sk_b, _pk_b) = generate_keypair();
// Legacy non-standard layout base64(IV || ct) — must be rejected.
let legacy = nostr_core::util::base64_encode(&[0u8; 48]);
assert_eq!(
nip04_decrypt(&sk_b, &pk_a, &legacy),
Err(NostrError::Nip04InvalidFormat)
);
}
// ── Known-answer vectors generated with `nak` (authoritative) ───────────
//
// Generated with:
// nak key public 1111...11
// -> 4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa
// nak encrypt --nip04 --sec 1111...11 \
// -p 4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa \
// "nak vector"
// -> H3hKxnfd4MX/a9Rl0cvmFg==?iv=y8Ngg9dcK8XinwERJpzNsA==
//
// The decrypt vector pins the raw-X (unhashed) key derivation and the
// `base64(ct)?iv=base64(iv)` wire format against an independent
// implementation. The encrypt test relies on the same shared key path,
// so a regression to a hashed key or a packed IV layout fails here.
#[test]
fn test_nip04_nak_known_answer_decrypt() {
let sk = SecretKey::from_bytes(TEST_SK);
let peer = PublicKey::from_bytes(TEST_PEER_PK);
let decrypted = nip04_decrypt(&sk, &peer, NAK_CIPHERTEXT).unwrap();
assert_eq!(decrypted, NAK_PLAINTEXT);
}
#[test]
fn test_nip04_nak_known_answer_encrypt() {
use aes::cipher::KeyIvInit;
let sk = SecretKey::from_bytes(TEST_SK);
let peer = PublicKey::from_bytes(TEST_PEER_PK);
// The IV is random per encryption, so a byte-for-byte comparison of
// nip04_encrypt output against nak's output is impossible. Instead,
// pin the key derivation: derive the raw-X key, encrypt the plaintext
// with nak's IV, and the result must equal nak's ciphertext exactly.
let key = ecdh_shared_secret_raw_x(&sk, &peer).unwrap();
let iv = nostr_core::util::base64_decode(NAK_IV_B64).unwrap();
let mut buf = vec![0u8; NAK_PLAINTEXT.len() + 16 + 16];
buf[..NAK_PLAINTEXT.len()].copy_from_slice(NAK_PLAINTEXT.as_bytes());
let ct = Aes256CbcEnc::new_from_slices(&key, &iv)
.unwrap()
.encrypt_padded_mut::<Pkcs7>(&mut buf, NAK_PLAINTEXT.len())
fn test_reexport_decrypts_nak_vector() {
let sk = SecretKey::from_bytes([0x11; 32]);
let pk: PublicKey = "4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
.parse()
.unwrap();
assert_eq!(
nostr_core::util::base64_encode(ct),
NAK_CT_B64,
"raw-X key + nak IV must reproduce nak's ciphertext byte-for-byte"
);
// And the full nip04_encrypt round-trip must hold.
let encrypted = nip04_encrypt(&sk, &peer, NAK_PLAINTEXT).unwrap();
assert!(encrypted.contains(IV_SEPARATOR));
let roundtrip = nip04_decrypt(&sk, &peer, &encrypted).unwrap();
assert_eq!(roundtrip, NAK_PLAINTEXT);
}
// Fixed test keys (NOT used for anything real).
const TEST_SK: [u8; 32] = [
0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11,
0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11,
0x11, 0x11,
];
// `nak key public 1111...11`
const TEST_PEER_PK: [u8; 32] = [
0x4f, 0x35, 0x5b, 0xdc, 0xb7, 0xcc, 0x0a, 0xf7, 0x28, 0xef, 0x3c, 0xce, 0xb9, 0x61, 0x5d,
0x90, 0x68, 0x4b, 0xb5, 0xb2, 0xca, 0x5f, 0x85, 0x9a, 0xb0, 0xf0, 0xb7, 0x04, 0x07, 0x58,
0x71, 0xaa,
];
const NAK_PLAINTEXT: &str = "nak vector";
// Full nak output: base64(ct)?iv=base64(iv)
const NAK_CIPHERTEXT: &str = "H3hKxnfd4MX/a9Rl0cvmFg==?iv=y8Ngg9dcK8XinwERJpzNsA==";
// Just the ciphertext part, for the same-IV comparison in the encrypt test.
const NAK_CT_B64: &str = "H3hKxnfd4MX/a9Rl0cvmFg==";
// Just the IV part, for the same-IV comparison in the encrypt test.
const NAK_IV_B64: &str = "y8Ngg9dcK8XinwERJpzNsA==";
#[test]
fn test_nip04_raw_x_key_symmetric_and_unhashed() {
use nostr_core::crypto::keys::{ecdh_shared_secret, ecdh_shared_secret_raw_x};
let (sk_a, pk_a) = generate_keypair();
let (sk_b, pk_b) = generate_keypair();
// ECDH is symmetric: both parties must derive the same raw-X key.
let key_ab = ecdh_shared_secret_raw_x(&sk_a, &pk_b).unwrap();
let key_ba = ecdh_shared_secret_raw_x(&sk_b, &pk_a).unwrap();
assert_eq!(key_ab, key_ba);
// Regression guard: the raw-X key must NOT equal the hashed ECDH
// secret used by NIP-44 (the old bug double-hashed this value).
let hashed = ecdh_shared_secret(&sk_a, &pk_b).unwrap();
assert_ne!(key_ab, hashed);
let out = nip04_decrypt(&sk, &pk, "H3hKxnfd4MX/a9Rl0cvmFg==?iv=y8Ngg9dcK8XinwERJpzNsA==");
assert_eq!(out.unwrap(), "nak vector");
}
}
+29 -3
View File
@@ -617,7 +617,7 @@ mod tests {
// master private key = e8f32e723decf4051aefac8e2c93c9c5b214313817cdb01a1494b917c8436b35
// master chain code = 873dff81c02f525623fd1fe5167eac3a55a049de3d314bb42ee227ffed37d508
// m/0' private key = edb2e14f9ee77d26dd93b4ecede8d16ed408ce149b6cd80b0715a2d911a0afea
// m/0' chain code = 47fdacbd0f1097043b78c63e20c34ef4ed9a111d980047ad16282c7ae6236141
// m/0' chain code = 47fdacbd0f1097043b78c63c20c34ef4ed9a111d980047ad16282c7ae6236141
#[test]
fn test_bip32_derive_child_hardened_vector() {
let mut parent_key = [0u8; 32];
@@ -647,9 +647,9 @@ mod tests {
//
// BIP-32 test vector 1, deriving m/0'/1 from m/0':
// m/0' private key = edb2e14f9ee77d26dd93b4ecede8d16ed408ce149b6cd80b0715a2d911a0afea
// m/0' chain code = 47fdacbd0f1097043b78c63e20c34ef4ed9a111d980047ad16282c7ae6236141
// m/0' chain code = 47fdacbd0f1097043b78c63c20c34ef4ed9a111d980047ad16282c7ae6236141
// m/0'/1 private key= 3c6cb8d0f6a264c91ea8b5030fadaa8e538b020f0a387421a12de9319dc93368
// m/0'/1 chain code = 2a7857631386ba23dacac3412ddca3f0da4b55a1d6e0231fcb4d8290c9421327
// m/0'/1 chain code = 2a7857631386ba23dacac34180dd1983734e444fdbf774041578e9b6adb37c19
#[test]
fn test_bip32_derive_child_nonhardened_vector() {
let mut parent_key = [0u8; 32];
@@ -764,6 +764,32 @@ mod tests {
);
}
/// Official NIP-06 specification test vectors
/// (https://github.com/nostr-protocol/nips/blob/master/06.md). Unlike the
/// self-generated pinned vector above, these come from the spec itself.
#[test]
fn test_nip06_official_spec_vectors() {
let vectors = [
(
"leader monkey parrot ring guide accident before fence cannon height naive bean",
"7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a",
"17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917",
),
(
"what bleak badge arrange retreat wolf trade produce cricket blur garlic valid proud rude strong choose busy staff weather area salt hollow arm fade",
"c15d739894c81a2fcfd3a2df85a0d2c0dbc47a280d092799f144d73d7ae78add",
"d41b22899549e1f3d335a31002cfd382174006e166d3e658e3a5eecdb6463573",
),
];
for (mnemonic, sk_hex, pk_hex) in vectors {
assert!(mnemonic_validate(mnemonic), "spec mnemonic must validate");
let seed = mnemonic_to_seed(mnemonic, "");
let (sk, pk) = keypair_from_seed(&seed).unwrap();
assert_eq!(sk.to_hex(), sk_hex, "secret key for {mnemonic:?}");
assert_eq!(pk.to_hex(), pk_hex, "public key for {mnemonic:?}");
}
}
// SLIP-0010 ed25519 pinned vector for the same mnemonic.
// ed25519 secret key = dc03109ee8e06e18cee872b30efece39283e898c3355739a89fce2de6aa80cb1
// ed25519 public key = 73b263ebc50f4ad169f18d1d618489cb8e6dd29fbfed8d5f3dc0e4dc32931eb6
+48
View File
@@ -76,6 +76,11 @@ pub fn verify_auth_event(
return Err(NostrError::Nip42TimeTolerance);
}
// The id MUST be recomputed from the event body. Verifying the signature
// over the claimed `event.id` alone lets an attacker alter tags/content
// (e.g. swap the challenge) while keeping a valid-looking signature.
crate::nip001::verify_event_id(event).map_err(|_| NostrError::Nip42AuthEventInvalid)?;
let event_id = event.id.as_ref().ok_or(NostrError::Nip42AuthEventInvalid)?;
let sig = event.sig.as_ref().ok_or(NostrError::Nip42AuthEventInvalid)?;
schnorr_verify(&event.pubkey, event_id.as_bytes(), sig)
@@ -128,6 +133,49 @@ mod tests {
assert!(result.is_err());
}
fn now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
/// Regression: tags/content altered after signing must be rejected even
/// though `event.sig` is still a valid signature over the stale `event.id`.
#[test]
fn test_verify_auth_event_rejects_tampered_body() {
let (sk, _) = generate_keypair();
let mut event = create_auth_event("c", "wss://relay.example.com", &sk, now()).unwrap();
event.tags.push(Tag::with_value("injected", "x"));
assert_eq!(
verify_auth_event(&event, "c", "wss://relay.example.com", 600),
Err(NostrError::Nip42AuthEventInvalid)
);
}
#[test]
fn test_verify_auth_event_rejects_bad_signature() {
let (sk, _) = generate_keypair();
let mut event = create_auth_event("c", "wss://relay.example.com", &sk, now()).unwrap();
event.sig = Some(nostr_core::types::Signature::from_bytes([0xab; 64]));
assert_ne!(verify_auth_event(&event, "c", "wss://relay.example.com", 600), Ok(true));
}
#[test]
fn test_verify_auth_event_rejects_wrong_relay_and_stale() {
let (sk, _) = generate_keypair();
let event = create_auth_event("c", "wss://relay.example.com", &sk, now()).unwrap();
assert_eq!(
verify_auth_event(&event, "c", "wss://other.example.com", 600),
Err(NostrError::Nip42UrlMismatch)
);
let old = create_auth_event("c", "wss://relay.example.com", &sk, now() - 7200).unwrap();
assert_eq!(
verify_auth_event(&old, "c", "wss://relay.example.com", 600),
Err(NostrError::Nip42TimeTolerance)
);
}
#[test]
fn test_generate_challenge() {
let c1 = generate_challenge();
+2 -2
View File
@@ -141,7 +141,7 @@ The Rust port implements **all of the above**:
|----------|---------------|--------|
| NIP-01 | [`nips/src/nip001.rs`](nips/src/nip001.rs) | ✅ **Full** — event creation, signing, validation |
| NIP-03 | [`nips/src/nip003.rs`](nips/src/nip003.rs) | ✅ **Full** — OTS parsing, proof execution, verification |
| NIP-04 | [`nips/src/nip004.rs`](nips/src/nip004.rs) | ✅ **Full** — AES-256-CBC encrypt/decrypt |
| NIP-04 | [`core/src/crypto/nip04.rs`](core/src/crypto/nip04.rs) (re-exported by `nips/src/nip004.rs`) | ✅ **Full** — AES-256-CBC, raw-X ECDH, `ct?iv=iv` |
| NIP-05 | [`nips/src/nip005.rs`](nips/src/nip005.rs) | ✅ **Full** — DNS identifier verification |
| NIP-06 | [`nips/src/nip006.rs`](nips/src/nip006.rs) | ✅ **Full** — BIP39, BIP-32, SLIP-0010 |
| NIP-11 | [`nips/src/nip011.rs`](nips/src/nip011.rs) | ✅ **Full** — Relay info document |
@@ -151,7 +151,7 @@ The Rust port implements **all of the above**:
| NIP-21 | [`nips/src/nip021.rs`](nips/src/nip021.rs) | ✅ **Full** — nostr: URI parsing |
| NIP-34 | [`nips/src/nip034.rs`](nips/src/nip034.rs) | ✅ **Full** — Git events (repo, patch, PR, issue) |
| NIP-42 | [`nips/src/nip042.rs`](nips/src/nip042.rs) | ✅ **Full** — Auth events, challenge generation |
| NIP-44 | [`core/src/crypto/nip44.rs`](core/src/crypto/nip44.rs) | ✅ **Full** — ChaCha20-Poly1305 AEAD |
| NIP-44 | [`core/src/crypto/nip44.rs`](core/src/crypto/nip44.rs) | ✅ **Full** — v2: ChaCha20 + HMAC-SHA256, padding |
| NIP-46 | [`nips/src/nip046.rs`](nips/src/nip046.rs) | ✅ **Full** — bunker:// and nostrconnect:// URL parsing, request/response events |
| NIP-59 | [`nips/src/nip059.rs`](nips/src/nip059.rs) | ✅ **Full** — Gift wrap, seal, rumor creation/unwrapping |
| NIP-60 | [`nips/src/nip060.rs`](nips/src/nip060.rs) | ✅ **Full** — Wallet/token events |
-3
View File
@@ -11,9 +11,6 @@ serde_json.workspace = true
tracing.workspace = true
thiserror.workspace = true
tokio = { workspace = true, features = ["io-util", "net", "sync"] }
cbc = "0.1"
aes.workspace = true
sha2.workspace = true
hex.workspace = true
rand.workspace = true
serialport.workspace = true
+43 -79
View File
@@ -3,8 +3,7 @@
use std::sync::Mutex;
use nostr_core::crypto::keys::{public_key_from_secret_key, schnorr_sign};
use nostr_core::crypto::nip44;
use nostr_core::error::NostrError;
use nostr_core::crypto::{nip04, nip44};
use nostr_core::types::{Event, PublicKey, SecretKey};
use nostr_core::NostrResult;
@@ -55,11 +54,11 @@ impl NostrSigner for LocalSigner {
}
fn nip04_encrypt(&self, peer_pubkey: &PublicKey, plaintext: &str) -> NostrResult<String> {
nip04_encrypt_impl(&self.secret_key, peer_pubkey, plaintext)
nip04::nip04_encrypt(&self.secret_key, peer_pubkey, plaintext)
}
fn nip04_decrypt(&self, peer_pubkey: &PublicKey, ciphertext: &str) -> NostrResult<String> {
nip04_decrypt_impl(&self.secret_key, peer_pubkey, ciphertext)
nip04::nip04_decrypt(&self.secret_key, peer_pubkey, ciphertext)
}
fn nip44_encrypt(&self, peer_pubkey: &PublicKey, plaintext: &str) -> NostrResult<Vec<u8>> {
@@ -88,87 +87,52 @@ impl NostrSigner for LocalSigner {
}
}
// ── NIP-04 implementation (local) ───────────────────────────────────────────
fn nip04_encrypt_impl(
sender_sk: &SecretKey,
recipient_pk: &PublicKey,
plaintext: &str,
) -> NostrResult<String> {
use cbc::Encryptor;
use aes::cipher::{block_padding::Pkcs7, BlockEncryptMut, KeyIvInit};
use rand::rngs::OsRng;
use rand::RngCore;
use nostr_core::crypto::keys::ecdh_shared_secret_both;
let (shared_even, _) = ecdh_shared_secret_both(sender_sk, recipient_pk)?;
let key = derive_nip04_key(&shared_even);
let mut iv = [0u8; 16];
OsRng.fill_bytes(&mut iv);
let mut buf = vec![0u8; plaintext.len() + 16 + 16];
buf[..plaintext.len()].copy_from_slice(plaintext.as_bytes());
let cipher = Encryptor::<aes::Aes256>::new_from_slices(&key, &iv)
.map_err(|_| NostrError::Nip04InvalidFormat)?;
let encrypted = cipher
.encrypt_padded_mut::<Pkcs7>(&mut buf, plaintext.len())
.map_err(|_| NostrError::Nip04InvalidFormat)?;
let mut result = Vec::with_capacity(16 + encrypted.len());
result.extend_from_slice(&iv);
result.extend_from_slice(encrypted);
Ok(nostr_core::util::base64_encode(&result))
}
fn nip04_decrypt_impl(
recipient_sk: &SecretKey,
sender_pk: &PublicKey,
ciphertext_b64: &str,
) -> NostrResult<String> {
use cbc::Decryptor;
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit};
use nostr_core::crypto::keys::ecdh_shared_secret_both;
let (shared_even, shared_odd) = ecdh_shared_secret_both(recipient_sk, sender_pk)?;
let decoded = nostr_core::util::base64_decode(ciphertext_b64)?;
if decoded.len() < 16 + 16 {
return Err(NostrError::Nip04InvalidFormat);
}
let iv = &decoded[..16];
let encrypted = &decoded[16..];
for shared in [shared_even, shared_odd] {
let key = derive_nip04_key(&shared);
if let Ok(cipher) = Decryptor::<aes::Aes256>::new_from_slices(&key, iv) {
let mut buf = encrypted.to_vec();
if let Ok(decrypted) = cipher.decrypt_padded_mut::<Pkcs7>(&mut buf) {
if let Ok(text) = String::from_utf8(decrypted.to_vec()) {
return Ok(text);
}
}
}
}
Err(NostrError::Nip04DecryptFailed)
}
fn derive_nip04_key(shared_secret: &[u8; 32]) -> [u8; 32] {
use sha2::Digest;
let hash = sha2::Sha256::digest(shared_secret);
let mut key = [0u8; 32];
key.copy_from_slice(&hash);
key
}
#[cfg(test)]
mod tests {
use super::*;
use nostr_core::crypto::keys::generate_keypair;
use nostr_core::types::Kind;
// Interop vectors produced by `nak` (independent implementation).
// SK_A = 1111...11, SK_B = 2222...22
// nak encrypt [--nip04] --sec SK_B -p pub(SK_A) "<plaintext>"
const SK_A: [u8; 32] = [0x11; 32];
const PK_B: &str = "466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f27";
const NAK_NIP04: &str = "ZiEsGMnKs9sjO7r/HNQdB+T07E8pQdadbMBgA4j1MPo=?iv=Siodb8Kb1Ahk5YLpS1w2LA==";
const NAK_NIP44: &str = "AgKFHl+hGEe6OVzGumsGpkArIDajp6E/ovG8V62YHM1V5HMJUB4D+wYBvZq0ghOs0iDtZWCrWsny86xAKCMnc/pXI5lqk2P2x/Tu/FDSF/M9FyzuLSVYBSkjQ1FhvqeHisRl";
fn signer_a() -> (LocalSigner, PublicKey) {
(LocalSigner::new(SecretKey::from_bytes(SK_A)).unwrap(), PK_B.parse().unwrap())
}
/// Regression: LocalSigner used to carry its own NIP-04 copy with a
/// SHA-256-hashed key and `base64(iv||ct)` layout, so it could not read
/// any real client's DMs even after the 0.1.1 fix to `nostr_nips`.
#[test]
fn test_local_signer_nip04_decrypts_nak() {
let (s, peer) = signer_a();
assert_eq!(s.nip04_decrypt(&peer, NAK_NIP04).unwrap(), "hello from nak nip04");
}
#[test]
fn test_local_signer_nip04_output_is_standard_format() {
let (s, peer) = signer_a();
let out = s.nip04_encrypt(&peer, "hi").unwrap();
assert!(out.contains("?iv="), "must use ct?iv=iv wire format, got {out}");
// And it must be readable by the canonical implementation.
assert_eq!(
nip04::nip04_decrypt(&SecretKey::from_bytes(SK_A), &peer, &out).unwrap(),
"hi"
);
}
#[test]
fn test_local_signer_nip44_decrypts_nak() {
let (s, peer) = signer_a();
let raw = nostr_core::util::base64_decode(NAK_NIP44).unwrap();
assert_eq!(s.nip44_decrypt(&peer, &raw).unwrap(), b"hello from nak nip44");
}
#[test]
fn test_local_signer_get_public_key() {
let (sk, pk) = generate_keypair();
+52 -15
View File
@@ -1,4 +1,11 @@
//! Integration tests ported from the C test suite.
//!
//! Rule for crypto tests in this file: a round-trip (`decrypt(encrypt(x)) == x`)
//! proves nothing about interoperability — a wrong-but-symmetric
//! implementation passes it. Every encryption scheme must also have at least
//! one known-answer vector produced by an independent implementation (`nak`,
//! or official spec vectors).
#![cfg(test)]
use nostr_core::crypto::hmac::hmac_sha256;
use nostr_core::crypto::keys::{
@@ -126,16 +133,34 @@ fn test_nip01_invalid_pubkey_length() {
}
// ── NIP-04 Test Vectors ─────────────────────────────────────────────────────
//
// Previously named "known_vectors" but only round-tripped, so it passed with
// the old hashed-key / packed-IV implementation. The ciphertext below was
// produced by `nak encrypt --nip04 --sec <sk1> -p <pk2> nanana`.
const NIP04_SK1: &str = "91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe";
const NIP04_SK2: &str = "96f6fa197aa07477ab88f6981118466ae3a982faab8ad5db9d5426870c73d220";
const NIP04_PK1: &str = "b38ce15d3d9874ee710dfabb7ff9801b1e0e20aace6e9a1a05fa7482a04387d1";
const NIP04_PK2: &str = "dcb33a629560280a0ee3b6b99b68c044fe8914ad8a984001ebf6099a9b474dc3";
const NAK_NIP04_NANANA: &str = "t1Ql9H8r9XUeaE5wLg9I6A==?iv=JbKO96c0ABhevClnoVhZGg==";
#[test]
fn test_nip04_known_vectors() {
let sk1: SecretKey = "91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe".parse().unwrap();
let sk2: SecretKey = "96f6fa197aa07477ab88f6981118466ae3a982faab8ad5db9d5426870c73d220".parse().unwrap();
let pk1: PublicKey = "b38ce15d3d9874ee710dfabb7ff9801b1e0e20aace6e9a1a05fa7482a04387d1".parse().unwrap();
let pk2: PublicKey = "dcb33a629560280a0ee3b6b99b68c044fe8914ad8a984001ebf6099a9b474dc3".parse().unwrap();
let encrypted = nostr_nips::nip004::nip04_encrypt(&sk1, &pk2, "nanana").unwrap();
let decrypted = nostr_nips::nip004::nip04_decrypt(&sk2, &pk1, &encrypted).unwrap();
assert_eq!(decrypted, "nanana");
let sk1: SecretKey = NIP04_SK1.parse().unwrap();
let sk2: SecretKey = NIP04_SK2.parse().unwrap();
let pk1: PublicKey = NIP04_PK1.parse().unwrap();
let pk2: PublicKey = NIP04_PK2.parse().unwrap();
assert_eq!(public_key_from_secret_key(&sk1).unwrap(), pk1);
assert_eq!(public_key_from_secret_key(&sk2).unwrap(), pk2);
// Recipient decrypts nak's ciphertext.
assert_eq!(nostr_nips::nip004::nip04_decrypt(&sk2, &pk1, NAK_NIP04_NANANA).unwrap(), "nanana");
// Sender can also read it (ECDH symmetry).
assert_eq!(nostr_nips::nip004::nip04_decrypt(&sk1, &pk2, NAK_NIP04_NANANA).unwrap(), "nanana");
// Byte-exact encrypt with nak's IV.
let iv: [u8; 16] = nostr_core::util::base64_decode("JbKO96c0ABhevClnoVhZGg==").unwrap().try_into().unwrap();
assert_eq!(nostr_nips::nip004::nip04_encrypt_with_iv(&sk1, &pk2, "nanana", &iv).unwrap(), NAK_NIP04_NANANA);
}
// ── NIP-13 PoW Tests ────────────────────────────────────────────────────────
@@ -197,12 +222,24 @@ fn test_nip19_nsec_roundtrip() {
assert_eq!(decoded, Bech32Entity::Nsec(sk));
}
// ── NIP-44 Round-trip Tests ─────────────────────────────────────────────────
// ── NIP-44 Tests ────────────────────────────────────────────────────────────
/// `nak encrypt --sec <NIP04_SK1> -p <NIP04_PK2> 'Hello, NIP-44!'`
#[test]
fn test_nip44_nak_known_answer() {
let sk2: SecretKey = NIP04_SK2.parse().unwrap();
let pk1: PublicKey = NIP04_PK1.parse().unwrap();
let raw = nostr_core::util::base64_decode(
"Anpxt3Dh3Ry6jFCyk4wP19eFOgflbutisqscdKydeCLcXdX7LPcCfpvC42Lk+q4+ZiqEoXc28rsV4NHloNsBTuFnok5jvy/UbMN+3FYdookXQw74i6WzYsAzEKYvu7jinR4e",
)
.unwrap();
assert_eq!(nip44_decrypt(&sk2, &pk1, &raw).unwrap(), b"Hello, NIP-44!");
}
#[test]
fn test_nip44_round_trip_basic() {
let sk1: SecretKey = "91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe".parse().unwrap();
let sk2: SecretKey = "96f6fa197aa07477ab88f6981118466ae3a982faab8ad5db9d5426870c73d220".parse().unwrap();
let sk1: SecretKey = NIP04_SK1.parse().unwrap();
let sk2: SecretKey = NIP04_SK2.parse().unwrap();
let pk1 = public_key_from_secret_key(&sk1).unwrap();
let pk2 = public_key_from_secret_key(&sk2).unwrap();
let enc = nip44_encrypt(&sk1, &pk2, b"Hello, NIP-44!").unwrap();
@@ -221,15 +258,15 @@ fn test_nip44_unicode() {
assert_eq!(String::from_utf8(dec).unwrap(), "Hello 🌍 World! 🚀");
}
/// NIP-44 forbids empty plaintext (nak: "plaintext can't be empty"). This
/// test used to assert that empty messages round-trip, enshrining a spec
/// violation whose output other clients reject.
#[test]
fn test_nip44_empty() {
fn test_nip44_empty_rejected() {
let sk1: SecretKey = "3333333333333333333333333333333333333333333333333333333333333333".parse().unwrap();
let sk2: SecretKey = "4444444444444444444444444444444444444444444444444444444444444444".parse().unwrap();
let pk1 = public_key_from_secret_key(&sk1).unwrap();
let pk2 = public_key_from_secret_key(&sk2).unwrap();
let enc = nip44_encrypt(&sk1, &pk2, b"").unwrap();
let dec = nip44_decrypt(&sk2, &pk1, &enc).unwrap();
assert_eq!(dec, b"");
assert!(nip44_encrypt(&sk1, &pk2, b"").is_err());
}
// ── NIP-59 Gift Wrap Tests ──────────────────────────────────────────────────