From 758ffaf7d952268586f9b1906f32b6ed2990a693 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 4 Oct 2026 13:27:43 +0700 Subject: [PATCH] build NIP-59 seals manually for nip4e interop --- Cargo.lock | 1 + Cargo.toml | 2 +- crates/chat/Cargo.toml | 1 + crates/chat/src/lib.rs | 10 +++- crates/chat/src/room.rs | 116 +++++++++++++++++++++++++++++++++++----- docs/nip4e-audit.md | 12 +++++ 6 files changed, 127 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c9f72605..a5710ca7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1083,6 +1083,7 @@ dependencies = [ "nip4e", "nostr-sdk", "person", + "rand 0.10.3", "settings", "smallvec", "smol", diff --git a/Cargo.toml b/Cargo.toml index 8ca3bc30..c17f1307 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,7 +44,7 @@ hkdf = "0.12" chacha20 = "0.9" hmac = "0.12" # Pinned to the instance `nostr-sdk` already builds, so NIP-44 nonces share it -rand = { version = "0.10", default-features = false, features = [ "std", "sys_rng" ] } +rand = { version = "0.10", default-features = false, features = [ "std", "sys_rng", "thread_rng" ] } # Others anyhow = "1.0.44" diff --git a/crates/chat/Cargo.toml b/crates/chat/Cargo.toml index e0dcd9f7..efc4f0e4 100644 --- a/crates/chat/Cargo.toml +++ b/crates/chat/Cargo.toml @@ -17,6 +17,7 @@ instant.workspace = true anyhow.workspace = true itertools.workspace = true +rand.workspace = true smallvec.workspace = true log.workspace = true flume.workspace = true diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index a2e47ade..98c14cdd 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -874,9 +874,17 @@ where let seal: Event = Event::from_json(seal)?; seal.verify()?; + let sender_encryption_key = seal + .tags + .iter() + .find(|tag| tag.kind() == "n") + .and_then(|tag| tag.content()) + .and_then(|content| PublicKey::parse(content).ok()) + .unwrap_or(seal.pubkey); + // Get the rumor event let rumor = signer - .nip44_decrypt_async(&seal.pubkey, &seal.content) + .nip44_decrypt_async(&sender_encryption_key, &seal.content) .await?; let rumor = UnsignedEvent::from_json(rumor)?; diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index d1885300..4c031a1c 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -6,14 +6,16 @@ use common::EventExt; use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task}; use instant::Duration; use itertools::Itertools; +use nip4e::DeviceRegistry; use nostr_sdk::prelude::*; use person::{Person, PersonRegistry}; use settings::{RoomConfig, SignerKind}; -use state::{NostrRegistry, TIMEOUT}; +use state::{NostrRegistry, TIMEOUT, UniversalSigner}; use crate::{FileAttachment, KIND_FILE_MESSAGE, NewMessage}; const NO_DEKEY: &str = "User hasn't set up a decoupled encryption key yet."; +const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key yet. Set one up in the settings to message them."; /// The outcome of delivering an encrypted rumor to a single receiver. #[derive(Debug, Clone)] @@ -474,6 +476,9 @@ impl Room { pub fn send(&self, rumor: UnsignedEvent, cx: &App) -> Option>> { let config = self.config.clone(); + let device = DeviceRegistry::global(cx); + let encryption_signer = device.read(cx).signer(cx); + let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); let user_signer = nostr.read(cx).signer(); @@ -495,14 +500,33 @@ impl Room { let signer_kind = config.signer_kind(); // Deliver to every receiver concurrently - let sends = receivers - .into_iter() - .map(|receiver| deliver(&client, signer_kind, receiver, &rumor, &user_signer)); + let sends = receivers.into_iter().map(|receiver| { + deliver( + &client, + signer_kind, + receiver, + &rumor, + encryption_signer.as_ref(), + &user_signer, + ) + }); + let mut reports: Vec = futures::future::join_all(sends).await; + let minimum_success = reports.iter().any(SendReport::success); // Back up the message to ourselves once at least one receiver got it - if config.backup() && reports.iter().any(SendReport::success) { - reports.push(deliver(&client, signer_kind, sender, &rumor, &user_signer).await); + if config.backup() && minimum_success { + reports.push( + deliver( + &client, + signer_kind, + sender, + &rumor, + encryption_signer.as_ref(), + &user_signer, + ) + .await, + ); } reports @@ -516,6 +540,7 @@ async fn deliver( signer_kind: &SignerKind, receiver: Person, rumor: &UnsignedEvent, + encryption_signer: Option<&UniversalSigner>, user_signer: &S, ) -> SendReport where @@ -532,9 +557,26 @@ where result: Err(NO_DEKEY.into()), }; } + + // Sender didn't set up a decoupled encryption key + if encryption_signer.is_none() { + return SendReport { + receiver: public_key, + result: Err(USER_NO_DEKEY.into()), + }; + } } - match send_gift_wrap(client, user_signer, &receiver, rumor, signer_kind).await { + match send_gift_wrap( + client, + &receiver, + rumor, + signer_kind, + encryption_signer, + user_signer, + ) + .await + { Ok(output) => SendReport { receiver: public_key, result: Ok(output), @@ -549,10 +591,11 @@ where /// Build the gift-wrapped event for a rumor and send it. async fn send_gift_wrap( client: &Client, - signer: &S, receiver: &Person, rumor: &UnsignedEvent, config: &SignerKind, + encryption_signer: Option<&UniversalSigner>, + user_signer: &S, ) -> Result, Error> where S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44, @@ -578,11 +621,50 @@ where SignerKind::User => receiver.public_key(), }; - // Construct the gift wrap event - let event = nip59::GiftWrapBuilder::new(receiver_key, rumor.clone()) - .extra_tags(extra_tags) - .finalize_async(signer) - .await?; + // Make sure the rumor carries an event ID before it's serialized + let mut rumor = rumor.clone(); + rumor.ensure_id(); + + let rumor_json = rumor.as_json(); + + let seal: Event = match encryption_signer { + Some(encryption_signer) => { + let encryption_pubkey = encryption_signer.get_public_key_async().await?; + let content = encryption_signer + .nip44_encrypt_async(&receiver_key, &rumor_json) + .await?; + + EventBuilder::new(Kind::Seal, content) + .tags(vec![Tag::custom("n", [encryption_pubkey.to_hex()])]) + .custom_created_at(tweaked_timestamp()) + .finalize_async(user_signer) + .await? + } + None => { + let content = user_signer + .nip44_encrypt_async(&receiver_key, &rumor_json) + .await?; + + EventBuilder::new(Kind::Seal, content) + .custom_created_at(tweaked_timestamp()) + .finalize_async(user_signer) + .await? + } + }; + + let ephemeral_keys = Keys::generate(); + let content = nip44::encrypt( + ephemeral_keys.secret_key(), + &receiver_key, + seal.as_json(), + nip44::Version::default(), + )?; + extra_tags.push(Tag::public_key(receiver_key)); + + let event = EventBuilder::new(Kind::GiftWrap, content) + .tags(extra_tags) + .custom_created_at(tweaked_timestamp()) + .finalize(&ephemeral_keys)?; // Send to the receiver's NIP-17 relays. client @@ -591,3 +673,11 @@ where .await .map_err(Into::into) } + +/// A tweaked timestamp to thwart time-analysis attacks, per NIP-59 +fn tweaked_timestamp() -> Timestamp { + const MAX_TWEAK_SECONDS: u64 = 2 * 24 * 60 * 60; + + let seconds = rand::random_range(0..=MAX_TWEAK_SECONDS); + Timestamp::from_secs(Timestamp::now().as_secs().saturating_sub(seconds)) +} diff --git a/docs/nip4e-audit.md b/docs/nip4e-audit.md index 0eb70499..4e6b5f78 100644 --- a/docs/nip4e-audit.md +++ b/docs/nip4e-audit.md @@ -30,6 +30,18 @@ > notification, the requester in the "Wait for approval" notification — so the > transfer can be verified out of band before approving. Still open: the > plaintext backup file in section 4. +> +> **Update 4**: Verified against the PsstPsst source (nip17-gift-wrap.ts, +> encryption-key.service.ts) and closed the remaining envelope incompatibility. +> Coop now emits PsstPsst's split-key NIP-59 format when the sender has an +> encryption key: the seal (kind 13) is signed by the identity, the rumor +> content is NIP-44'd under the sender's encryption key, and the seal carries +> the sender's encryption pubkey in an `n` tag; the wrap uses a one-shot +> ephemeral key. Receiving supports both formats: the seal's `n` tag selects +> the content-decryption key, falling back to the seal author for legacy +> unified seals. Interop matrix is now green in all four directions, with one +> caveat: a Coop sender without an encryption key degrades to the legacy +> format, which PsstPsst ignores on its relay intake path. **Scope**: `crates/nip4e/src/lib.rs` (key announcement/sync), the send path (`crates/chat/src/room.rs`), the receive path (`crates/chat/src/lib.rs`), UI integration (`crates/workspace`), compared against the [NIP-4e draft](https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md) and [PsstPsst's implementation](https://github.com/CodyTseng/psstpsst) (`docs/protocols/nip4e.md` + `docs/protocols/nip17-extensions.md`, plus rust-nostr's NIP-59 source to verify crypto mechanics).