nip4e: fix message encryption and sync #57
Generated
+1
@@ -1083,6 +1083,7 @@ dependencies = [
|
||||
"nip4e",
|
||||
"nostr-sdk",
|
||||
"person",
|
||||
"rand 0.10.3",
|
||||
"settings",
|
||||
"smallvec",
|
||||
"smol",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user