7 Commits
17 changed files with 584 additions and 269 deletions
Generated
+24 -24
View File
@@ -1073,7 +1073,6 @@ version = "1.0.2"
dependencies = [
"anyhow",
"common",
"device",
"flume 0.11.1",
"futures",
"fuzzy-matcher",
@@ -1081,6 +1080,7 @@ dependencies = [
"instant",
"itertools 0.13.0",
"log",
"nip4e",
"nostr-sdk",
"person",
"settings",
@@ -1413,7 +1413,6 @@ dependencies = [
"auto_update",
"chat",
"common",
"device",
"gpui-pre",
"gpui-pre-linux",
"gpui-pre-macos",
@@ -1422,6 +1421,7 @@ dependencies = [
"gpui-pre-windows",
"log",
"nip29",
"nip4e",
"nostr-sdk",
"person",
"settings",
@@ -1441,7 +1441,6 @@ dependencies = [
"common",
"console_error_panic_hook",
"console_log",
"device",
"getrandom 0.2.17",
"getrandom 0.3.4",
"getrandom 0.4.3",
@@ -1451,6 +1450,7 @@ dependencies = [
"instant",
"log",
"nip29",
"nip4e",
"person",
"settings",
"state",
@@ -1823,26 +1823,6 @@ dependencies = [
"unicode-xid",
]
[[package]]
name = "device"
version = "1.0.2"
dependencies = [
"anyhow",
"common",
"flume 0.11.1",
"gpui-pre",
"instant",
"log",
"nostr-sdk",
"person",
"settings",
"smallvec",
"smol",
"state",
"theme",
"ui",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -4577,6 +4557,26 @@ dependencies = [
"ui",
]
[[package]]
name = "nip4e"
version = "1.0.2"
dependencies = [
"anyhow",
"common",
"flume 0.11.1",
"gpui-pre",
"instant",
"log",
"nostr-sdk",
"person",
"settings",
"smallvec",
"smol",
"state",
"theme",
"ui",
]
[[package]]
name = "no_std_io2"
version = "0.9.4"
@@ -9438,12 +9438,12 @@ dependencies = [
"chat",
"chat_ui",
"common",
"device",
"gpui-pre",
"instant",
"log",
"nip29",
"nip29_ui",
"nip4e",
"nostr-connect",
"nostr-sdk",
"person",
+1 -1
View File
@@ -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" }
+102 -15
View File
@@ -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)
+5 -66
View File
@@ -3,7 +3,6 @@ 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;
@@ -15,7 +14,6 @@ use state::{NostrRegistry, TIMEOUT};
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.";
/// The outcome of delivering an encrypted rumor to a single receiver.
#[derive(Debug, Clone)]
@@ -472,35 +470,10 @@ 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();
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();
@@ -522,31 +495,14 @@ 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,
&encryption_signer,
&user_signer,
)
});
let sends = receivers
.into_iter()
.map(|receiver| deliver(&client, signer_kind, receiver, &rumor, &user_signer));
let mut reports: Vec<SendReport> = futures::future::join_all(sends).await;
// 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,
&encryption_signer,
&user_signer,
)
.await,
);
reports.push(deliver(&client, signer_kind, sender, &rumor, &user_signer).await);
}
reports
@@ -560,7 +516,6 @@ async fn deliver<S>(
signer_kind: &SignerKind,
receiver: Person,
rumor: &UnsignedEvent,
encryption_signer: &Option<S>,
user_signer: &S,
) -> SendReport
where
@@ -577,25 +532,9 @@ 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()),
};
}
}
// Determine the signer to use
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 {
match send_gift_wrap(client, user_signer, &receiver, rumor, signer_kind).await {
Ok(output) => SendReport {
receiver: public_key,
result: Ok(output),
+37 -8
View File
@@ -126,6 +126,7 @@ impl GroupsRegistry {
if event.signer_changed() {
this.reset(cx);
this.handle_notifications(cx);
this.load_cached(cx);
this.load(cx);
}
}));
@@ -133,6 +134,7 @@ impl GroupsRegistry {
cx.defer(move |cx| {
if let Err(error) = this.update(cx, |this, cx| {
this.handle_notifications(cx);
this.load_cached(cx);
if nostr.read(cx).current_user().is_some() {
this.load(cx);
@@ -349,20 +351,21 @@ impl GroupsRegistry {
cx.notify();
}
fn load(&mut self, cx: &mut Context<Self>) {
fn load_cached(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
if nostr.read(cx).current_user().is_none() {
return;
}
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
// Show the cached groups before the relays answer.
let task: Task<Result<GroupList, Error>> = cx.background_spawn(async move {
let me = signer.get_public_key_async().await?;
let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1);
client
.subscribe(ReqTarget::auto([filter.clone()]))
.with_id(list_subscription_id())
.await?;
match client.database().query(filter).await?.into_iter().next() {
Some(event) => GroupList::parse(&event),
None => Ok(GroupList::default()),
@@ -373,11 +376,37 @@ impl GroupsRegistry {
.push(cx.spawn(async move |this, cx| match task.await {
Ok(list) => {
if let Err(error) = this.update(cx, |this, cx| this.merge(list, cx)) {
log::warn!("nip29: applying the group list failed: {error}");
log::warn!("nip29: applying the cached group list failed: {error}");
}
}
Err(error) => log::warn!("nip29: loading the group list failed: {error}"),
Err(error) => log::warn!("nip29: loading the cached group list failed: {error}"),
}));
}
fn load(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let updates: Task<Result<(), Error>> = cx.background_spawn(async move {
let me = signer.get_public_key_async().await?;
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1);
client
.subscribe(ReqTarget::auto([filter]))
.with_id(list_subscription_id())
.close_on(opts)
.await?;
Ok(())
});
self.tasks.push(cx.spawn(async move |_this, _cx| {
if let Err(error) = updates.await {
log::warn!("nip29: subscribing to the group list failed: {error}");
}
}));
self.watch_forks(cx);
}
@@ -1,5 +1,5 @@
[package]
name = "device"
name = "nip4e"
version.workspace = true
edition.workspace = true
publish.workspace = true
@@ -10,7 +10,6 @@ use gpui::{
App, AppContext, Context, Entity, EventEmitter, Global, IntoElement, ParentElement,
SharedString, Styled, Subscription, Task, Window, div, relative,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use settings::AppSettings;
@@ -40,7 +39,7 @@ pub enum DeviceEvent {
/// User have not setup encryption key
NotSet,
/// The device is requesting an encryption key
Requesting,
Requesting(SharedString),
/// An error occurred
Error(SharedString),
}
@@ -59,19 +58,14 @@ impl DeviceEvent {
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
#[derive(Debug)]
pub struct DeviceRegistry {
signer: Entity<Option<UniversalSigner>>,
/// Whether there is a pending request for encryption key approval
pub pending_request: bool,
/// Whether an announcement has been made for this device
pub announcement_existed: Arc<AtomicBool>,
/// Signer
signer: Entity<Option<UniversalSigner>>,
/// Async tasks
/// Relay notification listener
notification_listener: Option<Task<Result<(), Error>>>,
tasks: Vec<Task<Result<(), Error>>>,
/// Event subscription
_subscriptions: SmallVec<[Subscription; 2]>,
}
@@ -110,6 +104,9 @@ impl DeviceRegistry {
// Observe the user signer
cx.subscribe(&nostr, move |this, _nostr, event, cx| {
if event.signer_changed() && settings.read(cx).is_nip4e_enabled(cx) {
// The old account's state must not leak into the new one
this.announcement_existed.store(false, Ordering::Relaxed);
this.handle_notifications(cx);
this.get_announcement(cx);
}
}),
@@ -125,12 +122,15 @@ impl DeviceRegistry {
signer,
pending_request: false,
announcement_existed: Arc::new(AtomicBool::new(false)),
notification_listener: None,
tasks: vec![],
_subscriptions: subscriptions,
}
}
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
self.notification_listener = None;
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
@@ -138,7 +138,7 @@ impl DeviceRegistry {
let announcement_existed = self.announcement_existed.clone();
let (tx, rx) = flume::bounded::<Event>(100);
self.tasks.push(cx.background_spawn(async move {
self.notification_listener = Some(cx.background_spawn(async move {
let mut notifications = client.notifications();
let mut processed_events = HashSet::new();
let current_user = signer.get_public_key_async().await.ok();
@@ -245,39 +245,41 @@ impl DeviceRegistry {
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
self.tasks.push(cx.background_spawn(async move {
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let current_user = signer.get_public_key_async().await?;
// Construct the filter for the device announcement event
let filter = Filter::new()
.kind(Kind::Custom(10044))
.author(current_user)
.limit(1);
client
.subscribe(filter)
.close_on(opts)
.with_id(SubscriptionId::new("nip4e"))
.await?;
Ok(())
}));
let announcement_existed = self.announcement_existed.clone();
self.tasks.push(cx.spawn(async move |this, cx| {
// Wait for 5 seconds
cx.background_executor().timer(Duration::from_secs(5)).await;
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let opts =
SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let current_user = signer.get_public_key_async().await?;
// Then check if the msg relays have been found
if announcement_existed.load(Ordering::Acquire) {
// Construct the filter for the device announcement event
let filter = Filter::new()
.kind(Kind::Custom(10044))
.author(current_user)
.limit(1);
client
.subscribe(filter)
.close_on(opts)
.with_id(SubscriptionId::new("nip4e"))
.await?;
Ok(())
});
if let Err(e) = task.await {
this.update(cx, |_this, cx| {
cx.emit(DeviceEvent::error(e.to_string()));
})?;
return Ok(());
}
this.update(cx, |_this, cx| {
cx.emit(DeviceEvent::NotSet);
})?;
if !announcement_existed.load(Ordering::Acquire) {
this.update(cx, |_this, cx| {
cx.emit(DeviceEvent::NotSet);
})?;
}
Ok(())
}));
@@ -317,13 +319,13 @@ impl DeviceRegistry {
cx.background_spawn(async move {
// Construct an announcement event
let event = EventBuilder::new(Kind::Custom(10044), "")
.tags(vec![
Tag::custom("n", vec![n]),
Tag::custom("client", vec![CLIENT_NAME]),
])
.tag(Tag::custom("n", vec![n]))
.finalize_async(&signer)
.await?;
// Persist the key locally before announcing it
set_keys(&client, &signer, &secret).await?;
// Publish announcement
client
.send_event(&event)
@@ -331,20 +333,21 @@ impl DeviceRegistry {
.ack_policy(AckPolicy::none())
.await?;
// Save device keys to the database
set_keys(&client, &signer, &secret).await?;
Ok(keys)
})
}
/// Set encryption key from the announcement event
fn set_encryption(&mut self, event: &Event, cx: &mut Context<Self>) {
let Some(announcement) = Announcement::from_event(event) else {
log::warn!("Ignoring malformed encryption announcement");
return;
};
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let announcement = Announcement::from(event);
let device_pubkey = announcement.public_key();
// Get encryption key from the database and compare with the announcement
@@ -384,11 +387,8 @@ impl DeviceRegistry {
let public_key = signer.get_public_key_async().await?;
let id = SubscriptionId::new("dekey-requests");
// Construct a filter for encryption key requests
let filter = Filter::new()
.kind(Kind::Custom(4454))
.author(public_key)
.since(Timestamp::now());
// Construct a filter for encryption key requests.
let filter = Filter::new().kind(Kind::Custom(4454)).author(public_key);
// Subscribe to the device key requests on user's write relays
client.subscribe(vec![filter]).with_id(id).await?;
@@ -417,26 +417,27 @@ impl DeviceRegistry {
.pubkey(app_pubkey)
.limit(1);
match client.database().query(filter).await?.into_iter().next() {
// Found an approval event
Some(event) => Ok(Some(event)),
// No approval event found, construct a request event
None => {
// Construct an event for device key request
let event = EventBuilder::new(Kind::Custom(4454), "")
.tags(vec![
Tag::custom("P", vec![app_pubkey]),
Tag::custom("client", vec![CLIENT_NAME]),
])
.finalize_async(&signer)
.await?;
// Send the event to write relays
client.send_event(&event).to_nip65().await?;
Ok(None)
if let Some(event) = client.database().query(filter).await?.into_iter().next() {
// Only reuse the stored approval if its key is still valid,
// otherwise it's stale and a fresh request is needed
if decrypt_approval(&client, &app_keys, public_key, &event)
.await
.is_ok()
{
return Ok(Some(event));
}
}
// No usable approval event found, construct a request event
let event = EventBuilder::new(Kind::Custom(4454), "")
.tags(vec![Tag::custom("P", vec![app_pubkey])])
.finalize_async(&signer)
.await?;
// Send the event to write relays
client.send_event(&event).to_nip65().await?;
Ok(None)
});
self.tasks.push(cx.spawn(async move |this, cx| {
@@ -466,20 +467,45 @@ impl DeviceRegistry {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let app_keys_task = get_or_init_app_keys(cx);
cx.emit(DeviceEvent::Requesting);
self.tasks.push(cx.spawn(async move |this, cx| {
let app_keys = match app_keys_task.await {
Ok(app_keys) => app_keys,
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(DeviceEvent::error(e.to_string()));
})?;
return Ok(());
}
};
self.tasks.push(cx.background_spawn(async move {
let public_key = signer.get_public_key_async().await?;
this.update(cx, |_this, cx| {
cx.emit(DeviceEvent::Requesting(authorization_code(
&app_keys.public_key(),
)));
})?;
// Construct a filter for device key requests
let filter = Filter::new()
.kind(Kind::Custom(4455))
.author(public_key)
.since(Timestamp::now());
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let public_key = signer.get_public_key_async().await?;
// Subscribe to the device key requests on user's write relays
client.subscribe(filter).await?;
// Only approvals addressed to this device's app key matter
let filter = Filter::new()
.kind(Kind::Custom(4455))
.author(public_key)
.pubkey(app_keys.public_key());
// Subscribe to the device key approvals on user's write relays
client.subscribe(filter).await?;
Ok(())
});
if let Err(e) = task.await {
this.update(cx, |_this, cx| {
cx.emit(DeviceEvent::error(e.to_string()));
})?;
}
Ok(())
}));
@@ -488,22 +514,16 @@ impl DeviceRegistry {
/// Parse the approval event to get encryption key then set it
fn extract_encryption(&mut self, event: Event, cx: &mut Context<Self>) {
let app_keys_task = get_or_init_app_keys(cx);
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let approval_id = event.id;
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
let app_keys = app_keys_task.await?;
let master = event
.tags
.iter()
.find(|tag| tag.kind() == "P")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Invalid event's tags")?;
let payload = event.content.as_str();
let decrypted = app_keys.nip44_decrypt_async(&master, payload).await?;
let secret = SecretKey::from_hex(&decrypted)?;
let keys = Keys::new(secret);
let identity = signer.get_public_key_async().await?;
let keys = decrypt_approval(&client, &app_keys, identity, &event).await?;
Ok(keys)
});
@@ -513,6 +533,8 @@ impl DeviceRegistry {
Ok(keys) => {
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
// Clean up our request and the approval event from relays
this.cleanup_sync_events(approval_id, cx);
})?;
}
Err(e) => {
@@ -525,6 +547,47 @@ impl DeviceRegistry {
}));
}
/// Delete our key request and the consumed approval event from relays
fn cleanup_sync_events(&mut self, approval_id: EventId, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
self.tasks.push(cx.background_spawn(async move {
let result: Result<(), Error> = async {
let public_key = signer.get_public_key_async().await?;
// Collect our key request events
let filter = Filter::new().kind(Kind::Custom(4454)).author(public_key);
let mut ids: Vec<EventId> = client
.database()
.query(filter)
.await?
.into_iter()
.map(|event| event.id)
.collect();
ids.push(approval_id);
let deletion = EventBuilder::new(Kind::EventDeletion, "")
.tags(Tags::from_list(ids.into_iter().map(Tag::event).collect()))
.finalize_async(&signer)
.await?;
client.send_event(&deletion).to_nip65().await?;
Ok(())
}
.await;
if let Err(e) = result {
log::error!("Failed to delete sync events: {e}");
}
Ok(())
}));
}
/// Approve requests for device keys from other devices
fn approve(&mut self, event: &Event, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
@@ -570,9 +633,14 @@ impl DeviceRegistry {
Ok(())
});
self.tasks.push(cx.spawn_in(window, async move |_this, cx| {
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(_) => {
this.update(cx, |this, cx| {
// Allow future requests to surface again
this.set_pending_request(false, cx);
})?;
cx.update(|window, cx| {
window.clear_notification_by_id::<DeviceNotification>(id, cx);
})
@@ -601,11 +669,15 @@ impl DeviceRegistry {
}
self.set_pending_request(true, cx);
// Show notification
let notification = self.notification(event, cx);
let notification = match self.notification(event, cx) {
Ok(notification) => notification,
Err(e) => {
log::warn!("Ignoring invalid encryption key request: {e}");
self.set_pending_request(false, cx);
return;
}
};
// The registry is global and not bound to a window, so surface the
// request in an open window.
if let Some(window) = cx.windows().first().copied() {
if let Err(error) = window.update(cx, |_view, window, cx| {
window.push_notification(notification, cx);
@@ -618,19 +690,30 @@ impl DeviceRegistry {
}
/// Build a notification for the encryption request.
fn notification(&self, event: Event, cx: &Context<Self>) -> Notification {
fn notification(&self, event: Event, cx: &Context<Self>) -> Result<Notification, Error> {
const MSG: &str = "You've requested an encryption key from another device. \
Approve to allow Coop to share with it.";
Approve to allow Coop to share with it. Before approving, \
compare the code with the one shown on the requesting \
device.";
let request = Announcement::from(&event);
let requester = event
.tags
.iter()
.find(|tag| tag.kind() == "P")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Request has no valid requester key")?;
let code = authorization_code(&requester);
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&request.public_key(), cx);
let profile = persons.read(cx).get(&requester, cx);
let code_label = code.clone();
let entity = cx.entity().downgrade();
let loading = Rc::new(Cell::new(false));
let key = SharedString::from(event.id.to_hex());
Notification::new()
Ok(Notification::new()
.type_id::<DeviceNotification>(key)
.autohide(false)
.with_kind(NotificationKind::Info)
@@ -687,7 +770,7 @@ impl DeviceRegistry {
.font_semibold()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Client:")),
.child(SharedString::from("Code:")),
)
.child(
div()
@@ -696,7 +779,7 @@ impl DeviceRegistry {
.px_2()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.child(request.client_name()),
.child(code_label.clone()),
),
),
)
@@ -722,12 +805,19 @@ impl DeviceRegistry {
.ok();
}
})
})
}))
}
}
struct DeviceNotification;
/// The first eight hexadecimal characters of the client public key.
fn authorization_code(public_key: &PublicKey) -> SharedString {
let hex = public_key.to_hex();
let code = hex[..8].to_uppercase();
SharedString::from(format!("{} {}", &code[..4], &code[4..]))
}
/// Get or create new app keys (async, returns a task)
fn get_or_init_app_keys(cx: &App) -> Task<Result<Keys, Error>> {
let read = cx.read_credentials(CLIENT_NAME);
@@ -758,6 +848,51 @@ fn get_or_init_app_keys(cx: &App) -> Task<Result<Keys, Error>> {
})
}
/// Decrypt and validate an approval event against the current announcement.
async fn decrypt_approval(
client: &Client,
app_keys: &Keys,
identity: PublicKey,
event: &Event,
) -> Result<Keys, Error> {
let master = event
.tags
.iter()
.find(|tag| tag.kind() == "P")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Invalid event's tags")?;
let decrypted = app_keys
.nip44_decrypt_async(&master, event.content.as_str())
.await?;
let secret = SecretKey::from_hex(&decrypted)?;
let keys = Keys::new(secret);
// Reject approvals carrying a key that no longer matches the published announcement.
let filter = Filter::new()
.kind(Kind::Custom(10044))
.author(identity)
.limit(1);
if let Some(announcement) = client.database().query(filter).await?.into_iter().next() {
let announced = announcement
.tags
.iter()
.find(|tag| tag.kind() == "n")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Announcement has no valid encryption key")?;
if keys.public_key() != announced {
return Err(anyhow!("Approval key doesn't match the announcement"));
}
}
Ok(keys)
}
/// Encrypt and store device keys in the local database.
async fn set_keys<S>(client: &Client, signer: &S, secret: &str) -> Result<(), Error>
where
+4 -1
View File
@@ -216,7 +216,10 @@ impl PersonRegistry {
/// Set profile encryption keys announcement
fn set_announcement(&mut self, event: &Event, cx: &mut App) {
let announcement = Announcement::from(event);
let Some(announcement) = Announcement::from_event(event) else {
log::warn!("Ignoring announcement with missing or invalid key tag");
return;
};
if let Some(person) = self.persons.get(&event.pubkey) {
person.update(cx, |person, cx| {
+10 -32
View File
@@ -1,4 +1,3 @@
use gpui::SharedString;
use nostr_sdk::prelude::*;
/// Announcement
@@ -6,50 +5,29 @@ use nostr_sdk::prelude::*;
pub struct Announcement {
/// The public key of the device that created this announcement.
public_key: PublicKey,
/// The name of the device that created this announcement.
client_name: Option<String>,
}
impl From<&Event> for Announcement {
fn from(val: &Event) -> Self {
let public_key = val
impl Announcement {
/// Parse an announcement from an event, returning `None` when the `n`
/// tag is missing or malformed so callers never mistake the author's
/// identity key for an encryption key.
pub fn from_event(event: &Event) -> Option<Self> {
let public_key = event
.tags
.iter()
.find(|tag| tag.kind() == "n")
.and_then(|tag| tag.content())
.and_then(|c| PublicKey::parse(c).ok())
.unwrap_or(val.pubkey);
.and_then(|c| PublicKey::parse(c).ok())?;
let client_name = val
.tags
.iter()
.find(|tag| tag.kind() == "client")
.and_then(|tag| tag.content())
.map(|c| c.to_string());
Self::new(public_key, client_name)
Some(Self { public_key })
}
}
impl Announcement {
pub fn new(public_key: PublicKey, client_name: Option<String>) -> Self {
Self {
public_key,
client_name,
}
pub fn new(public_key: PublicKey) -> Self {
Self { public_key }
}
/// Returns the public key of the device that created this announcement.
pub fn public_key(&self) -> PublicKey {
self.public_key
}
/// Returns the client name of the device that created this announcement.
pub fn client_name(&self) -> SharedString {
self.client_name
.as_ref()
.map(SharedString::from)
.unwrap_or(SharedString::from("Unknown"))
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ ui = { path = "../ui" }
theme = { path = "../theme" }
common = { path = "../common" }
state = { path = "../state" }
device = { path = "../device" }
nip4e = { path = "../nip4e" }
chat = { path = "../chat" }
chat_ui = { path = "../chat_ui" }
nip29 = { path = "../nip29" }
+4 -4
View File
@@ -1,12 +1,11 @@
use instant::Duration;
use anyhow::Error;
use device::DeviceRegistry;
use gpui::prelude::FluentBuilder;
use gpui::{
AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Subscription, Task, Window, div,
};
use instant::Duration;
use nip4e::DeviceRegistry;
use nostr_sdk::prelude::*;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
@@ -52,8 +51,9 @@ impl RestoreEncryption {
let device = DeviceRegistry::global(cx);
let content = self.key_input.read(cx).value();
if !content.is_empty() {
if content.is_empty() {
self.set_error("Secret Key cannot be empty.", cx);
return;
}
let Ok(secret) = SecretKey::parse(&content) else {
+10 -8
View File
@@ -5,11 +5,11 @@ use anyhow::Error;
use auto_update::AutoUpdater;
use chat::{ChatEvent, ChatRegistry};
use common::download_dir;
use device::{DeviceEvent, DeviceRegistry};
use gpui::{
Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
Render, SharedString, Styled, Subscription, Task, Window, div, px,
};
use nip4e::{DeviceEvent, DeviceRegistry};
use nip29::{GroupId, GroupKey};
use nostr_sdk::prelude::*;
use serde::Deserialize;
@@ -106,22 +106,24 @@ impl Workspace {
// Observe all events emitted by the device registry
cx.subscribe_in(&device, window, |_this, _device, event, window, cx| {
match event {
DeviceEvent::Requesting => {
const MSG: &str =
"Please open other client and approve the request for encryption key.";
DeviceEvent::Requesting(code) => {
const MSG: &str = "Please open another client and approve the request \
for the encryption key. Compare the code with the \
one shown there before approving.";
let note = Notification::new()
.id::<DeviceNotifcation>()
.autohide(false)
.title("Wait for approval")
.title(format!("Wait for approval · {code}"))
.message(MSG)
.with_kind(NotificationKind::Info);
window.push_notification(note, cx);
}
DeviceEvent::NotSet => {
const MSG: &str =
"User're not setup encryption key yet. Do you want to create one?";
const MSG: &str = "No encryption key announcement was found. It may \
still be propagating, or you can restore one \
from the settings.";
let note = Notification::new()
.id::<DeviceNotifcation>()
@@ -132,7 +134,7 @@ impl Workspace {
move |_this, window, cx| {
let device = DeviceRegistry::global(cx);
device.update(cx, |this, cx| {
this.set_announcement(Keys::generate(), cx);
this.get_announcement(cx);
});
window.clear_notification::<DeviceNotifcation>(cx);
},
+1 -1
View File
@@ -33,7 +33,7 @@ ui = { path = "../crates/ui" }
theme = { path = "../crates/theme" }
common = { path = "../crates/common" }
state = { path = "../crates/state" }
device = { path = "../crates/device" }
nip4e = { path = "../crates/nip4e" }
chat = { path = "../crates/chat" }
nip29 = { path = "../crates/nip29" }
settings = { path = "../crates/settings" }
+1 -1
View File
@@ -73,7 +73,7 @@ fn main() {
// Initialize device signer
//
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
device::init(cx);
nip4e::init(cx);
// Initialize app registry
chat::init(cx);
+142
View File
@@ -0,0 +1,142 @@
# NIP-4e Audit Report
> **Update**: The P0 findings (C1, C2, C3) are 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`.
>
> **Update 2**: The P1 sync findings are fixed — F1 (4455 approvals now filtered by
> the requester's `p` tag so the approver no longer processes its own response), F2
> (decrypted keys are validated against the current 10044 announcement via
> `decrypt_approval`), F3 (`since(now)` removed so offline requests/approvals
> backfill), F4 (`pending_request` resets after an approval completes), F5 (a NIP-09
> deletion now removes the 4454 requests and the consumed 4455 after a successful
> sync), F6 (stale stored 4455s no longer short-circuit `request()` — they are
> validated first and a fresh request is published when invalid), F8 (device keys
> are saved locally *before* the 10044 announcement is published, closing the
> echo/self-request race).
>
> **Update 3**: The P2/UX findings are fixed — the notification listener is now
> restarted on account switch and `announcement_existed` is reset (no stale
> `current_user` / false negatives after changing accounts), `NotSet` fires on
> EOSE instead of a fixed 5-second timer, `Announcement::from_event` returns
> `None` on a missing/malformed `n` tag instead of falling back to the identity
> pubkey, the 4454 notification parses the requester key from the `P` tag, the
> NotSet "Retry" button re-checks for an announcement instead of silently
> generating (rotating) a new encryption key, and the restore dialog's
> inverted empty-key check is fixed. Additionally, the `client` tag and
> `client_name` were dropped from announcements/requests, and the PsstPsst
> authorization code was adapted: both sides display the first 8 hex chars of
> the requester client pubkey (`XXXX XXXX`) — the approver in the request
> 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.
**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
The key-sync side (kinds 10044/4454/4455) is a reasonable sketch of the spec, but **the actual message encryption is inverted and non-functional end-to-end**:
- **Sending** seals/signs with the **device encryption key** (should be the identity key).
- **Receiving** unwraps gift wraps with the **identity key** (should be the device key when the wrap is addressed to your encryption key `E`).
Every NIP-4e message between two Coop clients fails at decryption; messages to/from PsstPsst fail in both directions. On top of that there are ~10 sync/UX bugs that make even key distribution unreliable.
## 1. Critical — the message path is broken (P0)
rust-nostr's `GiftWrapBuilder::finalize_async(signer)` (verified against upstream source):
- Seal **content** = `nip44_encrypt(conversation_key(signer_sk, receiver_pk), rumor)`
- Seal is **signed by `signer`** → `seal.pubkey = signer_pk`
- Gift wrap = `nip44_encrypt(conversation_key(ephemeral_sk, receiver_pk), seal)`
- Unwrap requires the receiver's secret matching `receiver_pk`; then rust-nostr **rejects if `rumor.pubkey != seal.pubkey`** ("sender mismatch").
NIP-4e draft says the *sender* keeps using their **identity key** (`ecdh(b, E)` — Bob's `b`) and only the *target* changes to `E`. So:
| # | Finding | Location |
|---|---------|----------|
| **C1** | **Send path signs the seal with the device key.** `select_signer` returns the encryption signer, and `GiftWrapBuilder::finalize_async(device_signer)` signs the seal with `E` while `rumor.pubkey` = identity (`Room::rumor` uses `current_user`). Result: (a) receivers can't attribute the sender, (b) Coop's own `rumor.pubkey != seal.pubkey` check rejects it, (c) PsstPsst verifies the seal against the sender's *identity* key → invalid. | `crates/chat/src/room.rs:482-495, 590-596, 643-646`; rumor pubkey at `room.rs:404-417` |
| **C2** | **Receive path unwraps with the identity key.** `handle_notifications` captures the *user* signer and passes it to `extract_rumor` → `nip44_decrypt(ecdh(a, wrap_pubkey))`. A 4e wrap is encrypted to your `E`, so decryption always fails → "Failed to extract rumor" → trash. The correct unwrap key for 4e messages is the **device key** (`ecdh(e, wrap_pubkey)`), then `ecdh(e, seal.pubkey)` for the rumor — both succeed with the device signer in rust-nostr's unwrapper. | `crates/chat/src/lib.rs:184, 252, 743-801` |
| **C3** | **The `DEVICE_GIFTWRAP` subscription is never created.** The constant exists (`state/src/constants.rs:24`), its ID is used for EOSE matching (`chat/src/lib.rs:190`), but no code ever `subscribe()`s with it (presumably intended: `#p = E` filter on inbox relays). Only `USER_GIFTWRAP` (`#p = A`) exists. | `crates/chat/src/lib.rs:190-191, 410-418` |
**Consequences**:
| Path | Status |
|------|--------|
| Coop → Coop (4e) | ❌ Fails at receiver wrap-decryption (C2); even with a device-key unwrap it fails the sender-mismatch check (C1) |
| Coop → PsstPsst | ❌ PsstPsst unwraps fine (wrap → `E`), but the seal signature doesn't verify against your identity key and there's no seal `n` tag → rejected |
| PsstPsst → Coop | ❌ Wrap routed via identity `p` tag arrives, but Coop unwraps with the identity key → fail (C2) |
| Coop → self backup copy | ❌ Same broken envelope, so cross-device history sync also fails |
| Non-4e (`SignerKind::User`) | ✅ Works — the bug only bites when 4e is enabled, which is why it looks "half-baked" rather than fully broken |
> Note on interop targets: the **draft** wants `seal signed by identity, content = ecdh(identity_sk, receiver_E)` — which is exactly `GiftWrapBuilder::finalize_async(user_signer)` with `receiver = E`. **PsstPsst's profile** goes further: seal signed by identity **plus** an `n` tag with the sender's `E`, content encrypted with `ecdh(sender_e, receiver_E)`. So minimal correctness (Coop↔Coop) = swap the signers; PsstPsst interop additionally requires implementing their split-key envelope (`n` tag, sender-`e` content key).
```mermaid
sequenceDiagram
participant S as Sender (Coop)
participant R as Receiver (Coop)
Note over S: TODAY (broken)<br/>seal signed by device key Es<br/>wrap → Er, unwrap with identity a
S->>R: gift wrap (→ Er)
R->>R: nip44_decrypt(a, wrap) ✗ FAILS
Note over S: CORRECT (draft)<br/>seal signed by identity As<br/>wrap → Er, unwrap with device e
S->>R: gift wrap (→ Er)
R->>R: nip44_decrypt(e, wrap) ✓<br/>nip44_decrypt(e, seal.pubkey) ✓
```
## 2. Key-sync bugs (P1)
| # | Finding | Location |
|---|---------|----------|
| **F1** | **No `p`-tag filter on 4455.** `wait_for_approval` subscribes to *all* your 4455s (`author=me, since=now`). `extract_encryption` then tries to decrypt transfers meant for *other* devices → NIP-44 failure → `DeviceEvent::Error` → error notification. The approving device also picks up its own just-published 4455 and errors on it. Spec/PsstPsst: filter by `p = own client key` and *ignore* undecryptable responses. | `nip4e/src/lib.rs:476-482, 163-165, 489-526` |
| **F2** | **No validation of the received key.** After decrypting a 4455, the key is installed without comparing `derived_pubkey` to the current 10044 `n` value. A stale 4455 (pre-rotation) silently installs a dead key. PsstPsst: "accepts the key only when that public key equals the current kind 10044 `n` value". | `nip4e/src/lib.rs:489-526` |
| **F3** | **`since(Timestamp::now())` on both subscriptions.** The approver misses all 4454s published while it was offline (the draft says Cope "looks for **all** 4454 events" — i.e. backfill). The requester likewise misses 4455s published while it was offline; recovery depends on LMDB having synced them. This is the main "works only when both apps are open at the same time" behavior. | `nip4e/src/lib.rs:388-394, 476-482` |
| **F4** | **`pending_request` is never reset to false.** After the first approval in a session, every subsequent device request is silently dropped (`ask_for_approval` early-returns). | `nip4e/src/lib.rs:597-601` (no `set_pending_request(false)` anywhere) |
| **F5** | **No deletion after success.** The draft explicitly says the requester deletes its 4454 + the received 4455 (NIP-09). Coop leaves them on relays forever, and combined with F6 below, stale events get re-applied on later launches. | absent |
| **F6** | **`request()` short-circuits on *any* old 4455 in the local DB** (`limit(1)`, no recency check, no decrypt-check before committing to it) — stale after rotation. | `nip4e/src/lib.rs:414-422` |
| **F7** | **4454/4455 tag deviations vs PsstPsst profile**: no `n` tag (which key is requested) or `relay` hints on 4454; no identity `p` tag on 4455; `P` tag in 4455 is the device key `E` instead of a freshly generated client key (`c` per the draft). The last one is mostly cosmetic (recipients read `P` either way) but links every transfer to your announcement. | `nip4e/src/lib.rs:426-432, 559-565` |
| **F8** | **Self-request race.** `set_encryption` races the 10044 echo against the local `set_keys` write: if the announcement you just published comes back before the DB save completes, `get_keys` fails → the *master* publishes a 4454 asking *itself*, then shows "You've requested an encryption key from another device" about its own profile. | `nip4e/src/lib.rs:342-375` vs `335` |
## 3. Robustness / state management (P2)
- **`announcement_existed` never resets** and the notification loop resolves `current_user` **once** at startup (`nip4e/src/lib.rs:144`) — after an account switch, 10044/4454/4455 filtering uses the stale pubkey, and a new account without an announcement never gets `NotSet`.
- **`NotSet` after a fixed 5s timer**, not EOSE — slow relays produce false "no key" prompts (`nip4e/src/lib.rs:269-283`).
- **`get_announcement` runs on *every* settings change** (including theme changes) — each call pushes 2 more tasks into `self.tasks` (never drained, grows all session) and re-creates the `wait_for_request` subscription on every `Set` (`nip4e/src/lib.rs:100-116, 295`).
- **Publish-before-save**: `create_encryption` publishes 10044 *then* saves locally; if `set_keys` fails, the key exists only in memory while 10044 advertises it → permanent unlockable requests (`nip4e/src/lib.rs:317-338`).
- Unbounded `processed_events` HashSet in the 4e notification loop (chat's version prunes at 10k; this one doesn't).
## 4. Security considerations
- **One-click approval with no out-of-band check.** Anyone who can get a 4454 signed by your identity (i.e., any of your devices — fine) is approved by client-name alone. PsstPsst requires comparing an 8-hex-char authorization code derived from the requester's client key. Given this event transfers your *master encryption key*, an approval-code comparison is worth copying.
- **`backup()` writes the bech32 secret key to a plaintext file** (`nip4e/src/lib.rs:222-240`). Consider at least warning in the UI.
- **`Announcement::from` falls back to `val.pubkey`** when the `n` tag is missing/malformed (`state/src/nip4e.rs:14-23`) → a malformed 10044 makes `deliver()` believe 4e is available and produce hybrid messages signed with the device key but wrapped to the identity — worst of both worlds. PsstPsst treats a missing `n` as "not a 4e peer".
- Plaintext-encrypted device key sits in kind-30078 local DB events — local-only, acceptable.
## 5. UI/UX bugs
| # | Finding | Location |
|---|---------|----------|
| **U1** | **The "Retry" button is a key-rotation footgun.** On `NotSet`, the notification's action calls `set_announcement(Keys::generate())`. If the `NotSet` was a false negative (F-timer) and an announcement *does* exist, clicking "Retry" silently **rotates your encryption key**, breaking decryption on all other devices until each re-requests. The label says retry; the code creates a new key. | `workspace/src/lib.rs:130-140` |
| **U2** | **Inverted empty-check** in restore: `if !content.is_empty() { set_error("cannot be empty") }` — errors when the input is *not* empty, then falls through and reports "invalid" for empty input. | `workspace/src/dialogs/restore.rs:54-56` |
| **U3** | Restore/reset republish a 10044 with a new key without warning that other devices will need to re-request (reset dialog warns about message loss — good — but restore doesn't warn at all). | `workspace/src/lib.rs:466-473` |
| **U4** | Every spurious decrypt failure from F1 surfaces as a persistent error notification. | `workspace/src/lib.rs:152-154` |
## 6. What's already correct
- 10044 construction matches the draft (`n` tag, replaceable kind, extra `client` tag is harmless).
- 4454 tags (`P`, `client`) match the draft; request/response events are correctly signed by the identity key, so requests are authenticated.
- Client keys are generated per install and stored in the OS keyring — reasonable.
- Local key escrow (kind 30078, NIP-44 self-encrypted, `d = "coop:device"`, never published) is a sound design; the `IDENTIFIER` value surviving the crate rename is fine (changing it would orphan stored keys).
- Dedup of relay echoes, opt-in `nip4e: false` default, and `Auto` mode degrading to the identity path when no announcement exists (this fallback is why normal DMs still work).
## 7. Recommended fix roadmap (ordered)
1. **P0 — fix the envelope (C1+C2+C3)**, Coop↔Coop:
- Send: always sign the seal with the **identity** signer (`GiftWrapBuilder::finalize_async(user_signer)` with `receiver = announcement E`). The device key is *not* needed to send.
- Receive: create the `DEVICE_GIFTWRAP` subscription (`#p = E`) on the user's inbox relays; in `extract_rumor`, unwrap with the **device signer** for wraps addressed to `E` (fall back to identity for wraps addressed to `A`); re-process trash when `DeviceEvent::Set` arrives.
- Keep `rumor.pubkey = identity` — the sender-mismatch check then passes by construction.
2. **P0.5 — decide the interop target**: if PsstPsst interop matters, adopt their split-key envelope (seal `n` tag = sender `E`, seal content encrypted with `ecdh(sender_e, receiver_E)`, seal signed by identity). rust-nostr's builder can't express that split today — you'd build the seal manually.
3. **P1 — sync hardening**: add `p`-tag filters, validate decrypted key against the current 10044 `n`, replace `since(now)` with a startup backfill query, reset `pending_request`, delete 4454/4455 after success, ignore (don't error on) transfers for other devices, and save locally *before* publishing.
4. **P2 — UX**: fix the Retry button to actually retry (`get_announcement`) with a separate explicit "Create key" action; fix the inverted restore check; add an approval code comparison before sharing the key.
5. **P3 — state hygiene**: reset `announcement_existed` + re-resolve `current_user` on signer change, drive `NotSet` off EOSE, bound the task vec/dedup set.
**Validation status**: audit only, no code changed; the workspace compiled cleanly at the last `cargo check`. The rust-nostr NIP-59 behavior was verified against upstream master source (the workspace pins `nostr-sdk` to that git repo; the seal-content/signing behavior has been stable across recent versions).
+1 -1
View File
@@ -14,7 +14,7 @@ ui = { path = "../crates/ui" }
theme = { path = "../crates/theme" }
common = { path = "../crates/common" }
state = { path = "../crates/state" }
device = { path = "../crates/device" }
nip4e = { path = "../crates/nip4e" }
chat = { path = "../crates/chat" }
nip29 = { path = "../crates/nip29" }
settings = { path = "../crates/settings" }
+1 -1
View File
@@ -65,7 +65,7 @@ pub fn run() -> Result<(), JsValue> {
// Initialize device signer
//
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
device::init(cx);
nip4e::init(cx);
// Initialize app registry
chat::init(cx);