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 {
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<Self>) {
pub(crate) fn request(
&mut self,
expected_encryption: Option<PublicKey>,
cx: &mut Context<Self>,
) {
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<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");
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<EventId> {
.collect()
}
/// Extract the requester client key from a key request's `P` tag
fn client_pubkey_from_request(event: &Event) -> Option<PublicKey> {
event
/// Extract the client public key from an event.
fn client_pubkey_from_event(event: &Event) -> Option<PublicKey> {
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<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.
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<Keys, Error> {
let master = event
.tags
.iter()
.find(|tag| tag.kind() == "P")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Invalid event's tags")?;
let 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<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));
}
}