SHORTWAVE

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 1059

The 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

LayerHides from relayNotes
NIP-44 (seal)Message plaintextAlso hides sender identity (encrypted under ephemeral key in wrap)
NIP-59 (gift-wrap)Sender pubkey, event kind, tagsRandomised created_at ±2 days hides timing
Multi-relay publishSingle point of censorshipFirst 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