nip4e: fix message encryption and sync (#57)
Reviewed-on: #57
This commit was merged in pull request #57.
This commit is contained in:
@@ -7,7 +7,7 @@ publish.workspace = true
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
state = { path = "../state" }
|
||||
device = { path = "../device" }
|
||||
nip4e = { path = "../nip4e" }
|
||||
person = { path = "../person" }
|
||||
settings = { path = "../settings" }
|
||||
|
||||
@@ -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
|
||||
|
||||
+111
-16
@@ -12,6 +12,7 @@ use gpui::{
|
||||
WeakEntity,
|
||||
};
|
||||
use instant::Duration;
|
||||
use nip4e::{DeviceEvent, DeviceRegistry};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP};
|
||||
@@ -138,17 +139,24 @@ impl ChatRegistry {
|
||||
let (tx, rx) = flume::unbounded::<Signal>();
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
// Subscribe to the signer event
|
||||
cx.subscribe(&nostr, |this, _nostr, event, cx| {
|
||||
if event.signer_changed() {
|
||||
this.reset(cx);
|
||||
this.handle_notifications(cx);
|
||||
this.get_metadata(cx);
|
||||
this.get_rooms(cx);
|
||||
};
|
||||
}),
|
||||
);
|
||||
let device = DeviceRegistry::global(cx);
|
||||
|
||||
subscriptions.push(cx.subscribe(&nostr, |this, _nostr, event, cx| {
|
||||
if event.signer_changed() {
|
||||
this.reset(cx);
|
||||
this.handle_notifications(cx);
|
||||
this.get_metadata(cx);
|
||||
this.get_rooms(cx);
|
||||
};
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.subscribe(&device, |this, _device, event, cx| {
|
||||
if matches!(event, DeviceEvent::Set) {
|
||||
this.handle_notifications(cx);
|
||||
this.get_messages(cx);
|
||||
this.reprocess_trash(cx);
|
||||
}
|
||||
}));
|
||||
|
||||
cx.defer(move |cx| {
|
||||
let _ = entity.update(cx, |this, cx| {
|
||||
@@ -183,6 +191,9 @@ impl ChatRegistry {
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let device = DeviceRegistry::global(cx);
|
||||
let device_signer = device.read(cx).signer(cx);
|
||||
|
||||
let seen = self.seen.clone();
|
||||
let event_map = self.event_map.clone();
|
||||
let trash = self.trash.downgrade();
|
||||
@@ -249,7 +260,14 @@ impl ChatRegistry {
|
||||
}
|
||||
|
||||
// Extract the rumor from the gift wrap event
|
||||
match extract_rumor(&client, &signer, event.as_ref()).await {
|
||||
match extract_rumor(
|
||||
&client,
|
||||
&signer,
|
||||
device_signer.as_ref(),
|
||||
event.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(rumor) => {
|
||||
let Some(rumor_id) = rumor.id else {
|
||||
log::error!("Rumor missing id after ensure_id");
|
||||
@@ -385,6 +403,9 @@ impl ChatRegistry {
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let device = DeviceRegistry::global(cx);
|
||||
let device_signer = device.read(cx).signer(cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
@@ -417,6 +438,20 @@ impl ChatRegistry {
|
||||
|
||||
client.subscribe(target).with_id(id).await?;
|
||||
|
||||
// Also pick up gift wraps addressed to this account's decoupled encryption key
|
||||
if let Some(device_signer) = device_signer.as_ref() {
|
||||
let device_public_key = device_signer.get_public_key_async().await?;
|
||||
|
||||
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(device_public_key);
|
||||
let id = SubscriptionId::new(DEVICE_GIFTWRAP);
|
||||
|
||||
let target: HashMap<RelayUrl, Filter> = nip17::extract_relay_list(&event)
|
||||
.map(|relay| (relay, filter.clone()))
|
||||
.collect();
|
||||
|
||||
client.subscribe(target).with_id(id).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
@@ -430,6 +465,49 @@ impl ChatRegistry {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Retry unwrapping trashed gift wraps with the newly available encryption key.
|
||||
fn reprocess_trash(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
let device_signer = DeviceRegistry::global(cx).read(cx).signer(cx);
|
||||
|
||||
let failed: Vec<FailedMessage> = self.trash.read(cx).iter().cloned().collect();
|
||||
|
||||
if failed.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
for message in failed {
|
||||
let Ok(event) = Event::from_json(message.raw_event.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if extract_rumor(&client, &signer, device_signer.as_ref(), &event)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(rumor) = get_rumor(&client, event.id).await else {
|
||||
continue;
|
||||
};
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.trash.update(cx, |trash, cx| {
|
||||
trash.remove(&message);
|
||||
cx.notify();
|
||||
});
|
||||
this.new_message(NewMessage::new(event.id, rumor), cx);
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Reload the chat registry, fetching messages and contact list from relays.
|
||||
pub fn reload(&mut self, cx: &mut Context<Self>) {
|
||||
self.reset(cx);
|
||||
@@ -740,21 +818,30 @@ impl ChatRegistry {
|
||||
}
|
||||
|
||||
/// Unwraps a gift-wrapped event and processes its contents.
|
||||
async fn extract_rumor<S>(
|
||||
async fn extract_rumor<S, D>(
|
||||
client: &Client,
|
||||
signer: &S,
|
||||
device_signer: Option<&D>,
|
||||
gift_wrap: &Event,
|
||||
) -> Result<UnsignedEvent, Error>
|
||||
where
|
||||
S: AsyncNip44 + ?Sized,
|
||||
D: AsyncNip44 + ?Sized,
|
||||
{
|
||||
// Try to get cached rumor first
|
||||
if let Ok(rumor) = get_rumor(client, gift_wrap.id).await {
|
||||
return Ok(rumor);
|
||||
}
|
||||
|
||||
// Try to unwrap with the available signer
|
||||
let unwrapped = try_unwrap_with(signer, gift_wrap).await?;
|
||||
let unwrapped = match try_unwrap_with(signer, gift_wrap).await {
|
||||
Ok(unwrapped) => unwrapped,
|
||||
Err(error) => {
|
||||
let Some(device_signer) = device_signer else {
|
||||
return Err(error);
|
||||
};
|
||||
try_unwrap_with(device_signer, gift_wrap).await?
|
||||
}
|
||||
};
|
||||
let mut rumor = unwrapped.rumor;
|
||||
|
||||
// Verify rumor author matches the seal sender (as per mobile implementation)
|
||||
@@ -787,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)?;
|
||||
|
||||
+71
-41
@@ -3,19 +3,19 @@ use std::hash::{Hash, Hasher};
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
use common::EventExt;
|
||||
use device::DeviceRegistry;
|
||||
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 or it's not available.";
|
||||
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)]
|
||||
@@ -472,28 +472,6 @@ impl Room {
|
||||
tags
|
||||
}
|
||||
|
||||
/// Select the appropriate signer based on signer kind and available keys.
|
||||
fn select_signer<'a, S>(
|
||||
signer_kind: &SignerKind,
|
||||
has_announcement: bool,
|
||||
encryption_signer: &'a Option<S>,
|
||||
user_signer: &'a S,
|
||||
) -> &'a S {
|
||||
match signer_kind {
|
||||
SignerKind::Auto => {
|
||||
if has_announcement {
|
||||
encryption_signer.as_ref().unwrap_or(user_signer)
|
||||
} else {
|
||||
user_signer
|
||||
}
|
||||
}
|
||||
SignerKind::Encryption => encryption_signer
|
||||
.as_ref()
|
||||
.expect("encryption signer must be set"),
|
||||
SignerKind::User => user_signer,
|
||||
}
|
||||
}
|
||||
|
||||
/// Send the rumor to every member's messaging relays.
|
||||
pub fn send(&self, rumor: UnsignedEvent, cx: &App) -> Option<Task<Vec<SendReport>>> {
|
||||
let config = self.config.clone();
|
||||
@@ -528,21 +506,23 @@ impl Room {
|
||||
signer_kind,
|
||||
receiver,
|
||||
&rumor,
|
||||
&encryption_signer,
|
||||
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) {
|
||||
if config.backup() && minimum_success {
|
||||
reports.push(
|
||||
deliver(
|
||||
&client,
|
||||
signer_kind,
|
||||
sender,
|
||||
&rumor,
|
||||
&encryption_signer,
|
||||
encryption_signer.as_ref(),
|
||||
&user_signer,
|
||||
)
|
||||
.await,
|
||||
@@ -560,7 +540,7 @@ async fn deliver<S>(
|
||||
signer_kind: &SignerKind,
|
||||
receiver: Person,
|
||||
rumor: &UnsignedEvent,
|
||||
encryption_signer: &Option<S>,
|
||||
encryption_signer: Option<&UniversalSigner>,
|
||||
user_signer: &S,
|
||||
) -> SendReport
|
||||
where
|
||||
@@ -587,15 +567,16 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the signer to use
|
||||
let signer = Room::select_signer(
|
||||
match send_gift_wrap(
|
||||
client,
|
||||
&receiver,
|
||||
rumor,
|
||||
signer_kind,
|
||||
announcement.is_some(),
|
||||
encryption_signer,
|
||||
user_signer,
|
||||
);
|
||||
|
||||
match send_gift_wrap(client, signer, &receiver, rumor, signer_kind).await {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(output) => SendReport {
|
||||
receiver: public_key,
|
||||
result: Ok(output),
|
||||
@@ -610,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,
|
||||
@@ -639,11 +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)
|
||||
.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 has_encryption_announcement = receiver.announcement().is_some();
|
||||
|
||||
let seal: Event = match (encryption_signer, has_encryption_announcement) {
|
||||
(Some(encryption_signer), true) => {
|
||||
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?
|
||||
}
|
||||
_ => {
|
||||
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
|
||||
@@ -652,3 +674,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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user