14 KiB
NIP-4e Audit Report
Update: The P0 findings (C1, C2, C3) are now fixed — seals are always signed by the identity key,
extract_rumorfalls back to the device key, theDEVICE_GIFTWRAPsubscription is created, and trashed wraps are retried onDeviceEvent::Set. The remaining findings (F1–F8, P2 items, UX bugs) are still open.
Scope: crates/nip4e/src/lib.rs (key announcement/sync), the send path (crates/chat/src/room.rs), the receive path (crates/chat/src/lib.rs), UI integration (crates/workspace), compared against the NIP-4e draft and PsstPsst's implementation (docs/protocols/nip4e.md + docs/protocols/nip17-extensions.md, plus rust-nostr's NIP-59 source to verify crypto mechanics).
Executive summary
The key-sync side (kinds 10044/4454/4455) is a reasonable sketch of the spec, but the actual message encryption is inverted and non-functional end-to-end:
- Sending seals/signs with the device encryption key (should be the identity key).
- Receiving unwraps gift wraps with the identity key (should be the device key when the wrap is addressed to your encryption key
E).
Every NIP-4e message between two Coop clients fails at decryption; messages to/from PsstPsst fail in both directions. On top of that there are ~10 sync/UX bugs that make even key distribution unreliable.
1. Critical — the message path is broken (P0)
rust-nostr's GiftWrapBuilder::finalize_async(signer) (verified against upstream source):
- Seal content =
nip44_encrypt(conversation_key(signer_sk, receiver_pk), rumor) - Seal is signed by
signer→seal.pubkey = signer_pk - Gift wrap =
nip44_encrypt(conversation_key(ephemeral_sk, receiver_pk), seal) - Unwrap requires the receiver's secret matching
receiver_pk; then rust-nostr rejects ifrumor.pubkey != seal.pubkey("sender mismatch").
NIP-4e draft says the sender keeps using their identity key (ecdh(b, E) — Bob's b) and only the target changes to E. So:
| # | Finding | Location |
|---|---|---|
| C1 | Send path signs the seal with the device key. select_signer returns the encryption signer, and GiftWrapBuilder::finalize_async(device_signer) signs the seal with E while rumor.pubkey = identity (Room::rumor uses current_user). Result: (a) receivers can't attribute the sender, (b) Coop's own rumor.pubkey != seal.pubkey check rejects it, (c) PsstPsst verifies the seal against the sender's identity key → invalid. |
crates/chat/src/room.rs:482-495, 590-596, 643-646; rumor pubkey at room.rs:404-417 |
| C2 | Receive path unwraps with the identity key. handle_notifications captures the user signer and passes it to extract_rumor → nip44_decrypt(ecdh(a, wrap_pubkey)). A 4e wrap is encrypted to your E, so decryption always fails → "Failed to extract rumor" → trash. The correct unwrap key for 4e messages is the device key (ecdh(e, wrap_pubkey)), then ecdh(e, seal.pubkey) for the rumor — both succeed with the device signer in rust-nostr's unwrapper. |
crates/chat/src/lib.rs:184, 252, 743-801 |
| C3 | The DEVICE_GIFTWRAP subscription is never created. The constant exists (state/src/constants.rs:24), its ID is used for EOSE matching (chat/src/lib.rs:190), but no code ever subscribe()s with it (presumably intended: #p = E filter on inbox relays). Only USER_GIFTWRAP (#p = A) exists. |
crates/chat/src/lib.rs:190-191, 410-418 |
Consequences:
| Path | Status |
|---|---|
| Coop → Coop (4e) | ❌ Fails at receiver wrap-decryption (C2); even with a device-key unwrap it fails the sender-mismatch check (C1) |
| Coop → PsstPsst | ❌ PsstPsst unwraps fine (wrap → E), but the seal signature doesn't verify against your identity key and there's no seal n tag → rejected |
| PsstPsst → Coop | ❌ Wrap routed via identity p tag arrives, but Coop unwraps with the identity key → fail (C2) |
| Coop → self backup copy | ❌ Same broken envelope, so cross-device history sync also fails |
Non-4e (SignerKind::User) |
✅ Works — the bug only bites when 4e is enabled, which is why it looks "half-baked" rather than fully broken |
Note on interop targets: the draft wants
seal signed by identity, content = ecdh(identity_sk, receiver_E)— which is exactlyGiftWrapBuilder::finalize_async(user_signer)withreceiver = E. PsstPsst's profile goes further: seal signed by identity plus anntag with the sender'sE, content encrypted withecdh(sender_e, receiver_E). So minimal correctness (Coop↔Coop) = swap the signers; PsstPsst interop additionally requires implementing their split-key envelope (ntag, sender-econtent key).
sequenceDiagram
participant S as Sender (Coop)
participant R as Receiver (Coop)
Note over S: TODAY (broken)<br/>seal signed by device key Es<br/>wrap → Er, unwrap with identity a
S->>R: gift wrap (→ Er)
R->>R: nip44_decrypt(a, wrap) ✗ FAILS
Note over S: CORRECT (draft)<br/>seal signed by identity As<br/>wrap → Er, unwrap with device e
S->>R: gift wrap (→ Er)
R->>R: nip44_decrypt(e, wrap) ✓<br/>nip44_decrypt(e, seal.pubkey) ✓
2. Key-sync bugs (P1)
| # | Finding | Location |
|---|---|---|
| F1 | No p-tag filter on 4455. wait_for_approval subscribes to all your 4455s (author=me, since=now). extract_encryption then tries to decrypt transfers meant for other devices → NIP-44 failure → DeviceEvent::Error → error notification. The approving device also picks up its own just-published 4455 and errors on it. Spec/PsstPsst: filter by p = own client key and ignore undecryptable responses. |
nip4e/src/lib.rs:476-482, 163-165, 489-526 |
| F2 | No validation of the received key. After decrypting a 4455, the key is installed without comparing derived_pubkey to the current 10044 n value. A stale 4455 (pre-rotation) silently installs a dead key. PsstPsst: "accepts the key only when that public key equals the current kind 10044 n value". |
nip4e/src/lib.rs:489-526 |
| F3 | since(Timestamp::now()) on both subscriptions. The approver misses all 4454s published while it was offline (the draft says Cope "looks for all 4454 events" — i.e. backfill). The requester likewise misses 4455s published while it was offline; recovery depends on LMDB having synced them. This is the main "works only when both apps are open at the same time" behavior. |
nip4e/src/lib.rs:388-394, 476-482 |
| F4 | pending_request is never reset to false. After the first approval in a session, every subsequent device request is silently dropped (ask_for_approval early-returns). |
nip4e/src/lib.rs:597-601 (no set_pending_request(false) anywhere) |
| F5 | No deletion after success. The draft explicitly says the requester deletes its 4454 + the received 4455 (NIP-09). Coop leaves them on relays forever, and combined with F6 below, stale events get re-applied on later launches. | absent |
| F6 | request() short-circuits on any old 4455 in the local DB (limit(1), no recency check, no decrypt-check before committing to it) — stale after rotation. |
nip4e/src/lib.rs:414-422 |
| F7 | 4454/4455 tag deviations vs PsstPsst profile: no n tag (which key is requested) or relay hints on 4454; no identity p tag on 4455; P tag in 4455 is the device key E instead of a freshly generated client key (c per the draft). The last one is mostly cosmetic (recipients read P either way) but links every transfer to your announcement. |
nip4e/src/lib.rs:426-432, 559-565 |
| F8 | Self-request race. set_encryption races the 10044 echo against the local set_keys write: if the announcement you just published comes back before the DB save completes, get_keys fails → the master publishes a 4454 asking itself, then shows "You've requested an encryption key from another device" about its own profile. |
nip4e/src/lib.rs:342-375 vs 335 |
3. Robustness / state management (P2)
announcement_existednever resets and the notification loop resolvescurrent_useronce at startup (nip4e/src/lib.rs:144) — after an account switch, 10044/4454/4455 filtering uses the stale pubkey, and a new account without an announcement never getsNotSet.NotSetafter a fixed 5s timer, not EOSE — slow relays produce false "no key" prompts (nip4e/src/lib.rs:269-283).get_announcementruns on every settings change (including theme changes) — each call pushes 2 more tasks intoself.tasks(never drained, grows all session) and re-creates thewait_for_requestsubscription on everySet(nip4e/src/lib.rs:100-116, 295).- Publish-before-save:
create_encryptionpublishes 10044 then saves locally; ifset_keysfails, the key exists only in memory while 10044 advertises it → permanent unlockable requests (nip4e/src/lib.rs:317-338). - Unbounded
processed_eventsHashSet in the 4e notification loop (chat's version prunes at 10k; this one doesn't).
4. Security considerations
- One-click approval with no out-of-band check. Anyone who can get a 4454 signed by your identity (i.e., any of your devices — fine) is approved by client-name alone. PsstPsst requires comparing an 8-hex-char authorization code derived from the requester's client key. Given this event transfers your master encryption key, an approval-code comparison is worth copying.
backup()writes the bech32 secret key to a plaintext file (nip4e/src/lib.rs:222-240). Consider at least warning in the UI.Announcement::fromfalls back toval.pubkeywhen thentag is missing/malformed (state/src/nip4e.rs:14-23) → a malformed 10044 makesdeliver()believe 4e is available and produce hybrid messages signed with the device key but wrapped to the identity — worst of both worlds. PsstPsst treats a missingnas "not a 4e peer".- Plaintext-encrypted device key sits in kind-30078 local DB events — local-only, acceptable.
5. UI/UX bugs
| # | Finding | Location |
|---|---|---|
| U1 | The "Retry" button is a key-rotation footgun. On NotSet, the notification's action calls set_announcement(Keys::generate()). If the NotSet was a false negative (F-timer) and an announcement does exist, clicking "Retry" silently rotates your encryption key, breaking decryption on all other devices until each re-requests. The label says retry; the code creates a new key. |
workspace/src/lib.rs:130-140 |
| U2 | Inverted empty-check in restore: if !content.is_empty() { set_error("cannot be empty") } — errors when the input is not empty, then falls through and reports "invalid" for empty input. |
workspace/src/dialogs/restore.rs:54-56 |
| U3 | Restore/reset republish a 10044 with a new key without warning that other devices will need to re-request (reset dialog warns about message loss — good — but restore doesn't warn at all). | workspace/src/lib.rs:466-473 |
| U4 | Every spurious decrypt failure from F1 surfaces as a persistent error notification. | workspace/src/lib.rs:152-154 |
6. What's already correct
- 10044 construction matches the draft (
ntag, replaceable kind, extraclienttag is harmless). - 4454 tags (
P,client) match the draft; request/response events are correctly signed by the identity key, so requests are authenticated. - Client keys are generated per install and stored in the OS keyring — reasonable.
- Local key escrow (kind 30078, NIP-44 self-encrypted,
d = "coop:device", never published) is a sound design; theIDENTIFIERvalue surviving the crate rename is fine (changing it would orphan stored keys). - Dedup of relay echoes, opt-in
nip4e: falsedefault, andAutomode degrading to the identity path when no announcement exists (this fallback is why normal DMs still work).
7. Recommended fix roadmap (ordered)
- P0 — fix the envelope (C1+C2+C3), Coop↔Coop:
- Send: always sign the seal with the identity signer (
GiftWrapBuilder::finalize_async(user_signer)withreceiver = announcement E). The device key is not needed to send. - Receive: create the
DEVICE_GIFTWRAPsubscription (#p = E) on the user's inbox relays; inextract_rumor, unwrap with the device signer for wraps addressed toE(fall back to identity for wraps addressed toA); re-process trash whenDeviceEvent::Setarrives. - Keep
rumor.pubkey = identity— the sender-mismatch check then passes by construction.
- Send: always sign the seal with the identity signer (
- P0.5 — decide the interop target: if PsstPsst interop matters, adopt their split-key envelope (seal
ntag = senderE, seal content encrypted withecdh(sender_e, receiver_E), seal signed by identity). rust-nostr's builder can't express that split today — you'd build the seal manually. - P1 — sync hardening: add
p-tag filters, validate decrypted key against the current 10044n, replacesince(now)with a startup backfill query, resetpending_request, delete 4454/4455 after success, ignore (don't error on) transfers for other devices, and save locally before publishing. - P2 — UX: fix the Retry button to actually retry (
get_announcement) with a separate explicit "Create key" action; fix the inverted restore check; add an approval code comparison before sharing the key. - P3 — state hygiene: reset
announcement_existed+ re-resolvecurrent_useron signer change, driveNotSetoff EOSE, bound the task vec/dedup set.
Validation status: audit only, no code changed; the workspace compiled cleanly at the last cargo check. The rust-nostr NIP-59 behavior was verified against upstream master source (the workspace pins nostr-sdk to that git repo; the seal-content/signing behavior has been stable across recent versions).