implement nip4e interop tags

This commit is contained in:
2026-11-04 08:42:18 +07:00
parent 198f13bd1d
commit 029ab21983
5 changed files with 350 additions and 31 deletions
+293 -24
View File
@@ -474,7 +474,7 @@ impl DeviceRegistry {
})?; })?;
} else { } else {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.request(cx); this.request(Some(device_pubkey), cx);
})?; })?;
} }
Ok(()) Ok(())
@@ -502,7 +502,11 @@ impl DeviceRegistry {
} }
/// Request encryption keys from other device /// Request encryption keys from other device
pub(crate) fn request(&mut self, cx: &mut Context<Self>) { pub(crate) fn request(
&mut self,
expected_encryption: Option<PublicKey>,
cx: &mut Context<Self>,
) {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer(); 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), "") let event = EventBuilder::new(Kind::Custom(4454), "")
.tags(vec![Tag::custom("P", vec![app_pubkey])]) .tags(tags)
.finalize_async(&signer) .finalize_async(&signer)
.await?; .await?;
@@ -692,7 +713,6 @@ impl DeviceRegistry {
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer(); let signer = nostr.read(cx).signer();
// Get user's write relays
let event = event.clone(); let event = event.clone();
let request_id = event.id; let request_id = event.id;
@@ -700,10 +720,20 @@ impl DeviceRegistry {
// Get device keys // Get device keys
let keys = get_keys(&client, &signer).await?; let keys = get_keys(&client, &signer).await?;
let secret = keys.secret_key().to_secret_hex(); 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 // Extract the target public key from the event tags
let target = 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 // Encrypt to a fresh one-time keypair so the transfer event
// can never be decrypted with the long-lived encryption key. // can never be decrypted with the long-lived encryption key.
@@ -713,17 +743,28 @@ impl DeviceRegistry {
// Construct the response event // Construct the response event
// //
// P tag: the one-time sender key used for the encryption // 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 // 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![ .tags(vec![
Tag::custom("P", vec![sender.public_key().to_hex()]), Tag::custom("P", vec![sender.public_key().to_hex()]),
Tag::public_key(identity),
Tag::public_key(target), Tag::public_key(target),
]) ])
.finalize_async(&signer) .finalize_async(&signer)
.await?; .await?;
// Send the response event to the user's relay list let relays = merge_relays(
client.send_event(&event).to_nip65().await?; 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(()) Ok(())
}); });
@@ -761,7 +802,7 @@ impl DeviceRegistry {
/// Handle encryption request /// Handle encryption request
fn ask_for_approval(&mut self, event: Event, cx: &mut Context<Self>) { fn ask_for_approval(&mut self, event: Event, cx: &mut Context<Self>) {
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"); log::warn!("Ignoring invalid encryption key request: no valid requester key");
return; return;
}; };
@@ -848,13 +889,16 @@ impl DeviceRegistry {
device."; device.";
let requester = 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 code = authorization_code(&requester);
let directory = self.directory.clone();
directory.update(cx, |directory, cx| directory.track([requester], cx));
let code_label = code.clone(); 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 entity = cx.entity().downgrade();
let loading = Rc::new(Cell::new(false)); let loading = Rc::new(Cell::new(false));
@@ -982,14 +1026,23 @@ fn parse_processed_requests(bytes: &[u8]) -> VecDeque<EventId> {
.collect() .collect()
} }
/// Extract the requester client key from a key request's `P` tag /// Extract the client public key from an event.
fn client_pubkey_from_request(event: &Event) -> Option<PublicKey> { fn client_pubkey_from_event(event: &Event) -> Option<PublicKey> {
event let key = event
.tags .tags
.iter() .iter()
.find(|tag| tag.kind() == "P") .find(|tag| tag.kind() == "P")
.and_then(|tag| tag.content()) .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 /// 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<RelayUrl> {
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<PublicKey> {
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<Vec<RelayUrl>, 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<RelayUrl>, second: Vec<RelayUrl>) -> Vec<RelayUrl> {
first
.into_iter()
.chain(second)
.collect::<HashSet<_>>()
.into_iter()
.collect()
}
/// The first eight hexadecimal characters of the client public key. /// The first eight hexadecimal characters of the client public key.
fn authorization_code(public_key: &PublicKey) -> SharedString { fn authorization_code(public_key: &PublicKey) -> SharedString {
let hex = public_key.to_hex(); let hex = public_key.to_hex();
@@ -1017,13 +1121,7 @@ async fn decrypt_approval(
identity: PublicKey, identity: PublicKey,
event: &Event, event: &Event,
) -> Result<Keys, Error> { ) -> Result<Keys, Error> {
let master = event let master = client_pubkey_from_event(event).context("Invalid event's tags")?;
.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 let decrypted = app_keys
.nip44_decrypt_async(&master, event.content.as_str()) .nip44_decrypt_async(&master, event.content.as_str())
@@ -1127,3 +1225,174 @@ where
Err(anyhow!("Key not found")) 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));
}
}
+7 -3
View File
@@ -54,7 +54,7 @@ impl EventEmitter<PersonEvent> for PersonRegistry {}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
enum Dispatch { enum Dispatch {
Person(Person), Person(Box<Person>),
Announcement(Event), Announcement(Event),
Relays(Event), Relays(Event),
Emojis(Event), Emojis(Event),
@@ -111,7 +111,7 @@ impl PersonRegistry {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
match event { match event {
Dispatch::Person(person) => { Dispatch::Person(person) => {
this.insert(person, cx); this.insert(*person, cx);
} }
Dispatch::Announcement(event) => { Dispatch::Announcement(event) => {
this.set_announcement(&event, cx); this.set_announcement(&event, cx);
@@ -175,7 +175,11 @@ impl PersonRegistry {
let metadata = Metadata::from_json(&event.content).unwrap_or_default(); let metadata = Metadata::from_json(&event.content).unwrap_or_default();
let person = Person::new(event.pubkey, metadata); 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"); log::warn!("Channel closed, dropping metadata event");
} }
} }
+7
View File
@@ -144,6 +144,13 @@ impl Person {
/// Set profile encryption keys announcement /// Set profile encryption keys announcement
pub(crate) fn set_announcement(&mut self, announcement: 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); self.announcement = Some(announcement);
} }
+14 -1
View File
@@ -5,6 +5,10 @@ use nostr_sdk::prelude::*;
pub struct Announcement { pub struct Announcement {
/// The public key of the device that created this announcement. /// The public key of the device that created this announcement.
public_key: PublicKey, 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 { impl Announcement {
@@ -16,11 +20,20 @@ impl Announcement {
.and_then(|tag| tag.content()) .and_then(|tag| tag.content())
.and_then(|c| PublicKey::parse(c).ok())?; .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. /// Returns the public key of the device that created this announcement.
pub fn public_key(&self) -> PublicKey { pub fn public_key(&self) -> PublicKey {
self.public_key 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)
}
} }
+29 -3
View File
@@ -89,8 +89,9 @@ with a documented [client profile](https://github.com/CodyTseng/psstpsst/blob/ma
## Status ## Status
Section A (items 1-5) is implemented in `crates/nip4e/src/lib.rs` and ### Section A (items 1-5) — implemented
`crates/state/src/credentials.rs`:
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 - 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 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 - An open approval is withdrawn when a `4455` addressed to the same client key
arrives (another device already answered). 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.