Files
coop/docs/nip4e-psstpsst-comparison.md
T

5.4 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) is 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).

Items 6-12 remain open.