nip4e: catch up psstpsst #70
+293
-24
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ impl EventEmitter<PersonEvent> for PersonRegistry {}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Dispatch {
|
||||
Person(Person),
|
||||
Person(Box<Person>),
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user