135 lines
7.0 KiB
Markdown
135 lines
7.0 KiB
Markdown
# 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.
|