nip4e: fix message encryption and sync #57
Generated
+1
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user