nip4e: catch up psstpsst #70
+24
-1
@@ -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();
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user