Fix NIP-04 to standard wire format: raw-X ECDH key, ct?iv=iv layout

nip04_encrypt/decrypt previously double-hashed the ECDH shared secret
(SHA256 over the libsecp default hash) and packed base64(IV || ciphertext),
making signer-produced DMs readable only by itself.

- core/keys.rs: add ecdh_shared_secret_raw_x() using
  secp256k1::ecdh::shared_secret_point — raw X coordinate, unhashed.
  Existing hashed helpers unchanged (NIP-44 still uses them).
- nips/nip004.rs: use the raw-X key; emit base64(ct)?iv=base64(iv);
  decrypt parses only the ?iv= format (standard-only, no legacy fallback;
  missing separator -> Nip04InvalidFormat). Key derivation is
  parity-independent, so the both-parity dance is removed.
- Tests: known-answer vector generated with nak (fixed key 1111..11,
  peer 4f355bdcb7cc..., plaintext 'nak vector' ->
  H3hKxnfd4MX/a9Rl0cvmFg==?iv=y8Ngg9dcK8XinwERJpzNsA==); format and
  legacy-rejection tests; raw-X symmetry/regression test vs hashed ECDH.
- End-to-end verified both directions against a live signer daemon and
  nak decrypt --nip04 / nak encrypt --nip04.
- Also includes pre-existing local changes: nip060/nip061, relay pool,
  cashu service, Cargo.lock.
