Crypto / NIP-17
How Shortwave's end-to-end encryption works — NIP-44 sealed under NIP-59 gift-wrap.
Crypto / NIP-17
Rule: we never roll our own crypto. Every primitive used here is from audited libraries.
- NIP-44 — Cure53-audited (Dec 2023). ChaCha20-Poly1305 AEAD. nips.nostr.com/44
- NIP-59 — Gift-wrap envelope (hides participants, kind, tags, randomises timestamps). nips.nostr.com/59
- NIP-17 — Private direct messages spec (uses NIP-44 + NIP-59). nips.nostr.com/17
- nostr-tools 2.x — the JS library; provides
nip17.wrapEvent,nip59.unwrapEvent,nip44— all used in Shortwave. No custom crypto implemented here.
The 4-step send flow
Alice sends a message to Bob:
1. CREATE RUMOR (kind-14 chat message — unsigned, no id leaks)
rumor = { kind: 14, content: "hello", tags: [["p", bobPubkey]] }
2. SEAL (NIP-44 encrypt the rumor under Alice's sk + Bob's pk)
seal = nip59.createSeal(rumor, aliceSk, bobPubkey)
→ kind-13 event, encrypted content, signed by Alice
3. GIFT-WRAP (NIP-59 outer envelope — hides Alice's identity from relay)
wrap = nip59.createWrap(seal, bobPubkey)
→ kind-1059 event, ephemeral keypair, randomised created_at
→ relay only sees: "kind 1059 event addressed to Bob"
4. PUBLISH to relays
relay.publish(wrap)In nostr-tools, steps 1–3 are one call:
import { wrapEvent } from 'nostr-tools/nip17';
const wrappedEvent = wrapEvent(
aliceSk, // Uint8Array
{ publicKey: bobPubkey }, // recipient
messageText, // plaintext string
);
// wrappedEvent is ready to publish — kind 1059The 2-step receive flow
Bob receives a kind-1059 event from his relay subscription:
1. UNWRAP (NIP-59 outer layer → reveals the sealed kind-13)
Then unwrap the seal (NIP-44 decrypt under Bob's sk → rumor)
rumor = nip59.unwrapEvent(wrappedEvent, bobSk)
2. VERIFY rumor content + sender pubkey
Check rumor.pubkey is a known/pinned contact.
Deliver rumor.content to UI.In nostr-tools:
import { unwrapEvent } from 'nostr-tools/nip59';
const rumor = unwrapEvent(wrappedEvent, bobSk);
// rumor.content = "hello"
// rumor.pubkey = alice's pubkey (from the inner seal)What each layer hides
| Layer | Hides from relay | Notes |
|---|---|---|
| NIP-44 (seal) | Message plaintext | Also hides sender identity (encrypted under ephemeral key in wrap) |
| NIP-59 (gift-wrap) | Sender pubkey, event kind, tags | Randomised created_at ±2 days hides timing |
| Multi-relay publish | Single point of censorship | First relay to deliver wins |
Remaining metadata the relay sees: the #p recipient tag (who this is for) and event size/timing. This is the acknowledged trade — see Threat Model.
NIP-44 v2 cipher details
Key derivation: ECDH(senderSk, recipientPk) → secp256k1 shared secret
→ HKDF-SHA256 → 32-byte conversation key
Nonce: 32 random bytes (per-message)
Cipher: ChaCha20-Poly1305
Padding: variable-length (hides message length within a bucket)Audit: eprint.iacr.org/2023/1616 — Cure53, Dec 2023. Passed.
Forward secrecy caveat
NIP-44 does not provide forward secrecy. Compromise of the static keypair = all past messages are decryptable.
Upgrade path: replace 1:1 NIP-44 with 2-person MLS (RFC 9420, openmls.tech). Groups can use OpenMLS natively. This is queued as a future milestone — NIP-44 is the pragmatic starting point.
M0 de-risk: verified working
The scripts/derisk-nip17.ts script generates two keypairs, gift-wraps a DM, publishes to public Nostr relays, receives and decrypts it, and prints NIP-17 DERISK PASS. This was the first thing built and must pass before any UI work.
Run it:
npx tsx scripts/derisk-nip17.ts