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",
"nostr-sdk",
"person",
"rand 0.10.3",
"settings",
"smallvec",
"smol",
+1 -1
View File
@@ -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"
+1
View File
@@ -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
+9 -1
View File
@@ -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)?;
+102 -12
View File
@@ -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<Task<Vec<SendReport>>> {
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<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
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<S>(
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<S>(
client: &Client,
signer: &S,
receiver: &Person,
rumor: &UnsignedEvent,
config: &SignerKind,
encryption_signer: Option<&UniversalSigner>,
user_signer: &S,
) -> Result<Output<EventId, EventSendStatus>, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44,
@@ -578,12 +621,51 @@ 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)
// 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
.send_event(&event)
@@ -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))
}
+12
View File
@@ -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).