nip4e: fix message encryption and sync #57

Merged
reya merged 10 commits from fix-nip4e into master 2026-10-04 06:33:33 +00:00
6 changed files with 127 additions and 15 deletions
Showing only changes of commit 758ffaf7d9 - Show all commits
Generated
+1
View File
@@ -1083,6 +1083,7 @@ dependencies = [
"nip4e", "nip4e",
"nostr-sdk", "nostr-sdk",
"person", "person",
"rand 0.10.3",
"settings", "settings",
"smallvec", "smallvec",
"smol", "smol",
+1 -1
View File
@@ -44,7 +44,7 @@ hkdf = "0.12"
chacha20 = "0.9" chacha20 = "0.9"
hmac = "0.12" hmac = "0.12"
# Pinned to the instance `nostr-sdk` already builds, so NIP-44 nonces share it # 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 # Others
anyhow = "1.0.44" anyhow = "1.0.44"
+1
View File
@@ -17,6 +17,7 @@ instant.workspace = true
anyhow.workspace = true anyhow.workspace = true
itertools.workspace = true itertools.workspace = true
rand.workspace = true
smallvec.workspace = true smallvec.workspace = true
log.workspace = true log.workspace = true
flume.workspace = true flume.workspace = true
+9 -1
View File
@@ -874,9 +874,17 @@ where
let seal: Event = Event::from_json(seal)?; let seal: Event = Event::from_json(seal)?;
seal.verify()?; 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 // Get the rumor event
let rumor = signer let rumor = signer
.nip44_decrypt_async(&seal.pubkey, &seal.content) .nip44_decrypt_async(&sender_encryption_key, &seal.content)
.await?; .await?;
let rumor = UnsignedEvent::from_json(rumor)?; let rumor = UnsignedEvent::from_json(rumor)?;
+103 -13
View File
@@ -6,14 +6,16 @@ use common::EventExt;
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task}; use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
use instant::Duration; use instant::Duration;
use itertools::Itertools; use itertools::Itertools;
use nip4e::DeviceRegistry;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry}; use person::{Person, PersonRegistry};
use settings::{RoomConfig, SignerKind}; use settings::{RoomConfig, SignerKind};
use state::{NostrRegistry, TIMEOUT}; use state::{NostrRegistry, TIMEOUT, UniversalSigner};
use crate::{FileAttachment, KIND_FILE_MESSAGE, NewMessage}; use crate::{FileAttachment, KIND_FILE_MESSAGE, NewMessage};
const NO_DEKEY: &str = "User hasn't set up a decoupled encryption key yet."; 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. /// The outcome of delivering an encrypted rumor to a single receiver.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -474,6 +476,9 @@ impl Room {
pub fn send(&self, rumor: UnsignedEvent, cx: &App) -> Option<Task<Vec<SendReport>>> { pub fn send(&self, rumor: UnsignedEvent, cx: &App) -> Option<Task<Vec<SendReport>>> {
let config = self.config.clone(); let config = self.config.clone();
let device = DeviceRegistry::global(cx);
let encryption_signer = device.read(cx).signer(cx);
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let user_signer = nostr.read(cx).signer(); let user_signer = nostr.read(cx).signer();
@@ -495,14 +500,33 @@ impl Room {
let signer_kind = config.signer_kind(); let signer_kind = config.signer_kind();
// Deliver to every receiver concurrently // Deliver to every receiver concurrently
let sends = receivers let sends = receivers.into_iter().map(|receiver| {
.into_iter() deliver(
.map(|receiver| deliver(&client, signer_kind, receiver, &rumor, &user_signer)); &client,
signer_kind,
receiver,
&rumor,
encryption_signer.as_ref(),
&user_signer,
)
});
let mut reports: Vec<SendReport> = futures::future::join_all(sends).await; let mut reports: Vec<SendReport> = 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 // Back up the message to ourselves once at least one receiver got it
if config.backup() && reports.iter().any(SendReport::success) { if config.backup() && minimum_success {
reports.push(deliver(&client, signer_kind, sender, &rumor, &user_signer).await); reports.push(
deliver(
&client,
signer_kind,
sender,
&rumor,
encryption_signer.as_ref(),
&user_signer,
)
.await,
);
} }
reports reports
@@ -516,6 +540,7 @@ async fn deliver<S>(
signer_kind: &SignerKind, signer_kind: &SignerKind,
receiver: Person, receiver: Person,
rumor: &UnsignedEvent, rumor: &UnsignedEvent,
encryption_signer: Option<&UniversalSigner>,
user_signer: &S, user_signer: &S,
) -> SendReport ) -> SendReport
where where
@@ -532,9 +557,26 @@ where
result: Err(NO_DEKEY.into()), 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 { Ok(output) => SendReport {
receiver: public_key, receiver: public_key,
result: Ok(output), result: Ok(output),
@@ -549,10 +591,11 @@ where
/// Build the gift-wrapped event for a rumor and send it. /// Build the gift-wrapped event for a rumor and send it.
async fn send_gift_wrap<S>( async fn send_gift_wrap<S>(
client: &Client, client: &Client,
signer: &S,
receiver: &Person, receiver: &Person,
rumor: &UnsignedEvent, rumor: &UnsignedEvent,
config: &SignerKind, config: &SignerKind,
encryption_signer: Option<&UniversalSigner>,
user_signer: &S,
) -> Result<Output<EventId, EventSendStatus>, Error> ) -> Result<Output<EventId, EventSendStatus>, Error>
where where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44, S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44,
@@ -578,11 +621,50 @@ where
SignerKind::User => receiver.public_key(), SignerKind::User => receiver.public_key(),
}; };
// Construct the gift wrap event // Make sure the rumor carries an event ID before it's serialized
let event = nip59::GiftWrapBuilder::new(receiver_key, rumor.clone()) let mut rumor = rumor.clone();
.extra_tags(extra_tags) rumor.ensure_id();
.finalize_async(signer)
.await?; 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. // Send to the receiver's NIP-17 relays.
client client
@@ -591,3 +673,11 @@ where
.await .await
.map_err(Into::into) .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))
}
+12
View File
@@ -30,6 +30,18 @@
> notification, the requester in the "Wait for approval" notification — so the > notification, the requester in the "Wait for approval" notification — so the
> transfer can be verified out of band before approving. Still open: the > transfer can be verified out of band before approving. Still open: the
> plaintext backup file in section 4. > 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). **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).