diff --git a/Cargo.toml b/Cargo.toml index e5b7efb..cadf736 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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", @@ -52,12 +52,14 @@ 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"] @@ -65,4 +67,4 @@ 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"] } diff --git a/docs/NIP59_TRANSPORT.md b/docs/NIP59_TRANSPORT.md index cef2030..0f2fd54 100644 --- a/docs/NIP59_TRANSPORT.md +++ b/docs/NIP59_TRANSPORT.md @@ -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 @@ -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`. @@ -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 @@ -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 diff --git a/src/chat/keys.rs b/src/chat/keys.rs index 9b59485..3991e1b 100644 --- a/src/chat/keys.rs +++ b/src/chat/keys.rs @@ -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) @@ -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}; @@ -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 @@ -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) } @@ -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() diff --git a/src/chat/mod.rs b/src/chat/mod.rs index 6d3ffa2..a8b325e 100644 --- a/src/chat/mod.rs +++ b/src/chat/mod.rs @@ -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) { @@ -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( @@ -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(), @@ -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()]; @@ -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(), @@ -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()]; @@ -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()]; @@ -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(), @@ -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()]; diff --git a/src/chat/shared_key.rs b/src/chat/shared_key.rs index f51c2bb..ac933dd 100644 --- a/src/chat/shared_key.rs +++ b/src/chat/shared_key.rs @@ -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. @@ -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 { - 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}" diff --git a/src/chat/unwrap.rs b/src/chat/unwrap.rs index 6255822..31a2e73 100644 --- a/src/chat/unwrap.rs +++ b/src/chat/unwrap.rs @@ -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}; @@ -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() => {} _ => { diff --git a/src/chat/wrap.rs b/src/chat/wrap.rs index 92b121c..e68e47d 100644 --- a/src/chat/wrap.rs +++ b/src/chat/wrap.rs @@ -12,11 +12,15 @@ //! Legacy gift-wrap producers remain available as //! [`wrap_giftwrap_chat_message`] for dual-read migration windows. -use nostr_sdk::nips::{nip44, nip59}; +use nostr::nips::nip44; use nostr_sdk::prelude::*; use crate::error::{MostroError, ServiceError}; +/// NIP-59-compatible random timestamp tweak range (0..2 days), mirrored locally +/// because `nostr::nips::nip59::RANGE_RANDOM_TIMESTAMP_TWEAK` is private in 0.45. +const RANGE_RANDOM_TIMESTAMP_TWEAK_SECS: u64 = 172_800; + /// Wrap a plain-text chat message into a kind 14 event signed by `K_sign`. /// /// * `sender_trade_keys` — signs the inner kind 1 (sender authentication). @@ -45,7 +49,7 @@ pub async fn wrap_chat_message_with_tags( message: &str, extra_tags: Vec, ) -> Result { - if extra_tags.iter().any(|t| t.kind() == TagKind::p()) { + if extra_tags.iter().any(|t| t.kind() == "p") { return Err(MostroError::MostroInternalErr( ServiceError::UnexpectedError("extra_tags must not contain a p tag".to_string()), )); @@ -54,10 +58,10 @@ pub async fn wrap_chat_message_with_tags( // One timestamp for both events: recipients reject a mismatch (replay defense). let now = Timestamp::now(); - let inner = EventBuilder::text_note(message) + let inner = EventBuilder::new(Kind::TextNote, message) .custom_created_at(now) - .build(sender_trade_keys.public_key()) - .sign(sender_trade_keys) + .finalize_unsigned(sender_trade_keys.public_key()) + .finalize_async(sender_trade_keys) .await .map_err(|e| MostroError::MostroInternalErr(ServiceError::NostrError(e.to_string())))?; @@ -76,7 +80,7 @@ pub async fn wrap_chat_message_with_tags( EventBuilder::new(Kind::PrivateDirectMessage, content) .tags(tags) .custom_created_at(now) - .sign_with_keys(sign) + .finalize(sign) .map_err(|e| MostroError::MostroInternalErr(ServiceError::NostrError(e.to_string()))) } @@ -84,14 +88,17 @@ pub async fn wrap_chat_message_with_tags( /// /// Prefer [`wrap_chat_message`]. Kept for dual-read transition tests and any /// client that still needs to emit the superseded envelope during migration. +/// +/// Outer `created_at` is blurred with [`tweaked_timestamp`] (NIP-59-compatible +/// 0..2 day offset); signing uses `EventBuilder::finalize` (nostr 0.45). pub async fn wrap_giftwrap_chat_message( sender_trade_keys: &Keys, shared_pubkey: &PublicKey, message: &str, ) -> Result { - let inner = EventBuilder::text_note(message) - .build(sender_trade_keys.public_key()) - .sign(sender_trade_keys) + let inner = EventBuilder::new(Kind::TextNote, message) + .finalize_unsigned(sender_trade_keys.public_key()) + .finalize_async(sender_trade_keys) .await .map_err(|e| MostroError::MostroInternalErr(ServiceError::NostrError(e.to_string())))?; @@ -106,7 +113,19 @@ pub async fn wrap_giftwrap_chat_message( EventBuilder::new(Kind::GiftWrap, encrypted) .tag(Tag::public_key(*shared_pubkey)) - .custom_created_at(Timestamp::tweaked(nip59::RANGE_RANDOM_TIMESTAMP_TWEAK)) - .sign_with_keys(&ephemeral) + .custom_created_at(tweaked_timestamp()) + .finalize(&ephemeral) .map_err(|e| MostroError::MostroInternalErr(ServiceError::NostrError(e.to_string()))) } + +/// Subtract a random offset in `0..RANGE_RANDOM_TIMESTAMP_TWEAK_SECS` from now +/// (same behaviour as nostr 0.45's private `tweaked_timestamp`). +fn tweaked_timestamp() -> Timestamp { + let now = Timestamp::now().as_secs(); + // Re-use key material as CSPRNG bytes without pulling `rand` into the crate. + let entropy = Keys::generate(); + let bytes = entropy.secret_key().to_secret_bytes(); + let tweak = u64::from_le_bytes(bytes[0..8].try_into().expect("8 bytes")) + % RANGE_RANDOM_TIMESTAMP_TWEAK_SECS; + Timestamp::from_secs(now.saturating_sub(tweak)) +} diff --git a/src/dispute.rs b/src/dispute.rs index 47a0c3f..9c3ed13 100644 --- a/src/dispute.rs +++ b/src/dispute.rs @@ -11,7 +11,7 @@ use crate::{order::Order, user::User, user::UserInfo}; use chrono::Utc; -use nostr_sdk::Timestamp; +use nostr::types::Timestamp; use serde::{Deserialize, Serialize}; #[cfg(feature = "sqlx")] use sqlx::{FromRow, Type}; diff --git a/src/message.rs b/src/message.rs index d2f2ec2..8809939 100644 --- a/src/message.rs +++ b/src/message.rs @@ -13,9 +13,9 @@ use crate::prelude::*; use bitcoin::hashes::sha256::Hash as Sha256Hash; use bitcoin::hashes::Hash; -use bitcoin::key::Secp256k1; -use bitcoin::secp256k1::Message as BitcoinMessage; use nostr_sdk::prelude::*; +use secp256k1::schnorr; +use secp256k1::Secp256k1; #[cfg(feature = "sqlx")] use sqlx::FromRow; @@ -350,12 +350,12 @@ impl Message { /// [`WrapOptions::signed`](crate::nip59::WrapOptions::signed) set to /// `true`. It binds a message to the sender's trade keys without /// relying on the outer Nostr event signature. + /// + /// Implementation note (nostr 0.45): `Keys::sign_schnorr` takes the + /// digest as raw bytes (`AsRef<[u8]>`), not `bitcoin::secp256k1::Message`. pub fn sign(message: String, keys: &Keys) -> Signature { let hash: Sha256Hash = Sha256Hash::hash(message.as_bytes()); - let hash = hash.to_byte_array(); - let message: BitcoinMessage = BitcoinMessage::from_digest(hash); - - keys.sign_schnorr(&message) + keys.sign_schnorr(hash.to_byte_array()) } /// Verify a signature previously produced by [`Message::sign`]. @@ -363,17 +363,18 @@ impl Message { /// Returns `true` when `sig` is a valid Schnorr signature of the /// SHA-256 digest of `message` under `pubkey`, `false` otherwise /// (including when `pubkey` has no x-only representation). + /// + /// Uses the same `secp256k1` 0.30 types as `nostr` (via the crate's + /// direct `secp256k1` dependency) so verification stays aligned with + /// [`Message::sign`]. pub fn verify_signature(message: String, pubkey: PublicKey, sig: Signature) -> bool { - // Create payload hash let hash: Sha256Hash = Sha256Hash::hash(message.as_bytes()); let hash = hash.to_byte_array(); - let message: BitcoinMessage = BitcoinMessage::from_digest(hash); - // Create a verification-only context for better performance let secp = Secp256k1::verification_only(); - // Verify signature if let Ok(xonlykey) = pubkey.xonly() { - xonlykey.verify(&secp, &message, &sig).is_ok() + let sig = schnorr::Signature::from_byte_array(*sig.as_bytes()); + xonlykey.verify(&secp, &hash, &sig).is_ok() } else { false } @@ -992,7 +993,7 @@ mod test { }; use crate::order::SmallOrder; use crate::user::UserInfo; - use nostr_sdk::Keys; + use nostr_sdk::prelude::Keys; use uuid::uuid; #[test] diff --git a/src/nip59.rs b/src/nip59.rs index 33e4864..2c587b3 100644 --- a/src/nip59.rs +++ b/src/nip59.rs @@ -11,9 +11,11 @@ //! signs the seal (and encrypts it to the receiver), while a per-trade //! **trade key** authors the rumor and produces the inner tuple signature. //! This deliberately breaks NIP-59's "rumor author == seal signer" -//! convention that `nostr-sdk` 0.44 enforces via `SenderMismatch`, so the -//! unwrap path does its own NIP-44 + signature verification instead of -//! calling `nip59::extract_rumor`. +//! convention that `nostr` 0.45 enforces via `SenderMismatch` in +//! `nip59::extract_rumor`, so the unwrap path does its own NIP-44 + +//! signature verification instead of calling that helper. Seals are built +//! with `GiftWrapSealBuilder` (the 0.45 replacement for +//! `EventBuilder::seal`). //! //! The module centralizes wrap/unwrap so clients do not need to reimplement //! NIP-59 glue themselves. It does not manage relays, subscriptions, @@ -24,9 +26,14 @@ use std::str::FromStr; use crate::message::{Action, Message, Payload}; use crate::prelude::{CantDoReason, MostroError, ServiceError}; -use nostr_sdk::nips::{nip44, nip59}; +use nostr::nips::nip44; +use nostr::nips::nip59::GiftWrapSealBuilder; use nostr_sdk::prelude::*; +/// NIP-59-compatible random timestamp tweak range (0..2 days). +/// Mirrored locally: `RANGE_RANDOM_TIMESTAMP_TWEAK` is private in nostr 0.45. +const RANGE_RANDOM_TIMESTAMP_TWEAK_SECS: u64 = 172_800; + /// Options controlling how a Mostro message is wrapped. #[derive(Debug, Clone)] pub struct WrapOptions { @@ -74,13 +81,15 @@ pub struct UnwrappedMessage { /// /// * `message` — the Mostro message to send. /// * `identity_keys` — long-lived identity keys. Sign the seal (kind 13) -/// and encrypt it to `receiver` via NIP-44. Callers that want the -/// "full privacy" mode (no stable identity, no reputation) should pass -/// the same value as `trade_keys`. +/// and encrypt it to `receiver` via NIP-44 (`GiftWrapSealBuilder` in +/// nostr 0.45). Callers that want the "full privacy" mode (no stable +/// identity, no reputation) should pass the same value as `trade_keys`. /// * `trade_keys` — per-trade keys. Author of the rumor (kind 1) and /// signer of the inner tuple signature when `opts.signed == true`. /// * `receiver` — the Mostro node public key. -/// * `opts` — wrap options (PoW, expiration, signed). +/// * `opts` — wrap options (PoW, expiration, signed). Outer PoW uses +/// `UnsignedEvent::mine` when `pow > 0`; gift-wrap `created_at` is +/// blurred with the local NIP-59-compatible tweak helper. pub async fn wrap_message( message: &Message, identity_keys: &Keys, @@ -102,17 +111,15 @@ pub async fn wrap_message( // PoW only applies to the outer GiftWrap (per WrapOptions docs); the // rumor is encrypted inside the seal and never published on its own, // so mining its event id would burn CPU for nothing. - let rumor = EventBuilder::text_note(content).build(trade_keys.public_key()); + let rumor = + EventBuilder::new(Kind::TextNote, content).finalize_unsigned(trade_keys.public_key()); // Seal is encrypted and signed with identity_keys so the receiver can // decrypt it via (receiver_secret, seal.pubkey) — this keeps seal.pubkey // consistent with the encryption key, while leaving rumor.pubkey free to // carry the per-trade key (the mismatch standard NIP-59 rejects). - let seal: Event = EventBuilder::seal(identity_keys, &receiver, rumor) - .await - .map_err(|e| MostroError::MostroInternalErr(ServiceError::NostrError(e.to_string())))? - .sign(identity_keys) - .await + let seal: Event = GiftWrapSealBuilder::new(rumor, receiver) + .finalize(identity_keys) .map_err(|e| MostroError::MostroInternalErr(ServiceError::NostrError(e.to_string())))?; gift_wrap_from_seal_with_pow(&seal, receiver, opts.pow, opts.expiration) @@ -121,6 +128,10 @@ pub async fn wrap_message( /// Wrap an already built Seal into a NIP-59 GiftWrap with optional PoW and /// expiration. The outer event is signed with a freshly generated ephemeral /// key and carries a mandatory `p` tag pointing at `receiver`. +/// +/// PoW (`pow > 0`) is applied with `UnsignedEvent::mine(&SingleThreadPow, …)` +/// before `finalize`; `created_at` uses the local [`tweaked_timestamp`] helper +/// (nostr 0.45 made `Timestamp::tweaked` / the NIP-59 range private). fn gift_wrap_from_seal_with_pow( seal: &Event, receiver: PublicKey, @@ -148,14 +159,33 @@ fn gift_wrap_from_seal_with_pow( } tags.push(Tag::public_key(receiver)); - EventBuilder::new(Kind::GiftWrap, encrypted) + let unsigned = EventBuilder::new(Kind::GiftWrap, encrypted) .tags(tags) - .custom_created_at(Timestamp::tweaked(nip59::RANGE_RANDOM_TIMESTAMP_TWEAK)) - .pow(pow) - .sign_with_keys(&ephemeral) + .custom_created_at(tweaked_timestamp()) + .finalize_unsigned(ephemeral.public_key()); + + let unsigned = match core::num::NonZeroU8::new(pow) { + Some(pow) => unsigned + .mine(&SingleThreadPow, pow) + .map_err(|e| MostroError::MostroInternalErr(ServiceError::NostrError(e.to_string())))?, + None => unsigned, + }; + + unsigned + .finalize(&ephemeral) .map_err(|e| MostroError::MostroInternalErr(ServiceError::NostrError(e.to_string()))) } +/// Subtract a random offset in `0..RANGE_RANDOM_TIMESTAMP_TWEAK_SECS` from now. +fn tweaked_timestamp() -> Timestamp { + let now = Timestamp::now().as_secs(); + let entropy = Keys::generate(); + let bytes = entropy.secret_key().to_secret_bytes(); + let tweak = u64::from_le_bytes(bytes[0..8].try_into().expect("8 bytes")) + % RANGE_RANDOM_TIMESTAMP_TWEAK_SECS; + Timestamp::from_secs(now.saturating_sub(tweak)) +} + /// Try to open an incoming GiftWrap with the given `receiver_keys`. /// /// Returns `Ok(None)` only when the outer NIP-44 layer could not be @@ -437,7 +467,7 @@ mod tests { let corrupted = EventBuilder::new(Kind::GiftWrap, encrypted) .tags([Tag::public_key(receiver_keys.public_key())]) - .sign_with_keys(&ephemeral) + .finalize(&ephemeral) .expect("sign"); let result = unwrap_message(&corrupted, &receiver_keys).await; @@ -457,12 +487,10 @@ mod tests { inner: (&Message, Option), ) -> Event { let content = serde_json::to_string(&inner).unwrap(); - let rumor = EventBuilder::text_note(content).build(trade_keys.public_key()); - let seal = EventBuilder::seal(identity_keys, &receiver, rumor) - .await - .unwrap() - .sign(identity_keys) - .await + let rumor = + EventBuilder::new(Kind::TextNote, content).finalize_unsigned(trade_keys.public_key()); + let seal = GiftWrapSealBuilder::new(rumor, receiver) + .finalize(identity_keys) .unwrap(); gift_wrap_from_seal_with_pow(&seal, receiver, 0, None).unwrap() } diff --git a/src/order.rs b/src/order.rs index 5d24547..a0b4eb6 100644 --- a/src/order.rs +++ b/src/order.rs @@ -8,7 +8,8 @@ //! broadcasting via Nostr or surfacing minimal information to clients. use crate::prelude::*; -use nostr_sdk::{PublicKey, Timestamp}; +use nostr::key::PublicKey; +use nostr::types::Timestamp; use serde::{Deserialize, Serialize}; #[cfg(feature = "sqlx")] use sqlx::FromRow; @@ -775,7 +776,7 @@ impl From for SmallOrder { mod tests { use super::*; use crate::error::CantDoReason; - use nostr_sdk::Keys; + use nostr_sdk::prelude::Keys; use uuid::uuid; #[test] diff --git a/src/rating.rs b/src/rating.rs index 903e6eb..84de997 100644 --- a/src/rating.rs +++ b/src/rating.rs @@ -62,39 +62,20 @@ impl Rating { /// Encode the rating as a set of Nostr tags, ready to attach to an event. /// - /// The returned [`Tags`] value contains one entry per numeric field plus - /// a `z` marker tag identifying the payload as a rating. - pub fn to_tags(&self) -> Result { + /// Returns a [`Tags`] value with one entry per numeric field plus a `z` + /// marker tag identifying the payload as a rating. Encoding is infallible + /// (nostr 0.45 `Tag::custom` takes string kind keys directly). + pub fn to_tags(&self) -> Tags { let tags = vec![ - Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("total_reviews")), - vec![self.total_reviews.to_string()], - ), - Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("total_rating")), - vec![self.total_rating.to_string()], - ), - Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("last_rating")), - vec![self.last_rating.to_string()], - ), - Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("max_rate")), - vec![self.max_rate.to_string()], - ), - Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("min_rate")), - vec![self.min_rate.to_string()], - ), - Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("z")), - vec!["rating".to_string()], - ), + Tag::custom("total_reviews", vec![self.total_reviews.to_string()]), + Tag::custom("total_rating", vec![self.total_rating.to_string()]), + Tag::custom("last_rating", vec![self.last_rating.to_string()]), + Tag::custom("max_rate", vec![self.max_rate.to_string()]), + Tag::custom("min_rate", vec![self.min_rate.to_string()]), + Tag::custom("z", vec!["rating".to_string()]), ]; - let tags = Tags::from_list(tags); - - Ok(tags) + Tags::from_list(tags) } /// Rebuild a [`Rating`] from a set of Nostr tags previously produced by diff --git a/src/transport.rs b/src/transport.rs index f2c4383..c982b8f 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -44,7 +44,7 @@ use std::str::FromStr; use crate::message::Message; use crate::nip59::{self, UnwrappedMessage, WrapOptions}; use crate::prelude::{MostroError, ServiceError}; -use nostr_sdk::nips::nip44; +use nostr::nips::nip44; use nostr_sdk::prelude::*; use serde::{Deserialize, Serialize}; @@ -170,7 +170,10 @@ impl std::fmt::Display for Transport { /// from `trade_keys` and `receiver`, so only those two parties can /// decrypt the content. /// * `opts` — PoW difficulty, NIP-40 expiration and inner-signature flag, -/// same semantics as the gift-wrap transport. +/// same semantics as the gift-wrap transport. When `opts.pow > 0`, PoW is +/// mined on the unsigned event via `UnsignedEvent::mine(&SingleThreadPow, …)` +/// before signing with `finalize` (nostr 0.45; replaces `EventBuilder::pow` +/// / `sign_with_keys`). pub fn wrap_message_nip44( message: &Message, identity_keys: &Keys, @@ -210,10 +213,19 @@ pub fn wrap_message_nip44( tags.push(Tag::expiration(exp)); } - EventBuilder::new(Kind::PrivateDirectMessage, encrypted) + let unsigned = EventBuilder::new(Kind::PrivateDirectMessage, encrypted) .tags(tags) - .pow(opts.pow) - .sign_with_keys(trade_keys) + .finalize_unsigned(trade_keys.public_key()); + + let unsigned = match core::num::NonZeroU8::new(opts.pow) { + Some(pow) => unsigned + .mine(&SingleThreadPow, pow) + .map_err(|e| MostroError::MostroInternalErr(ServiceError::NostrError(e.to_string())))?, + None => unsigned, + }; + + unsigned + .finalize(trade_keys) .map_err(|e| MostroError::MostroInternalErr(ServiceError::NostrError(e.to_string()))) } @@ -393,7 +405,7 @@ mod tests { .expect("encrypt"); EventBuilder::new(Kind::PrivateDirectMessage, encrypted) .tags([Tag::public_key(receiver)]) - .sign_with_keys(trade_keys) + .finalize(trade_keys) .expect("sign") } @@ -686,8 +698,8 @@ mod tests { #[tokio::test] async fn unwrap_incoming_rejects_unknown_kind() { let keys = Keys::generate(); - let event = EventBuilder::text_note("hello") - .sign_with_keys(&keys) + let event = EventBuilder::new(Kind::TextNote, "hello") + .finalize(&keys) .expect("sign"); let result = unwrap_incoming(&event, &keys).await;