From 029ab2198338c69fbaea6942e94dc6f634ddbebe Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 4 Nov 2026 08:42:18 +0700 Subject: [PATCH] implement nip4e interop tags --- crates/nip4e/src/lib.rs | 317 +++++++++++++++++++++++++++--- crates/person/src/lib.rs | 10 +- crates/person/src/person.rs | 7 + crates/state/src/nip4e.rs | 15 +- docs/nip4e-psstpsst-comparison.md | 32 ++- 5 files changed, 350 insertions(+), 31 deletions(-) diff --git a/crates/nip4e/src/lib.rs b/crates/nip4e/src/lib.rs index 9ef10579..0541747d 100644 --- a/crates/nip4e/src/lib.rs +++ b/crates/nip4e/src/lib.rs @@ -474,7 +474,7 @@ impl DeviceRegistry { })?; } else { this.update(cx, |this, cx| { - this.request(cx); + this.request(Some(device_pubkey), cx); })?; } Ok(()) @@ -502,7 +502,11 @@ impl DeviceRegistry { } /// Request encryption keys from other device - pub(crate) fn request(&mut self, cx: &mut Context) { + pub(crate) fn request( + &mut self, + expected_encryption: Option, + cx: &mut Context, + ) { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); @@ -529,9 +533,26 @@ impl DeviceRegistry { } } - // No usable approval event found, construct a request event + // No usable approval event found, construct a request event. + // + // The `pubkey` tag is a compatibility alias for the draft's `P`, + // `relay` hints tell other devices where the response is accepted, + // `n` names the exact key this device expects to be transferred + let mut tags = vec![ + Tag::custom("P", vec![app_pubkey]), + Tag::custom("pubkey", vec![app_pubkey]), + ]; + + for url in write_relay_hints(&client, public_key).await? { + tags.push(Tag::custom("relay", vec![url.to_string()])); + } + + if let Some(expected) = expected_encryption { + tags.push(Tag::custom("n", vec![expected])); + } + let event = EventBuilder::new(Kind::Custom(4454), "") - .tags(vec![Tag::custom("P", vec![app_pubkey])]) + .tags(tags) .finalize_async(&signer) .await?; @@ -692,7 +713,6 @@ impl DeviceRegistry { let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); - // Get user's write relays let event = event.clone(); let request_id = event.id; @@ -700,10 +720,20 @@ impl DeviceRegistry { // Get device keys let keys = get_keys(&client, &signer).await?; let secret = keys.secret_key().to_secret_hex(); + let identity = signer.get_public_key_async().await?; // Extract the target public key from the event tags let target = - client_pubkey_from_request(&event).context("Target is not a valid public key")?; + client_pubkey_from_event(&event).context("Target is not a valid public key")?; + + // The requester may pin the exact key it expects + if let Some(requested) = requested_encryption_key(&event) + && requested != keys.public_key() + { + return Err(anyhow!( + "The requesting device expects an encryption key this device no longer holds" + )); + } // Encrypt to a fresh one-time keypair so the transfer event // can never be decrypted with the long-lived encryption key. @@ -713,17 +743,28 @@ impl DeviceRegistry { // Construct the response event // // P tag: the one-time sender key used for the encryption + // p tag: the account identity, for inbox routing // p tag: the requester's client key - let event = EventBuilder::new(Kind::Custom(4455), payload) + let response = EventBuilder::new(Kind::Custom(4455), payload) .tags(vec![ Tag::custom("P", vec![sender.public_key().to_hex()]), + Tag::public_key(identity), Tag::public_key(target), ]) .finalize_async(&signer) .await?; - // Send the response event to the user's relay list - client.send_event(&event).to_nip65().await?; + let relays = merge_relays( + write_relay_hints(&client, identity).await?, + request_relay_hints(&event), + ); + + let send = client.send_event(&response); + if relays.is_empty() { + send.to_nip65().await?; + } else { + send.to(relays).await?; + } Ok(()) }); @@ -761,7 +802,7 @@ impl DeviceRegistry { /// Handle encryption request fn ask_for_approval(&mut self, event: Event, cx: &mut Context) { - let Some(client_key) = client_pubkey_from_request(&event) else { + let Some(client_key) = client_pubkey_from_event(&event) else { log::warn!("Ignoring invalid encryption key request: no valid requester key"); return; }; @@ -848,13 +889,16 @@ impl DeviceRegistry { device."; let requester = - client_pubkey_from_request(&event).context("Request has no valid requester key")?; + client_pubkey_from_event(&event).context("Request has no valid requester key")?; let code = authorization_code(&requester); - let directory = self.directory.clone(); - directory.update(cx, |directory, cx| directory.track([requester], cx)); let code_label = code.clone(); + let directory = self.directory.clone(); + directory.update(cx, |directory, cx| { + directory.track([requester], cx); + }); + let entity = cx.entity().downgrade(); let loading = Rc::new(Cell::new(false)); @@ -982,14 +1026,23 @@ fn parse_processed_requests(bytes: &[u8]) -> VecDeque { .collect() } -/// Extract the requester client key from a key request's `P` tag -fn client_pubkey_from_request(event: &Event) -> Option { - event +/// Extract the client public key from an event. +fn client_pubkey_from_event(event: &Event) -> Option { + let key = event .tags .iter() .find(|tag| tag.kind() == "P") .and_then(|tag| tag.content()) - .and_then(|content| PublicKey::parse(content).ok()) + .and_then(|content| PublicKey::parse(content).ok()); + + key.or_else(|| { + event + .tags + .iter() + .find(|tag| tag.kind() == "pubkey") + .and_then(|tag| tag.content()) + .and_then(|content| PublicKey::parse(content).ok()) + }) } /// Whether the transfer event is p-tagged to the given client key @@ -1003,6 +1056,57 @@ fn transfer_addresses(event: &Event, client_key: PublicKey) -> bool { }) } +/// Parse the `relay` hints attached to a key request. +fn request_relay_hints(event: &Event) -> Vec { + event + .tags + .iter() + .filter(|tag| tag.kind() == "relay") + .filter_map(|tag| { + tag.content() + .and_then(|content| RelayUrl::parse(content).ok()) + }) + .collect() +} + +/// Parse the optional `n` tag of a key request. +fn requested_encryption_key(event: &Event) -> Option { + event + .tags + .iter() + .find(|tag| tag.kind() == "n") + .and_then(|tag| tag.content()) + .and_then(|content| PublicKey::parse(content).ok()) +} + +/// Collect the user's NIP-65 write relays. +async fn write_relay_hints(client: &Client, identity: PublicKey) -> Result, Error> { + let filter = Filter::new() + .kind(Kind::RelayList) + .author(identity) + .limit(1); + + let Some(event) = client.database().query(filter).await?.into_iter().next() else { + return Ok(vec![]); + }; + + // Unmarked relays serve both reads and writes + Ok(nip65::extract_relay_list(&event) + .filter(|(_, metadata)| *metadata != Some(RelayMetadata::Read)) + .map(|(url, _)| url) + .collect()) +} + +/// Merge relay lists, dropping duplicates +fn merge_relays(first: Vec, second: Vec) -> Vec { + first + .into_iter() + .chain(second) + .collect::>() + .into_iter() + .collect() +} + /// The first eight hexadecimal characters of the client public key. fn authorization_code(public_key: &PublicKey) -> SharedString { let hex = public_key.to_hex(); @@ -1017,13 +1121,7 @@ async fn decrypt_approval( identity: PublicKey, event: &Event, ) -> Result { - 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 master = client_pubkey_from_event(event).context("Invalid event's tags")?; let decrypted = app_keys .nip44_decrypt_async(&master, event.content.as_str()) @@ -1127,3 +1225,174 @@ where 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) -> 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 = (0u8..4) + .map(|i| { + let mut bytes = [0u8; 32]; + bytes[0] = i; + EventId::from_byte_array(bytes) + }) + .collect(); + + let bytes: Vec = 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)); + } +} diff --git a/crates/person/src/lib.rs b/crates/person/src/lib.rs index 39a04cc5..1bf0d447 100644 --- a/crates/person/src/lib.rs +++ b/crates/person/src/lib.rs @@ -54,7 +54,7 @@ impl EventEmitter for PersonRegistry {} #[derive(Debug, Clone)] enum Dispatch { - Person(Person), + Person(Box), Announcement(Event), Relays(Event), Emojis(Event), @@ -111,7 +111,7 @@ impl PersonRegistry { this.update(cx, |this, cx| { match event { Dispatch::Person(person) => { - this.insert(person, cx); + this.insert(*person, cx); } Dispatch::Announcement(event) => { this.set_announcement(&event, cx); @@ -175,7 +175,11 @@ impl PersonRegistry { let metadata = Metadata::from_json(&event.content).unwrap_or_default(); let person = Person::new(event.pubkey, metadata); - if tx.send_async(Dispatch::Person(person)).await.is_err() { + if tx + .send_async(Dispatch::Person(Box::new(person))) + .await + .is_err() + { log::warn!("Channel closed, dropping metadata event"); } } diff --git a/crates/person/src/person.rs b/crates/person/src/person.rs index 1cee8030..f5e9de77 100644 --- a/crates/person/src/person.rs +++ b/crates/person/src/person.rs @@ -144,6 +144,13 @@ impl Person { /// Set profile encryption keys announcement pub(crate) fn set_announcement(&mut self, announcement: Announcement) { + // A delayed or repeated announcement must not roll the key back + if let Some(current) = &self.announcement + && !announcement.is_newer_than(current) + { + return; + } + self.announcement = Some(announcement); } diff --git a/crates/state/src/nip4e.rs b/crates/state/src/nip4e.rs index e0d53583..6c0a0b65 100644 --- a/crates/state/src/nip4e.rs +++ b/crates/state/src/nip4e.rs @@ -5,6 +5,10 @@ use nostr_sdk::prelude::*; pub struct Announcement { /// The public key of the device that created this announcement. public_key: PublicKey, + /// The id of the announcement event, for ordering + event_id: EventId, + /// The creation time of the announcement event + created_at: Timestamp, } impl Announcement { @@ -16,11 +20,20 @@ impl Announcement { .and_then(|tag| tag.content()) .and_then(|c| PublicKey::parse(c).ok())?; - Some(Self { public_key }) + Some(Self { + public_key, + event_id: event.id, + created_at: event.created_at, + }) } /// Returns the public key of the device that created this announcement. pub fn public_key(&self) -> PublicKey { self.public_key } + + pub fn is_newer_than(&self, other: &Self) -> bool { + self.created_at > other.created_at + || (self.created_at == other.created_at && self.event_id < other.event_id) + } } diff --git a/docs/nip4e-psstpsst-comparison.md b/docs/nip4e-psstpsst-comparison.md index ea380f4d..8fd3b6ca 100644 --- a/docs/nip4e-psstpsst-comparison.md +++ b/docs/nip4e-psstpsst-comparison.md @@ -89,8 +89,9 @@ with a documented [client profile](https://github.com/CodyTseng/psstpsst/blob/ma ## Status -Section A (items 1-5) is implemented in `crates/nip4e/src/lib.rs` and -`crates/state/src/credentials.rs`: +### 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 @@ -105,4 +106,29 @@ Section A (items 1-5) is implemented in `crates/nip4e/src/lib.rs` and - An open approval is withdrawn when a `4455` addressed to the same client key arrives (another device already answered). -Items 6-12 remain open. +### 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.