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
-
Long-lived client key instead of ephemeral per-exchange keys. psstpsst generates a throwaway client keypair for every
4454/4455exchange and never persists it: a4455lives on relays forever, so reusing one client key means a single leak decrypts every key transfer ever addressed to it. Coop'sget_or_init_app_keyspersists one app key incredentialsand reuses it forever. -
The
4455is encrypted with the long-lived encryption key, not a fresh sender keypair. The draft (step 10) has the responder generate a fresh keypaircand encrypt toecdh(c, T), withPcarryingC. psstpsst follows this. Coop encrypts with the encryption key itself and itsPtag carriesE, so anyone holding the encryption private key — today or after a future rotation/compromise — can decrypt all historical4455s. -
No durable processed-request log. Coop's dedup is an in-memory
HashSetplus a singlepending_request: bool. psstpsst persists every handled4454id so approvals don't re-fire after restart and a device can recognize its own requests even after its client key is gone. -
Announcement-match is skipped when the local DB has no announcement.
decrypt_approvalsilently 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. -
No cross-device resolution detection. psstpsst lets a second existing device see a
4455already addressed to the requester's client key and withdraw its redundant approval, avoiding publishing the secret twice. Coop'spending_requestbool means two devices can both approve.
B. Protocol/interop
-
Missing
4454tags psstpsst sends:relayhints (the responder replies to shared relays; without them the4455may never reach the requester), optionaln(the exact requested key), and thepubkeycompatibility alias (psstpsst accepts bothPandpubkey; Coop reads onlyP). -
Missing identity
ptag on4455. psstpsst emitsp= identity plusp= requester client key; Coop has only the client-keyp. -
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 peer10044via a plain subscription incrates/chat.
C. Features
-
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.
-
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
-
No tests for
crates/nip4e. psstpsst has dedicated suites for the transfer flow, watcher, session, store, and approval sheet. -
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.
4455responses encrypt to a fresh one-time responder keypair carried in thePtag, matching the draft and psstpsst.- Bounded, persisted log of handled
4454ids; duplicate requests (same client key) are recorded without re-prompting, replaced requests (different client key) withdraw the stale sheet. decrypt_approvalfalls 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
4455addressed 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'sPtag plus thepubkeycompatibility alias,relayhints (the requester's NIP-65 write relays), and an optionalntag pinning the exact encryption key the requester observed in its announcement. Responder readers acceptPorpubkeyon both4454and4455. - Responder honors the request (
approve): the identityptag is added to4455for account inbox routing; the response is published to the union of the responder's NIP-65 write relays and the request's normalizedrelayhints (each hint parsed as untrusted input), falling back to the NIP-65 policy when no relays are known; when the request pins annkey 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):Announcementnow carries the event id and creation time, andis_newer_thanimplements the psstpsst ordering (newercreated_atwins, ties go to the lexicographically smaller event id).Person::set_announcementonly replaces an existing announcement with a newer one, so delayed or out-of-order10044events cannot roll a peer's key back. Freshness remains subscription-based: peer10044is 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.