Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 12 additions & 10 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ pre-release-hook = [
]

[dependencies]
chrono = "0.4.38"
serde = { version = "1.0.149", features = ["derive"] }
serde_json = "1.0.142"
uuid = { version = "1.18.0", features = [
chrono = "0.4.45"
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
uuid = { version = "1.24.0", features = [
"v4",
"fast-rng",
"macro-diagnostics",
Expand All @@ -52,17 +52,19 @@ sqlx = { version = "0.9.0", features = [
"chrono",
"uuid",
], optional = true }
wasm-bindgen = { version = "0.2.92", optional = true }
nostr-sdk = { version = "0.44.1", features = ["nip44", "nip59"] }
bitcoin = "0.32.7"
wasm-bindgen = { version = "0.2.127", optional = true }
nostr-sdk = { version = "0.45.1" }
nostr = { version = "0.45.1", default-features = false, features = ["nip44", "nip59"] }
bitcoin = "0.32.102"
# Leading `::hkdf` in chat code avoids ambiguity with `nostr_sdk::prelude::hkdf`.
hkdf = "0.12"
sha2 = "0.10"
hkdf = "0.13.0"
sha2 = "0.11.0"
secp256k1 = "0.30"

[features]
default = ["wasm"]
wasm = ["dep:wasm-bindgen"]
sqlx = ["dep:wasm-bindgen", "dep:sqlx"]

[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread"] }
53 changes: 28 additions & 25 deletions docs/NIP59_TRANSPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,12 @@ GiftWrap (kind 1059, signed by fresh ephemeral keys,
optional PoW, randomized created_at)
```

`nostr-sdk` 0.44's `nip59::extract_rumor` enforces
`nostr` 0.45's `nip59::extract_rumor` enforces
`seal.pubkey == rumor.pubkey` and rejects the split above with
`SenderMismatch`. `unwrap_message` therefore performs its own NIP-44
decryption and seal-signature verification instead of calling
`extract_rumor`.
`extract_rumor`. Seals are built with `GiftWrapSealBuilder` (the 0.45
replacement for `EventBuilder::seal`).

## Public API

Expand Down Expand Up @@ -125,20 +126,22 @@ Builds a publishable GiftWrap event. Steps:
1. Serialize `message` to JSON.
2. If `opts.signed`, sign the JSON with `trade_keys` and include the
signature in the inner tuple; else include `None`.
3. Build the rumor as `EventBuilder::text_note(inner_json)` authored
by `trade_keys.public_key()`. **No PoW is mined on the rumor** —
it is encrypted inside the seal and never published alone.
4. Seal via `EventBuilder::seal(identity_keys, &receiver, rumor)`
(NIP-44 encrypts the rumor JSON under `identity_keys ↔ receiver`)
and sign the resulting event with `identity_keys`. Encryption and
signing must use the same key so the receiver can derive the
shared secret from `seal.pubkey` alone.
3. Build the rumor as
`EventBuilder::new(Kind::TextNote, inner_json).finalize_unsigned(trade_keys.public_key())`.
**No PoW is mined on the rumor** — it is encrypted inside the seal
and never published alone.
4. Seal via `GiftWrapSealBuilder::new(rumor, receiver).finalize(identity_keys)`
(NIP-44 encrypts the rumor JSON under `identity_keys ↔ receiver`
and signs the seal). Encryption and signing must use the same key
so the receiver can derive the shared secret from `seal.pubkey`
alone.
5. Encrypt the seal JSON with NIP-44 under a fresh ephemeral key for
`receiver`, attach `["p", receiver]` (mandatory) and optionally
`["expiration", ts]`, stamp `created_at =
Timestamp::tweaked(nip59::RANGE_RANDOM_TIMESTAMP_TWEAK)` (OsRng,
0..172_800 s in the past), mine PoW at `opts.pow`, sign with the
ephemeral key.
`["expiration", ts]`, stamp `created_at` with the local
`tweaked_timestamp()` helper (0..172_800 s in the past; mirrors
nostr 0.45's private NIP-59 tweak), optionally
`UnsignedEvent::mine(&SingleThreadPow, pow)` when `opts.pow > 0`,
then `finalize` with the ephemeral key.

For full-privacy mode, pass the same `Keys` as both `identity_keys`
and `trade_keys`.
Expand Down Expand Up @@ -259,20 +262,20 @@ trust" must never look the same to the caller.
### Timestamp blur

Per NIP-59, GiftWrap `created_at` should be randomized to obscure the
real send time. The module calls
`Timestamp::tweaked(nip59::RANGE_RANDOM_TIMESTAMP_TWEAK)`, which draws
a uniformly random `u64` in `0..172_800` (two days) from `OsRng` and
subtracts it from the current Unix second. The `Timestamp::tweaked`
helper is also what `nostr-sdk`'s own `make_seal` uses, so wrap and
seal metadata share the same distribution.
real send time. nostr 0.45 made `Timestamp::tweaked` and
`RANGE_RANDOM_TIMESTAMP_TWEAK` private, so the module uses a local
`tweaked_timestamp()` that draws a uniformly random `u64` in
`0..172_800` (two days) and subtracts it from the current Unix second
— the same distribution as nostr's internal NIP-59 helper.

### PoW scope

`WrapOptions.pow` applies only to the outer GiftWrap event. The
rumor is encrypted inside the seal and never published on its own,
so mining its event id is pure CPU waste. The seal itself is not
mined; `nostr-sdk` does not expose a PoW hook on `EventBuilder::seal`
and the seal's id is not observable on relays in any meaningful way.
mined; `GiftWrapSealBuilder` has no PoW hook and the seal's id is
not observable on relays in any meaningful way. Outer PoW uses
`UnsignedEvent::mine(&SingleThreadPow, …)` before `finalize`.

### Ephemeral outer signer

Expand Down Expand Up @@ -350,10 +353,10 @@ if let Some(sig) = unwrapped.signature {

## Dependency surface

- `nostr-sdk = "0.44.1"` with features `nip44`, `nip59`.
- `nostr-sdk = "0.45.1"` and `nostr = "0.45.1"` (`nip44`, `nip59`).
- `serde_json` for the inner tuple.
- No new direct RNG dependency; randomness flows through
`nostr-sdk`'s `Timestamp::tweaked` (OsRng).
- No new direct RNG dependency; timestamp blur reuses
`Keys::generate()` entropy for the local tweak helper.

## Testing

Expand Down
44 changes: 35 additions & 9 deletions src/chat/keys.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
//! Domain-separated chat key derivation (`K_conv` / `K_sign`).
//!
//! The ECDH shared secret between two trade keys (or admin ↔ party trade key)
//! is **not** used on the wire. HKDF-SHA256 splits it into:
//! is **not** used on the wire. ECDH itself is computed by the local
//! [`generate_shared_key`] helper (`nostr::util::generate_shared_key` is
//! crate-private in 0.45). HKDF-SHA256 then splits that secret into:
//!
//! * [`K_conv`](derive_chat_keys) — NIP-44 encryption and the outer `p` tag
//! * [`K_sign`](derive_chat_keys) — signs the outer kind 14 event (author filter)
Expand All @@ -12,6 +14,7 @@
// module by that name, so a plain `use hkdf::Hkdf` is ambiguous.
use ::hkdf::Hkdf;
use nostr_sdk::prelude::*;
use secp256k1::{ecdh, PublicKey as Secp256k1PublicKey};
use sha2::Sha256;

use crate::error::{MostroError, ServiceError};
Expand All @@ -21,6 +24,35 @@ pub const CHAT_CONV_INFO: &[u8] = b"mostro:chat:conv:v1";
/// HKDF `info` for `K_sign`. Changing this value changes the wire format.
pub const CHAT_SIGN_INFO: &[u8] = b"mostro:chat:sign:v1";

/// Raw x25519-style ECDH shared secret (even-parity assumption, per NIP-04/44).
///
/// Replaces `nostr::util::generate_shared_key`, which is crate-private in 0.45.
pub(crate) fn generate_shared_key(
secret_key: &SecretKey,
public_key: &PublicKey,
) -> Result<[u8; 32], MostroError> {
let mut compressed = [0u8; 33];
compressed[0] = 0x02; // assume even parity, as NIP-04/44 do
compressed[1..].copy_from_slice(public_key.as_bytes());
let normalized = Secp256k1PublicKey::from_slice(&compressed).map_err(|e| {
MostroError::MostroInternalErr(ServiceError::EncryptionError(format!(
"invalid peer pubkey: {e}"
)))
})?;

let secret_key =
secp256k1::SecretKey::from_byte_array(&secret_key.to_secret_bytes()).map_err(|e| {
MostroError::MostroInternalErr(ServiceError::EncryptionError(format!(
"invalid local secret key: {e}"
)))
})?;

let point = ecdh::shared_secret_point(&normalized, &secret_key);
let mut shared = [0u8; 32];
shared.copy_from_slice(&point[..32]);
Ok(shared)
}

/// Derive `(K_conv, K_sign)` from a party's trade keys and the peer's trade pubkey.
///
/// Both peers obtain the same pair by swapping arguments
Expand All @@ -29,12 +61,7 @@ pub fn derive_chat_keys(
own_trade: &Keys,
peer_trade: &PublicKey,
) -> Result<(Keys, Keys), MostroError> {
let shared =
nostr_sdk::util::generate_shared_key(own_trade.secret_key(), peer_trade).map_err(|e| {
MostroError::MostroInternalErr(ServiceError::EncryptionError(format!(
"chat ECDH failed: {e}"
)))
})?;
let shared = generate_shared_key(own_trade.secret_key(), peer_trade)?;
derive_chat_keys_from_shared(&shared)
}

Expand Down Expand Up @@ -100,8 +127,7 @@ mod tests {
"000009ae5cff9f6ba9b05159ec5ed58c187f5882ea77c81ed5dd19163272a5d7"
);

let shared =
nostr_sdk::util::generate_shared_key(alice.secret_key(), &bob.public_key()).unwrap();
let shared = generate_shared_key(alice.secret_key(), &bob.public_key()).unwrap();
let expected_shared =
SecretKey::from_hex("def6633a53d07d1e829484c4d4bdbbeed2f4b14c21743e63871c174338e39475")
.unwrap()
Expand Down
33 changes: 15 additions & 18 deletions src/chat/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ pub use wrap::{wrap_chat_message, wrap_chat_message_with_tags, wrap_giftwrap_cha
#[cfg(test)]
mod tests {
use super::*;
use nostr_sdk::nips::nip44;
use nostr::nips::nip44;
use nostr_sdk::prelude::*;

fn chat_pair() -> (Keys, Keys, Keys, Keys) {
Expand All @@ -89,7 +89,7 @@ mod tests {

assert_eq!(event.kind, Kind::PrivateDirectMessage);
assert_eq!(event.pubkey, sign.public_key());
assert!(event.tags.public_keys().any(|pk| *pk == conv.public_key()));
assert!(event.tags.public_keys().any(|pk| pk == conv.public_key()));

let allowed = [alice.public_key(), bob.public_key()];
let decoded = unwrap_chat_message(
Expand Down Expand Up @@ -124,11 +124,10 @@ mod tests {
async fn unwrap_rejects_wrong_p_tag() {
let (alice, bob, conv, sign) = chat_pair();
let now = Timestamp::now();
let inner = EventBuilder::text_note("hi")
let inner = EventBuilder::new(Kind::TextNote, "hi")
.custom_created_at(now)
.build(alice.public_key())
.sign(&alice)
.await
.finalize_unsigned(alice.public_key())
.finalize(&alice)
.unwrap();
let content = nip44::encrypt(
conv.secret_key(),
Expand All @@ -141,7 +140,7 @@ mod tests {
let event = EventBuilder::new(Kind::PrivateDirectMessage, content)
.tag(Tag::public_key(wrong_p))
.custom_created_at(now)
.sign_with_keys(&sign)
.finalize(&sign)
.unwrap();

let allowed = [alice.public_key(), bob.public_key()];
Expand All @@ -163,11 +162,10 @@ mod tests {
async fn unwrap_rejects_future_timestamp() {
let (alice, bob, conv, sign) = chat_pair();
let far_future = Timestamp::from_secs(Timestamp::now().as_secs() + 3600);
let inner = EventBuilder::text_note("hi")
let inner = EventBuilder::new(Kind::TextNote, "hi")
.custom_created_at(far_future)
.build(alice.public_key())
.sign(&alice)
.await
.finalize_unsigned(alice.public_key())
.finalize(&alice)
.unwrap();
let content = nip44::encrypt(
conv.secret_key(),
Expand All @@ -179,7 +177,7 @@ mod tests {
let event = EventBuilder::new(Kind::PrivateDirectMessage, content)
.tag(Tag::public_key(conv.public_key()))
.custom_created_at(far_future)
.sign_with_keys(&sign)
.finalize(&sign)
.unwrap();

let allowed = [alice.public_key(), bob.public_key()];
Expand All @@ -205,7 +203,7 @@ mod tests {
let event = EventBuilder::new(Kind::PrivateDirectMessage, huge)
.tag(Tag::public_key(conv.public_key()))
.custom_created_at(now)
.sign_with_keys(&sign)
.finalize(&sign)
.unwrap();

let allowed = [alice.public_key(), bob.public_key()];
Expand All @@ -230,11 +228,10 @@ mod tests {
let (alice, bob, conv, sign) = chat_pair();
let intruder = Keys::generate();
let now = Timestamp::now();
let inner = EventBuilder::text_note("forged")
let inner = EventBuilder::new(Kind::TextNote, "forged")
.custom_created_at(now)
.build(intruder.public_key())
.sign(&intruder)
.await
.finalize_unsigned(intruder.public_key())
.finalize(&intruder)
.unwrap();
let content = nip44::encrypt(
conv.secret_key(),
Expand All @@ -246,7 +243,7 @@ mod tests {
let event = EventBuilder::new(Kind::PrivateDirectMessage, content)
.tag(Tag::public_key(conv.public_key()))
.custom_created_at(now)
.sign_with_keys(&sign)
.finalize(&sign)
.unwrap();

let allowed = [alice.public_key(), bob.public_key()];
Expand Down
8 changes: 2 additions & 6 deletions src/chat/shared_key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

use nostr_sdk::prelude::*;

use crate::chat::keys::derive_chat_keys_from_shared;
use crate::chat::keys::{derive_chat_keys_from_shared, generate_shared_key};
use crate::error::{MostroError, ServiceError};

/// Shared ECDH secret between two parties' trade (or admin) keys.
Expand All @@ -30,11 +30,7 @@ impl SharedKey {
/// Both peers obtain the same `SharedKey` by swapping arguments
/// (`A.derive(a_sk, b_pk) == B.derive(b_sk, a_pk)`).
pub fn derive(secret: &SecretKey, counterparty: &PublicKey) -> Result<Self, MostroError> {
let bytes = nostr_sdk::util::generate_shared_key(secret, counterparty).map_err(|e| {
MostroError::MostroInternalErr(ServiceError::EncryptionError(format!(
"shared key derivation failed: {e}"
)))
})?;
let bytes = generate_shared_key(secret, counterparty)?;
let secret = SecretKey::from_slice(&bytes).map_err(|e| {
MostroError::MostroInternalErr(ServiceError::EncryptionError(format!(
"invalid shared secret: {e}"
Expand Down
4 changes: 2 additions & 2 deletions src/chat/unwrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
//! except the caller-owned steps: rate-limit budget, outer-id LRU, and durable
//! inner-id deduplication.

use nostr_sdk::nips::nip44;
use nostr::nips::nip44;
use nostr_sdk::prelude::*;

use crate::error::{MostroError, ServiceError};
Expand Down Expand Up @@ -66,7 +66,7 @@ pub fn unwrap_chat_message(
}

// 2. Exactly one `p` tag equal to pub(K_conv)
let mut p_tags = outer.tags.iter().filter(|t| t.kind() == TagKind::p());
let mut p_tags = outer.tags.iter().filter(|t| t.kind() == "p");
match (p_tags.next().and_then(|t| t.content()), p_tags.next()) {
(Some(pk), None) if pk == conv.public_key().to_hex() => {}
_ => {
Expand Down
Loading