Files
coop/docs/nip4e-psstpsst-comparison.md
T
2026-11-04 08:42:18 +07:00

7.0 KiB

NIP-4e: psstpsst implementation comparison

A comparison of Coop's NIP-4e implementation (crates/nip4e) against the psstpsst client, which implements the draft NIP-4e (per-device keys) with a documented client profile.

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 4455s.

  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

  1. 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).

  2. Missing identity p tag on 4455. psstpsst emits p = identity plus p = requester client key; Coop has only the client-key p.

  3. 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

  1. 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.

  2. 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

  1. No tests for crates/nip4e. psstpsst has dedicated suites for the transfer flow, watcher, session, store, and approval sheet.

  2. 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.