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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
@@ -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 ──────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user