nip4e: catch up psstpsst #70

Merged
reya merged 4 commits from patch-nip-4e into master 2026-11-04 03:12:54 +00:00
7 changed files with 357 additions and 358 deletions
Showing only changes of commit 06ff8ce65d - Show all commits
+24 -1
View File
@@ -947,7 +947,11 @@ async fn extract_rumor(
let Some(device_signer) = device_signer else {
return Err(error);
};
try_unwrap_with(device_signer, gift_wrap).await?
match try_unwrap_with(device_signer, gift_wrap).await {
Ok(unwrapped) => unwrapped,
// The wrap may be sealed to a key retired by a rotation
Err(_) => try_unwrap_archived(client, signer, gift_wrap).await?,
}
}
};
let mut rumor = unwrapped.rumor;
@@ -1003,6 +1007,25 @@ async fn try_unwrap_with(
})
}
/// Unwrap with an encryption key retired by a rotation; peers may still seal
/// to the previous key until their announcement cache refreshes
async fn try_unwrap_archived(
client: &Client,
signer: &UniversalSigner,
gift_wrap: &Event,
) -> Result<UnwrappedGift, Error> {
for keys in DeviceRegistry::archived_keys(client, signer).await? {
let archived = UniversalSigner::new(keys);
if let Ok(unwrapped) = try_unwrap_with(&archived, gift_wrap).await {
return Ok(unwrapped);
}
}
Err(anyhow!(
"No archived encryption key could unwrap the gift wrap"
))
}
/// Stores an unwrapped event in local database with reference to original
async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
let room_id = rumor.uniq_id().to_string();
+24
View File
@@ -16,6 +16,7 @@ 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.";
const KEY_STALE: &str = "Your encryption key is out of date; sync it from another device in the settings to keep messaging.";
/// The outcome of delivering an encrypted rumor to a single receiver.
#[derive(Debug, Clone)]
@@ -553,6 +554,7 @@ impl Room {
let config = self.config.clone();
let device = DeviceRegistry::global(cx);
let resyncing = device.read(cx).is_resyncing();
let encryption_signer = device.read(cx).signer(cx);
let nostr = NostrRegistry::global(cx);
@@ -574,6 +576,28 @@ impl Room {
Some(cx.background_spawn(async move {
let signer_kind = config.signer_kind();
// Block sends while the local encryption key is stale against the
// current announcement; the replacement is being synced from
// another device
if resyncing && matches!(signer_kind, SignerKind::Auto | SignerKind::Encryption) {
let mut reports: Vec<SendReport> = receivers
.into_iter()
.map(|receiver| SendReport {
receiver: receiver.public_key(),
result: Err(KEY_STALE.into()),
})
.collect();
if config.backup() {
reports.push(SendReport {
receiver: sender.public_key(),
result: Err(KEY_STALE.into()),
});
}
return reports;
}
// Deliver to every receiver concurrently
let sends = receivers.into_iter().map(|receiver| {
deliver(
+105 -221
View File
@@ -17,12 +17,14 @@ use gpui_kit::component::notification::{Notification, NotificationType};
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
use nostr_sdk::prelude::*;
use person::Directory;
use settings::AppSettings;
use settings::{AppSettings, NIP4E_ROTATION_MAX_DAYS, NIP4E_ROTATION_MIN_DAYS};
use smallvec::{SmallVec, smallvec};
use state::{Announcement, NostrRegistry, UniversalSigner, credentials};
use ui::Avatar;
const IDENTIFIER: &str = "coop:device";
mod storage;
use storage::{SECONDS_PER_DAY, archive_key, get_keys, load_device_keys, set_keys};
pub fn init(cx: &mut App) {
DeviceRegistry::set_global(cx.new(DeviceRegistry::new), cx);
@@ -60,7 +62,7 @@ impl DeviceEvent {
#[derive(Debug)]
pub struct DeviceRegistry {
signer: Entity<Option<UniversalSigner>>,
/// Ephemeral client key for the in-flight key request. never persisted
/// Ephemeral client key for the in-flight key request; never persisted
app_keys: Option<Keys>,
/// The key request currently awaiting user approval on this device
pending_request: Option<PendingRequest>,
@@ -68,6 +70,10 @@ pub struct DeviceRegistry {
processed_requests: VecDeque<EventId>,
/// Whether an announcement has been made for this device
pub(crate) announcement_existed: Arc<AtomicBool>,
/// Whether the local key is stale against the current announcement.
resync: Arc<AtomicBool>,
/// Whether a key rotation is in flight, to coalesce overlapping requests
rotating: bool,
/// Person directory for requesters shown in notifications
directory: Entity<Directory>,
/// Relay notification listener
@@ -113,6 +119,7 @@ impl DeviceRegistry {
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.resync.store(false, Ordering::Relaxed);
// The in-flight request and client key belong to the old account
this.app_keys = None;
@@ -145,6 +152,8 @@ impl DeviceRegistry {
pending_request: None,
processed_requests: VecDeque::new(),
announcement_existed: Arc::new(AtomicBool::new(false)),
resync: Arc::new(AtomicBool::new(false)),
rotating: false,
directory: Directory::new(cx),
notification_listener: None,
tasks: vec![],
@@ -306,9 +315,16 @@ impl DeviceRegistry {
*this = Some(UniversalSigner::new(new_signer));
cx.notify();
});
// The local key is now consistent with the announcement
self.resync.store(false, Ordering::Release);
cx.emit(DeviceEvent::Set);
}
/// Whether the local encryption key is stale against the current announcement.
pub fn is_resyncing(&self) -> bool {
self.resync.load(Ordering::Acquire)
}
/// Backup the encryption's secret key, returning the `nsec1...` content
pub fn backup_bytes(&self, cx: &App) -> Task<Result<String, Error>> {
let nostr = NostrRegistry::global(cx);
@@ -411,6 +427,88 @@ impl DeviceRegistry {
}));
}
/// Replace the encryption key with a new one, archiving the old key.
pub fn rotate(&mut self, cx: &mut Context<Self>) {
if self.rotating {
return;
}
self.rotating = true;
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
self.tasks.push(cx.spawn(async move |this, cx| {
let result: Result<Keys, Error> = async {
// Archive the current key before replacing it
if let Ok(old) = get_keys(&client, &signer).await {
archive_key(&client, &signer, &old.secret_key().to_secret_hex()).await?;
}
let created = this
.update(cx, |this, cx| this.create_encryption(Keys::generate(), cx))?
.await?;
this.update(cx, |this, cx| {
this.set_signer(created.clone(), cx);
this.wait_for_request(cx);
})?;
Ok(created)
}
.await;
this.update(cx, |this, cx| {
this.rotating = false;
if let Err(e) = result {
cx.emit(DeviceEvent::error(e.to_string()));
}
})
.ok();
Ok(())
}));
}
/// Rotate the encryption key when the configured interval has elapsed.
///
/// Runs after the announcement reconciled, outside the blocking boot path.
pub fn rotate_if_due(&mut self, cx: &mut Context<Self>) {
let Some(interval_days) = AppSettings::get_nip4e_rotation_days(cx) else {
return;
};
let interval_days = interval_days.clamp(NIP4E_ROTATION_MIN_DAYS, NIP4E_ROTATION_MAX_DAYS);
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
self.tasks.push(cx.spawn(async move |this, cx| {
let Ok(keys) = load_device_keys(&client, &signer).await else {
return Ok(());
};
let elapsed = Timestamp::now()
.as_secs()
.saturating_sub(keys.created_at.as_secs());
if elapsed < u64::from(interval_days) * SECONDS_PER_DAY {
return Ok(());
}
this.update(cx, |this, cx| this.rotate(cx)).ok();
Ok(())
}));
}
/// Load encryption keys retired by a rotation from the local database.
pub async fn archived_keys<S>(client: &Client, signer: &S) -> Result<Vec<Keys>, Error>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
storage::archived_keys(client, signer).await
}
/// Create new encryption key and announce it to user's relay list
fn create_encryption(&self, keys: Keys, cx: &App) -> Task<Result<Keys, Error>> {
let nostr = NostrRegistry::global(cx);
@@ -419,6 +517,7 @@ impl DeviceRegistry {
let secret = keys.secret_key().to_secret_hex();
let n = keys.public_key();
let created_at = Timestamp::now();
cx.background_spawn(async move {
// Construct an announcement event
@@ -428,7 +527,7 @@ impl DeviceRegistry {
.await?;
// Persist the key locally before announcing it
set_keys(&client, &signer, &secret).await?;
set_keys(&client, &signer, &secret, created_at).await?;
// Publish announcement
client
@@ -471,9 +570,11 @@ impl DeviceRegistry {
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
this.wait_for_request(cx);
this.rotate_if_due(cx);
})?;
} else {
this.update(cx, |this, cx| {
this.resync.store(true, Ordering::Release);
this.request(Some(device_pubkey), cx);
})?;
}
@@ -1179,220 +1280,3 @@ async fn decrypt_approval(
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
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let content = signer.nip44_encrypt_async(&public_key, secret).await?;
// Construct the application data event
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tag(Tag::identifier(IDENTIFIER))
.finalize_async(signer)
.await?;
// Save the event to the database
client.database().save_event(&event).await?;
Ok(())
}
/// Get device keys from the local database.
async fn get_keys<S>(client: &Client, signer: &S) -> Result<Keys, Error>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(IDENTIFIER)
.author(public_key);
if let Some(event) = client.database().query(filter).await?.first() {
let content = signer
.nip44_decrypt_async(&public_key, &event.content)
.await?;
let secret = SecretKey::parse(&content)?;
let keys = Keys::new(secret);
Ok(keys)
} else {
Err(anyhow!("Key not found"))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Compose a minimal event whose validity doesn't matter for the helpers
fn event_with_tags(kind: Kind, tags: Vec<Tag>) -> Event {
let keys = Keys::generate();
let public_key = keys.public_key();
let created_at = Timestamp::now();
let tags = Tags::from_list(tags);
let id = EventId::compute(&public_key, &created_at, &kind, &tags, "");
Event::new(
id,
public_key,
created_at,
kind,
tags,
"",
Signature::from_slice(&[0u8; 64]).expect("signature should parse"),
)
}
#[test]
fn parse_processed_requests_roundtrip() {
let ids: Vec<EventId> = (0u8..4)
.map(|i| {
let mut bytes = [0u8; 32];
bytes[0] = i;
EventId::from_byte_array(bytes)
})
.collect();
let bytes: Vec<u8> = ids.iter().flat_map(|id| id.to_hex().into_bytes()).collect();
let parsed = parse_processed_requests(&bytes);
assert_eq!(parsed.len(), 4);
assert!(ids.iter().all(|id| parsed.contains(id)));
}
#[test]
fn parse_processed_requests_ignores_garbage() {
assert!(parse_processed_requests(&[]).is_empty());
assert!(parse_processed_requests(b"not hex at all").is_empty());
assert!(parse_processed_requests(&[b'a'; 63]).is_empty());
}
#[test]
fn client_pubkey_from_event_reads_p_tag() {
let client_key = Keys::generate().public_key();
let event = event_with_tags(
Kind::Custom(4454),
vec![Tag::custom("P", vec![client_key.to_hex()])],
);
assert_eq!(client_pubkey_from_event(&event), Some(client_key));
}
#[test]
fn client_pubkey_from_event_rejects_missing_or_invalid() {
let event = event_with_tags(Kind::Custom(4454), vec![]);
assert_eq!(client_pubkey_from_event(&event), None);
let event = event_with_tags(Kind::Custom(4454), vec![Tag::custom("P", vec!["nope"])]);
assert_eq!(client_pubkey_from_event(&event), None);
}
#[test]
fn client_pubkey_from_event_falls_back_to_alias() {
let client_key = Keys::generate().public_key();
let event = event_with_tags(
Kind::Custom(4454),
vec![Tag::custom("pubkey", vec![client_key.to_hex()])],
);
assert_eq!(client_pubkey_from_event(&event), Some(client_key));
}
#[test]
fn client_pubkey_from_event_prefers_p_tag() {
let client_key = Keys::generate().public_key();
let alias = Keys::generate().public_key();
let event = event_with_tags(
Kind::Custom(4454),
vec![
Tag::custom("P", vec![client_key.to_hex()]),
Tag::custom("pubkey", vec![alias.to_hex()]),
],
);
assert_eq!(client_pubkey_from_event(&event), Some(client_key));
}
#[test]
fn transfer_addresses_matches_client_key_p_tag() {
let client_key = Keys::generate().public_key();
let other = Keys::generate().public_key();
let identity = Keys::generate().public_key();
let event = event_with_tags(
Kind::Custom(4455),
vec![
Tag::custom("P", vec![other.to_hex()]),
Tag::public_key(client_key),
],
);
assert!(transfer_addresses(&event, client_key));
assert!(!transfer_addresses(&event, other));
assert!(!transfer_addresses(&event, identity));
}
#[test]
fn transfer_addresses_ignores_uppercase_p_tag() {
let client_key = Keys::generate().public_key();
let event = event_with_tags(
Kind::Custom(4455),
vec![Tag::custom("P", vec![client_key.to_hex()])],
);
assert!(!transfer_addresses(&event, client_key));
}
#[test]
fn request_relay_hints_parses_and_drops_invalid() {
let valid = RelayUrl::parse("wss://relay.example.com").expect("relay should parse");
let event = event_with_tags(
Kind::Custom(4454),
vec![
Tag::custom("relay", vec!["wss://relay.example.com"]),
Tag::custom("relay", vec!["not a relay"]),
Tag::custom("relay", vec!["wss://other.example.com"]),
],
);
let hints = request_relay_hints(&event);
assert_eq!(hints.len(), 2);
assert_eq!(hints[0], valid);
}
#[test]
fn requested_encryption_key_reads_n_tag() {
let encryption_key = Keys::generate().public_key();
let event = event_with_tags(
Kind::Custom(4454),
vec![Tag::custom("n", vec![encryption_key.to_hex()])],
);
assert_eq!(requested_encryption_key(&event), Some(encryption_key));
let event = event_with_tags(Kind::Custom(4454), vec![]);
assert_eq!(requested_encryption_key(&event), None);
}
#[test]
fn merge_relays_deduplicates() {
let a = RelayUrl::parse("wss://a.example.com").expect("relay should parse");
let b = RelayUrl::parse("wss://b.example.com").expect("relay should parse");
let merged = merge_relays(vec![a.clone(), b.clone()], vec![a.clone()]);
assert_eq!(merged.len(), 2);
assert!(merged.contains(&a));
assert!(merged.contains(&b));
}
}
+174
View File
@@ -0,0 +1,174 @@
use anyhow::{Error, anyhow};
use nostr_sdk::prelude::*;
/// The storage identifier for the device encryption key
const IDENTIFIER: &str = "coop:device";
/// The storage identifier for encryption keys retired by a rotation
const ARCHIVE_IDENTIFIER: &str = "coop:device-archive";
/// Keep the archived key list bounded
const ARCHIVE_LIMIT: usize = 10;
/// Seconds in a day, for rotation intervals
pub(crate) const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
/// Device encryption key material with its creation time
#[derive(Debug, Clone)]
pub(crate) struct DeviceKeys {
pub(crate) keys: Keys,
pub(crate) created_at: Timestamp,
}
/// Encode the stored key content: secret hex and creation time
fn encode_keys_content(secret: &str, created_at: &Timestamp) -> String {
format!("{secret}\n{}", created_at.as_secs())
}
/// Parse stored key content.
fn parse_keys_content(content: &str) -> Result<(SecretKey, Timestamp), Error> {
match content.split_once('\n') {
Some((secret, created_at)) => {
let created_at = created_at
.trim()
.parse::<u64>()
.unwrap_or_else(|_| Timestamp::now().as_secs());
Ok((
SecretKey::from_hex(secret)?,
Timestamp::from_secs(created_at),
))
}
None => Ok((SecretKey::from_hex(content)?, Timestamp::now())),
}
}
/// Parse archived secrets from stored content, one hex secret per line
fn parse_archived_keys(content: &str) -> Vec<Keys> {
content
.lines()
.filter_map(|line| SecretKey::from_hex(line.trim()).ok())
.map(Keys::new)
.collect()
}
/// Load the device keys with their creation time from the local database.
pub(crate) async fn load_device_keys<S>(client: &Client, signer: &S) -> Result<DeviceKeys, Error>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(IDENTIFIER)
.author(public_key);
let Some(event) = client.database().query(filter).await?.into_iter().next() else {
return Err(anyhow!("Key not found"));
};
let content = signer
.nip44_decrypt_async(&public_key, &event.content)
.await?;
let (secret, created_at) = parse_keys_content(&content)?;
Ok(DeviceKeys {
keys: Keys::new(secret),
created_at,
})
}
/// Encrypt and store device keys in the local database.
pub(crate) async fn set_keys<S>(
client: &Client,
signer: &S,
secret: &str,
created_at: Timestamp,
) -> Result<(), Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let content = signer
.nip44_encrypt_async(&public_key, &encode_keys_content(secret, &created_at))
.await?;
// Construct the application data event
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tag(Tag::identifier(IDENTIFIER))
.finalize_async(signer)
.await?;
// Save the event to the database
client.database().save_event(&event).await?;
Ok(())
}
/// Append an encryption secret to the bounded archive of retired keys.
pub(crate) async fn archive_key<S>(client: &Client, signer: &S, secret: &str) -> Result<(), Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(ARCHIVE_IDENTIFIER)
.author(public_key);
let mut archived: Vec<String> = match client.database().query(filter).await?.into_iter().next()
{
Some(event) => {
let content = signer
.nip44_decrypt_async(&public_key, &event.content)
.await?;
content.lines().map(str::to_string).collect()
}
None => vec![],
};
archived.insert(0, secret.to_string());
archived.truncate(ARCHIVE_LIMIT);
let content = signer
.nip44_encrypt_async(&public_key, &archived.join("\n"))
.await?;
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tag(Tag::identifier(ARCHIVE_IDENTIFIER))
.finalize_async(signer)
.await?;
client.database().save_event(&event).await?;
Ok(())
}
/// Get device keys from the local database.
pub(crate) async fn get_keys<S>(client: &Client, signer: &S) -> Result<Keys, Error>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
Ok(load_device_keys(client, signer).await?.keys)
}
/// Load encryption keys retired by a rotation from the local database.
pub(crate) async fn archived_keys<S>(client: &Client, signer: &S) -> Result<Vec<Keys>, Error>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(ARCHIVE_IDENTIFIER)
.author(public_key);
let Some(event) = client.database().query(filter).await?.into_iter().next() else {
return Ok(vec![]);
};
let content = signer
.nip44_decrypt_async(&public_key, &event.content)
.await?;
Ok(parse_archived_keys(&content))
}
+11 -1
View File
@@ -100,7 +100,12 @@ pub fn persisted_theme() -> ThemeSettings {
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
const LEGACY_FILE_SERVER: &str = "blossom.band";
/// Minimum days between automatic encryption key rotations
pub const NIP4E_ROTATION_MIN_DAYS: u32 = 1;
/// Maximum days between automatic encryption key rotations
pub const NIP4E_ROTATION_MAX_DAYS: u32 = 90;
/// Days between automatic encryption key rotations when enabled
pub const NIP4E_ROTATION_DEFAULT_DAYS: u32 = 30;
/// Built-in image shown behind chat and group panels
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
@@ -147,6 +152,7 @@ setting_accessors! {
pub screening: bool,
pub wot_auto_trust: bool,
pub nip4e: bool,
pub nip4e_rotation_days: Option<u32>,
pub hide_group_notice: bool,
pub file_server: Url,
pub panel_background: bool,
@@ -222,6 +228,9 @@ pub struct Settings {
pub wot_auto_trust: bool,
/// Enable decoupling encryption key
pub nip4e: bool,
/// Days between automatic encryption key rotations; None disables rotation
#[serde(default)]
pub nip4e_rotation_days: Option<u32>,
/// Hide the notice that messages in public groups are not encrypted
#[serde(default)]
pub hide_group_notice: bool,
@@ -254,6 +263,7 @@ impl Default for Settings {
screening: true,
wot_auto_trust: true,
nip4e: false,
nip4e_rotation_days: None,
hide_group_notice: false,
theme: ThemeSettings::default(),
panel_background: true,
+19 -1
View File
@@ -22,7 +22,7 @@ use gpui_kit::component::progress::Progress;
use gpui_kit::component::separator::Separator;
use gpui_kit::component::switch::Switch;
use gpui_kit::component::{Disableable, Sizable, WindowExt, h_flex, v_flex};
use settings::{AppSettings, AppearanceMode};
use settings::{AppSettings, AppearanceMode, NIP4E_ROTATION_DEFAULT_DAYS};
#[cfg(not(target_arch = "wasm32"))]
use speech::model::{self as speech_model};
use ui::{settings_block, settings_row};
@@ -337,6 +337,7 @@ impl Render for Preferences {
"Corner radius for large elements like dialogs. Empty follows the OS theme.";
const FOCUS_RING: &str = "Draw a ring around focused controls.";
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
const NIP4E_ROTATION: &str = "Automatically rotate the encryption key every 30 days, keeping the previous key for messages still sealed to it.";
const BACKGROUND: &str = "Show an image behind chat and group panels.";
const MEDIA: &str = "Server for media attachments. Only blossom services are supported.";
@@ -344,6 +345,7 @@ impl Render for Preferences {
let wot_auto_trust = AppSettings::get_wot_auto_trust(cx);
let hide_avatar = AppSettings::get_hide_avatar(cx);
let nip4e = AppSettings::get_nip4e(cx);
let nip4e_rotation_days = AppSettings::get_nip4e_rotation_days(cx);
let appearance_mode = AppSettings::get_appearance_mode(cx);
let theme = AppSettings::get_theme(cx);
let panel_background = AppSettings::get_panel_background(cx);
@@ -507,6 +509,22 @@ impl Render for Preferences {
}),
cx,
))
.child(settings_row(
"Rotate Encryption Key",
NIP4E_ROTATION,
Switch::new("nip4e_rotation")
.checked(nip4e_rotation_days.is_some())
.on_click(move |_, _window, cx| {
let enabled = AppSettings::get_nip4e_rotation_days(cx).is_some();
let days = if enabled {
None
} else {
Some(NIP4E_ROTATION_DEFAULT_DAYS)
};
AppSettings::update_nip4e_rotation_days(days, cx);
}),
cx,
))
.child(Separator::horizontal())
.child(self.render_speech(cx))
.child(self.render_dictation_shortcut(window, cx))
-134
View File
@@ -1,134 +0,0 @@
# NIP-4e: psstpsst implementation comparison
A comparison of Coop's NIP-4e implementation (`crates/nip4e`) against the
[psstpsst](https://github.com/CodyTseng/psstpsst) client, which implements the
draft [NIP-4e (per-device keys)](https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md)
with a documented [client profile](https://github.com/CodyTseng/psstpsst/blob/master/docs/protocols/nip4e.md).
## What Coop already covers (well)
| Capability | Coop | psstpsst |
|---|---|---|
| kind `10044` announcement (fetch + publish, `n` tag) | yes | yes |
| kind `4454` request with `P` tag + pairing code | yes | yes |
| kind `4455` transfer, NIP-44 encrypted | yes | yes |
| Requester key derivation & local storage | encrypted-to-self event in local DB | OS vault |
| Announcement-match validation on import | yes (`decrypt_approval`) | yes |
| Delete `4454` + consumed `4455` after success (draft step 12) | yes — psstpsst does *not* do this | no |
| Backup/export of encryption key (nsec) | yes, incl. wasm path | no |
| Requester identity shown to approver (avatar + name) | yes | no (code only) |
| Account-switch state reset | basic | stronger |
## Gaps
### A. Security-critical
1. **Long-lived client key instead of ephemeral per-exchange keys.**
psstpsst generates a throwaway client keypair for every `4454`/`4455`
exchange and never persists it: a `4455` lives on relays forever, so reusing
one client key means a single leak decrypts every key transfer ever addressed
to it. Coop's `get_or_init_app_keys` persists one app key in `credentials`
and reuses it forever.
2. **The `4455` is encrypted with the long-lived encryption key, not a fresh
sender keypair.** The draft (step 10) has the responder generate a fresh
keypair `c` and encrypt to `ecdh(c, T)`, with `P` carrying `C`. psstpsst
follows this. Coop encrypts with the encryption key itself and its `P` tag
carries `E`, so anyone holding the encryption private key — today or after a
future rotation/compromise — can decrypt all historical `4455`s.
3. **No durable processed-request log.** Coop's dedup is an in-memory
`HashSet` plus a single `pending_request: bool`. psstpsst persists every
handled `4454` id so approvals don't re-fire after restart and a device can
recognize its own requests even after its client key is gone.
4. **Announcement-match is skipped when the local DB has no announcement.**
`decrypt_approval` silently accepts a transfer when the local DB query
returns nothing. psstpsst keeps waiting until the transfer matches a known
announcement; the match is the replay-safety mechanism.
5. **No cross-device resolution detection.** psstpsst lets a second existing
device see a `4455` already addressed to the requester's client key and
withdraw its redundant approval, avoiding publishing the secret twice.
Coop's `pending_request` bool means two devices can both approve.
### B. Protocol/interop
6. **Missing `4454` tags psstpsst sends:** `relay` hints (the responder replies
to *shared* relays; without them the `4455` may never reach the requester),
optional `n` (the exact requested key), and the `pubkey` compatibility
alias (psstpsst accepts both `P` and `pubkey`; Coop reads only `P`).
7. **Missing identity `p` tag on `4455`.** psstpsst emits `p` = identity plus
`p` = requester client key; Coop has only the client-key `p`.
8. **No peer announcement caching/freshness model.** psstpsst caches per-peer
announcements in SQLite with tie-break rules (newer `created_at`, then
lexicographically smaller id), a one-day TTL freshness check, and relay
resolution (peer's write + capped DM relays, fallback to discovery). Coop
fetches peer `10044` via a plain subscription in `crates/chat`.
### C. Features
9. **Key rotation.** psstpsst supports scheduled automatic rotation
(1-90 days, default 30, off by default) with single-flight rotation,
startup due-checks, old keys retained in a bounded list for the transition,
and send-readiness gating. Coop has no rotation path.
10. **Resync gating.** psstpsst drops into a full-screen gate that blocks
messaging until a valid key arrives after observing a key change. Coop
re-requests on mismatch but doesn't gate messaging meanwhile.
### D. Engineering
11. **No tests** for `crates/nip4e`. psstpsst has dedicated suites for the
transfer flow, watcher, session, store, and approval sheet.
12. **No client profile doc** pinning down tag MUSTs, code format, and
compatibility. Coop's deviations (#2, #7) are implicit.
## Status
### Section A (items 1-5) — implemented
Implemented in `crates/nip4e/src/lib.rs` and `crates/state/src/credentials.rs`:
- Ephemeral per-request client key, held in memory for the lifetime of one
request (retries reuse it so an open approval sheet keeps targeting a key the
requester still listens for), cleared after import or account switch.
- `4455` responses encrypt to a fresh one-time responder keypair carried in the
`P` tag, matching the draft and psstpsst.
- Bounded, persisted log of handled `4454` ids; duplicate requests (same client
key) are recorded without re-prompting, replaced requests (different client
key) withdraw the stale sheet.
- `decrypt_approval` falls back to a live relay query when the local database
has no announcement and rejects when none is available.
- An open approval is withdrawn when a `4455` addressed to the same client key
arrives (another device already answered).
### Section B (items 6-8) — implemented
- **Interop tags on `4454`** (`crates/nip4e/src/lib.rs`): requests now carry
the draft's `P` tag plus the `pubkey` compatibility alias, `relay` hints (the
requester's NIP-65 write relays), and an optional `n` tag pinning the exact
encryption key the requester observed in its announcement. Responder readers
accept `P` or `pubkey` on both `4454` and `4455`.
- **Responder honors the request** (`approve`): the identity `p` tag is added
to `4455` for account inbox routing; the response is published to the union
of the responder's NIP-65 write relays and the request's normalized `relay`
hints (each hint parsed as untrusted input), falling back to the NIP-65
policy when no relays are known; when the request pins an `n` key this device
no longer holds, the transfer is refused instead of sending a key the
requester would reject.
- **Peer announcement ordering** (`crates/state/src/nip4e.rs`,
`crates/person/src/person.rs`): `Announcement` now carries the event id and
creation time, and `is_newer_than` implements the psstpsst ordering (newer
`created_at` wins, ties go to the lexicographically smaller event id).
`Person::set_announcement` only replaces an existing announcement with a
newer one, so delayed or out-of-order `10044` events cannot roll a peer's
key back. Freshness remains subscription-based: peer `10044` is re-subscribed
whenever a room opens, over the connected relay set that includes
discovery/bootstrap relays — push-based and stronger than psstpsst's one-day
TTL polling, so no separate announcement cache was added.
Items 9-12 remain open.