fix P0 issues in NIP-4e gift wrap handling
This commit is contained in:
+96
-9
@@ -12,6 +12,7 @@ use gpui::{
|
|||||||
WeakEntity,
|
WeakEntity,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
|
use nip4e::{DeviceEvent, DeviceRegistry};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP};
|
use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP};
|
||||||
@@ -138,17 +139,24 @@ impl ChatRegistry {
|
|||||||
let (tx, rx) = flume::unbounded::<Signal>();
|
let (tx, rx) = flume::unbounded::<Signal>();
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
subscriptions.push(
|
let device = DeviceRegistry::global(cx);
|
||||||
// Subscribe to the signer event
|
|
||||||
cx.subscribe(&nostr, |this, _nostr, event, cx| {
|
subscriptions.push(cx.subscribe(&nostr, |this, _nostr, event, cx| {
|
||||||
if event.signer_changed() {
|
if event.signer_changed() {
|
||||||
this.reset(cx);
|
this.reset(cx);
|
||||||
this.handle_notifications(cx);
|
this.handle_notifications(cx);
|
||||||
this.get_metadata(cx);
|
this.get_metadata(cx);
|
||||||
this.get_rooms(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| {
|
cx.defer(move |cx| {
|
||||||
let _ = entity.update(cx, |this, cx| {
|
let _ = entity.update(cx, |this, cx| {
|
||||||
@@ -183,6 +191,9 @@ impl ChatRegistry {
|
|||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
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 seen = self.seen.clone();
|
||||||
let event_map = self.event_map.clone();
|
let event_map = self.event_map.clone();
|
||||||
let trash = self.trash.downgrade();
|
let trash = self.trash.downgrade();
|
||||||
@@ -249,7 +260,14 @@ impl ChatRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract the rumor from the gift wrap event
|
// 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) => {
|
Ok(rumor) => {
|
||||||
let Some(rumor_id) = rumor.id else {
|
let Some(rumor_id) = rumor.id else {
|
||||||
log::error!("Rumor missing id after ensure_id");
|
log::error!("Rumor missing id after ensure_id");
|
||||||
@@ -385,6 +403,9 @@ impl ChatRegistry {
|
|||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
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| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
let public_key = signer.get_public_key_async().await?;
|
let public_key = signer.get_public_key_async().await?;
|
||||||
@@ -417,6 +438,20 @@ impl ChatRegistry {
|
|||||||
|
|
||||||
client.subscribe(target).with_id(id).await?;
|
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(())
|
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.
|
/// Reload the chat registry, fetching messages and contact list from relays.
|
||||||
pub fn reload(&mut self, cx: &mut Context<Self>) {
|
pub fn reload(&mut self, cx: &mut Context<Self>) {
|
||||||
self.reset(cx);
|
self.reset(cx);
|
||||||
@@ -740,21 +818,30 @@ impl ChatRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Unwraps a gift-wrapped event and processes its contents.
|
/// Unwraps a gift-wrapped event and processes its contents.
|
||||||
async fn extract_rumor<S>(
|
async fn extract_rumor<S, D>(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
signer: &S,
|
signer: &S,
|
||||||
|
device_signer: Option<&D>,
|
||||||
gift_wrap: &Event,
|
gift_wrap: &Event,
|
||||||
) -> Result<UnsignedEvent, Error>
|
) -> Result<UnsignedEvent, Error>
|
||||||
where
|
where
|
||||||
S: AsyncNip44 + ?Sized,
|
S: AsyncNip44 + ?Sized,
|
||||||
|
D: AsyncNip44 + ?Sized,
|
||||||
{
|
{
|
||||||
// Try to get cached rumor first
|
// Try to get cached rumor first
|
||||||
if let Ok(rumor) = get_rumor(client, gift_wrap.id).await {
|
if let Ok(rumor) = get_rumor(client, gift_wrap.id).await {
|
||||||
return Ok(rumor);
|
return Ok(rumor);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to unwrap with the available signer
|
let unwrapped = match try_unwrap_with(signer, gift_wrap).await {
|
||||||
let unwrapped = 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;
|
let mut rumor = unwrapped.rumor;
|
||||||
|
|
||||||
// Verify rumor author matches the seal sender (as per mobile implementation)
|
// Verify rumor author matches the seal sender (as per mobile implementation)
|
||||||
|
|||||||
+5
-66
@@ -3,7 +3,6 @@ use std::hash::{Hash, Hasher};
|
|||||||
|
|
||||||
use anyhow::{Error, anyhow};
|
use anyhow::{Error, anyhow};
|
||||||
use common::EventExt;
|
use common::EventExt;
|
||||||
use nip4e::DeviceRegistry;
|
|
||||||
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;
|
||||||
@@ -15,7 +14,6 @@ use state::{NostrRegistry, TIMEOUT};
|
|||||||
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 or it's not available.";
|
|
||||||
|
|
||||||
/// 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)]
|
||||||
@@ -472,35 +470,10 @@ impl Room {
|
|||||||
tags
|
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.
|
/// Send the rumor to every member's messaging relays.
|
||||||
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();
|
||||||
@@ -522,31 +495,14 @@ 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.into_iter().map(|receiver| {
|
let sends = receivers
|
||||||
deliver(
|
.into_iter()
|
||||||
&client,
|
.map(|receiver| deliver(&client, signer_kind, receiver, &rumor, &user_signer));
|
||||||
signer_kind,
|
|
||||||
receiver,
|
|
||||||
&rumor,
|
|
||||||
&encryption_signer,
|
|
||||||
&user_signer,
|
|
||||||
)
|
|
||||||
});
|
|
||||||
let mut reports: Vec<SendReport> = futures::future::join_all(sends).await;
|
let mut reports: Vec<SendReport> = futures::future::join_all(sends).await;
|
||||||
|
|
||||||
// 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() && reports.iter().any(SendReport::success) {
|
||||||
reports.push(
|
reports.push(deliver(&client, signer_kind, sender, &rumor, &user_signer).await);
|
||||||
deliver(
|
|
||||||
&client,
|
|
||||||
signer_kind,
|
|
||||||
sender,
|
|
||||||
&rumor,
|
|
||||||
&encryption_signer,
|
|
||||||
&user_signer,
|
|
||||||
)
|
|
||||||
.await,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
reports
|
reports
|
||||||
@@ -560,7 +516,6 @@ async fn deliver<S>(
|
|||||||
signer_kind: &SignerKind,
|
signer_kind: &SignerKind,
|
||||||
receiver: Person,
|
receiver: Person,
|
||||||
rumor: &UnsignedEvent,
|
rumor: &UnsignedEvent,
|
||||||
encryption_signer: &Option<S>,
|
|
||||||
user_signer: &S,
|
user_signer: &S,
|
||||||
) -> SendReport
|
) -> SendReport
|
||||||
where
|
where
|
||||||
@@ -577,25 +532,9 @@ 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()),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine the signer to use
|
match send_gift_wrap(client, user_signer, &receiver, rumor, signer_kind).await {
|
||||||
let signer = Room::select_signer(
|
|
||||||
signer_kind,
|
|
||||||
announcement.is_some(),
|
|
||||||
encryption_signer,
|
|
||||||
user_signer,
|
|
||||||
);
|
|
||||||
|
|
||||||
match send_gift_wrap(client, signer, &receiver, rumor, signer_kind).await {
|
|
||||||
Ok(output) => SendReport {
|
Ok(output) => SendReport {
|
||||||
receiver: public_key,
|
receiver: public_key,
|
||||||
result: Ok(output),
|
result: Ok(output),
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# NIP-4e Audit Report
|
# NIP-4e Audit Report
|
||||||
|
|
||||||
|
> **Update**: The P0 findings (C1, C2, C3) are now fixed — seals are always signed by the
|
||||||
|
> identity key, `extract_rumor` falls back to the device key, the `DEVICE_GIFTWRAP`
|
||||||
|
> subscription is created, and trashed wraps are retried on `DeviceEvent::Set`.
|
||||||
|
> The remaining findings (F1–F8, P2 items, UX bugs) are still open.
|
||||||
|
|
||||||
**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).
|
||||||
|
|
||||||
## Executive summary
|
## Executive summary
|
||||||
|
|||||||
Reference in New Issue
Block a user