This commit is contained in:
Laan Tungir
2026-09-24 09:20:37 -04:00
parent 2a9493b499
commit 8a78146c53
7 changed files with 237 additions and 73 deletions
Generated
+9 -9
View File
@@ -385,7 +385,7 @@ dependencies = [
[[package]]
name = "event-signer"
version = "0.0.3"
version = "0.1.0"
dependencies = [
"nostr-core",
"nostr-nips",
@@ -868,7 +868,7 @@ dependencies = [
[[package]]
name = "integration-tests"
version = "0.0.3"
version = "0.1.0"
dependencies = [
"nostr-core",
"nostr-nips",
@@ -910,7 +910,7 @@ dependencies = [
[[package]]
name = "keypair-generator"
version = "0.0.3"
version = "0.1.0"
dependencies = [
"nostr-core",
]
@@ -1041,7 +1041,7 @@ dependencies = [
[[package]]
name = "nostr-core"
version = "0.0.3"
version = "0.1.0"
dependencies = [
"aes",
"base64",
@@ -1063,7 +1063,7 @@ dependencies = [
[[package]]
name = "nostr-core-umbrella"
version = "0.0.3"
version = "0.1.0"
dependencies = [
"nostr-core",
"nostr-nips",
@@ -1074,7 +1074,7 @@ dependencies = [
[[package]]
name = "nostr-nips"
version = "0.0.3"
version = "0.1.0"
dependencies = [
"aes",
"block-modes",
@@ -1096,7 +1096,7 @@ dependencies = [
[[package]]
name = "nostr-relay"
version = "0.0.3"
version = "0.1.0"
dependencies = [
"futures-util",
"nostr-core",
@@ -1114,7 +1114,7 @@ dependencies = [
[[package]]
name = "nostr-services"
version = "0.0.3"
version = "0.1.0"
dependencies = [
"nostr-core",
"nostr-relay",
@@ -1129,7 +1129,7 @@ dependencies = [
[[package]]
name = "nostr-signer"
version = "0.0.3"
version = "0.1.0"
dependencies = [
"aes",
"cbc",
+31
View File
@@ -105,6 +105,37 @@ pub fn ecdh_shared_secret(
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.
pub fn ecdh_shared_secret_raw_x(
secret_key: &SecretKey,
public_key: &PublicKey,
) -> NostrResult<[u8; 32]> {
let sk = SecpSecretKey::from_slice(secret_key.as_bytes())
.map_err(|_| NostrError::InvalidInput)?;
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).
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,
// with no hashing. The X coordinate is the first 32 bytes.
let xy = secp256k1::ecdh::shared_secret_point(&pk, &sk);
let mut result = [0u8; 32];
result.copy_from_slice(&xy[..32]);
Ok(result)
}
/// Compute both possible ECDH shared secrets (even and odd parity).
/// Returns (even_parity_result, odd_parity_result).
pub fn ecdh_shared_secret_both(
+182 -43
View File
@@ -1,14 +1,24 @@
//! NIP-04: Legacy Encrypted Direct Messages (AES-256-CBC).
//!
//! Provides encryption and decryption of direct messages using the legacy
//! NIP-04 scheme (AES-256-CBC with base64 encoding).
//! 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 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_both;
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;
@@ -17,25 +27,21 @@ type Aes256CbcEnc = Encryptor<aes::Aes256>;
type Aes256CbcDec = Decryptor<aes::Aes256>;
const IV_SIZE: usize = 16;
const KEY_SIZE: usize = 32;
/// Derive a NIP-04 encryption key from an ECDH shared secret.
fn derive_nip04_key(shared_secret: &[u8; 32]) -> [u8; KEY_SIZE] {
use sha2::Digest;
let hash = sha2::Sha256::digest(shared_secret);
let mut key = [0u8; KEY_SIZE];
key.copy_from_slice(&hash);
key
}
/// Separator between the base64 ciphertext and the base64 IV, per NIP-04.
const IV_SEPARATOR: &str = "?iv=";
/// Encrypt a plaintext using NIP-04 scheme.
/// 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 (shared_even, _) = ecdh_shared_secret_both(sender_sk, recipient_pk)?;
let key = derive_nip04_key(&shared_even);
// 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);
@@ -50,46 +56,48 @@ pub fn nip04_encrypt(
.encrypt_padded_mut::<Pkcs7>(&mut buf, plaintext.len())
.map_err(|_| NostrError::Nip04InvalidFormat)?;
let mut result = Vec::with_capacity(IV_SIZE + encrypted.len());
result.extend_from_slice(&iv);
result.extend_from_slice(encrypted);
Ok(nostr_core::util::base64_encode(&result))
Ok(format!(
"{}{}{}",
nostr_core::util::base64_encode(encrypted),
IV_SEPARATOR,
nostr_core::util::base64_encode(&iv)
))
}
/// Decrypt a NIP-04 encrypted message.
/// 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 (shared_even, shared_odd) = ecdh_shared_secret_both(recipient_sk, sender_pk)?;
let (ct_b64, iv_b64) = ciphertext_b64
.split_once(IV_SEPARATOR)
.ok_or(NostrError::Nip04InvalidFormat)?;
let decoded = nostr_core::util::base64_decode(ciphertext_b64)?;
let encrypted = nostr_core::util::base64_decode(ct_b64)?;
let iv = nostr_core::util::base64_decode(iv_b64)?;
if decoded.len() < IV_SIZE + 16 {
if iv.len() != IV_SIZE || encrypted.len() < 16 {
return Err(NostrError::Nip04InvalidFormat);
}
let iv = &decoded[..IV_SIZE];
let encrypted = &decoded[IV_SIZE..];
// 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)?;
// Try both parities
for shared_secret in [shared_even, shared_odd] {
let key = derive_nip04_key(&shared_secret);
let cipher = match Aes256CbcDec::new_from_slices(&key, iv) {
Ok(c) => c,
Err(_) => continue,
};
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);
}
}
}
let cipher = Aes256CbcDec::new_from_slices(&key, &iv)
.map_err(|_| NostrError::Nip04InvalidFormat)?;
Err(NostrError::Nip04DecryptFailed)
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)]
@@ -110,12 +118,143 @@ mod tests {
#[test]
fn test_nip04_wrong_key() {
let (sk_a, pk_a) = generate_keypair();
let (sk_b, pk_b) = generate_keypair();
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())
.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);
}
}
+3
View File
@@ -8,6 +8,9 @@ use nostr_core::types::{Event, Kind, PublicKey};
use nostr_core::NostrResult;
/// A Cashu proof.
///
/// Field names `C`/`B` are uppercase to match the Cashu protocol JSON.
#[allow(non_snake_case)]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CashuProof {
pub id: String,
+3
View File
@@ -23,6 +23,9 @@ pub struct Nutzap {
}
/// A Nutzap proof.
///
/// Field name `C` is uppercase to match the Cashu protocol JSON.
#[allow(non_snake_case)]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct NutzapProof {
pub id: String,
-21
View File
@@ -20,10 +20,7 @@ use crate::ws::{NostrWsClient, WsState};
// ── Constants ───────────────────────────────────────────────────────────────
const MAX_RELAYS: usize = 32;
const MAX_SUBSCRIPTIONS: usize = 64;
const MAX_SEEN_EVENTS: usize = 1000;
const DEFAULT_TIMEOUT_MS: u64 = 5000;
const PING_INTERVAL_SECS: u64 = 59;
// ── Types ───────────────────────────────────────────────────────────────────
@@ -142,15 +139,12 @@ impl RelayEntry {
/// A pool subscription.
pub struct PoolSubscription {
id: SubscriptionId,
filters: Vec<Filter>,
relay_urls: Vec<String>,
on_event: Option<EventCallback>,
on_eose: Option<EoseCallback>,
close_on_eose: bool,
enable_deduplication: bool,
result_mode: EoseResultMode,
relay_timeout_seconds: u64,
eose_timeout_seconds: u64,
/// Track which relays have sent EOSE.
eose_received: Arc<Mutex<Vec<String>>>,
/// Track received event IDs for deduplication.
@@ -164,27 +158,21 @@ pub struct PoolSubscription {
impl PoolSubscription {
fn new(
id: SubscriptionId,
filters: Vec<Filter>,
relay_urls: Vec<String>,
on_event: Option<EventCallback>,
on_eose: Option<EoseCallback>,
close_on_eose: bool,
enable_deduplication: bool,
result_mode: EoseResultMode,
relay_timeout_seconds: u64,
eose_timeout_seconds: u64,
) -> Self {
PoolSubscription {
id,
filters,
relay_urls,
on_event,
on_eose,
close_on_eose,
enable_deduplication,
result_mode,
relay_timeout_seconds,
eose_timeout_seconds,
eose_received: Arc::new(Mutex::new(Vec::new())),
seen_events: Arc::new(Mutex::new(Vec::new())),
events: Arc::new(Mutex::new(Vec::new())),
@@ -407,22 +395,17 @@ impl RelayPool {
close_on_eose: bool,
enable_deduplication: bool,
result_mode: EoseResultMode,
relay_timeout_seconds: u64,
eose_timeout_seconds: u64,
) -> NostrResult<Arc<PoolSubscription>> {
let id = SubscriptionId::new(uuid::Uuid::new_v4().to_string());
let subscription = Arc::new(PoolSubscription::new(
id.clone(),
filters.clone(),
relay_urls.to_vec(),
on_event,
on_eose,
close_on_eose,
enable_deduplication,
result_mode,
relay_timeout_seconds,
eose_timeout_seconds,
));
// Register the subscription
@@ -903,8 +886,6 @@ impl RelayPool {
true, // close_on_eose
true, // enable_deduplication
EoseResultMode::FullSet,
5, // relay_timeout_seconds
10, // eose_timeout_seconds
)
.await?;
@@ -1053,8 +1034,6 @@ mod tests {
true,
true,
EoseResultMode::FullSet,
5,
10,
)
.await;
+9
View File
@@ -63,6 +63,9 @@ pub struct CashuMeltQuote {
}
/// A blinded message submitted to the mint for minting or swapping.
///
/// Field name `B` is uppercase to match the Cashu protocol JSON.
#[allow(non_snake_case)]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct BlindedMessage {
pub amount: u64,
@@ -71,6 +74,9 @@ pub struct BlindedMessage {
}
/// A blind signature returned by the mint.
///
/// Field name `C` is uppercase to match the Cashu protocol JSON.
#[allow(non_snake_case)]
#[derive(Debug, Clone, serde::Deserialize)]
pub struct BlindSignature {
pub amount: u64,
@@ -85,6 +91,9 @@ pub struct MintResponse {
}
/// A Cashu proof (token) that can be spent or swapped.
///
/// Field name `C` is uppercase to match the Cashu protocol JSON.
#[allow(non_snake_case)]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Proof {
pub amount: u64,