# NIP-4e: psstpsst implementation comparison A comparison of Coop's NIP-4e implementation (`crates/nip4e`) against the [psstpsst](https://github.com/CodyTseng/psstpsst) client, which implements the draft [NIP-4e (per-device keys)](https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md) with a documented [client profile](https://github.com/CodyTseng/psstpsst/blob/master/docs/protocols/nip4e.md). ## 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 `4455`s. 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 6. **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`). 7. **Missing identity `p` tag on `4455`.** psstpsst emits `p` = identity plus `p` = requester client key; Coop has only the client-key `p`. 8. **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 9. **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. 10. **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 11. **No tests** for `crates/nip4e`. psstpsst has dedicated suites for the transfer flow, watcher, session, store, and approval sheet. 12. **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.