Chapter 8
Private messages
Envelopes inside envelopes: how NIP-17 hides who talks to whom.
Psst! Come closer. This chapter is about secrets, so I brought envelopes. Lots of envelopes.
Everything you’ve seen so far is public on purpose: notes, likes, follow lists. But sometimes Alice
just wants to tell Bob where to meet. Nostr has no private servers to hide behind. Every
That’s harder than it sounds, because a secret has two parts:
- What you said: the words.
- Everything around it: who sent it, who it’s for, when, and roughly how long it was.
That second part is called
Three generations of secret notes
Nostr’s private messages evolved in three steps. Think of them as:
- NIP-04: a postcard with a padlock on the text. Everyone can still read the address, the sender and the postmark.
- NIP-44: a much better padlock. Still a postcard.
- NIP-17: the postcard goes in a signed envelope, and that envelope goes inside a plain brown parcel posted by a stranger.
Here is the same message sent all three ways, with real
The envelope lab
Alice sends Bob the same message three ways. Compare what a relay can read, then try to open each envelope with different keys.
What the relay sees
This is everything on the outside of the event, readable by any relay or snoop.
5 pieces of metadata leak in plain sight.
- Sender Leaked Signed by Alice: everyone knows who wrote it.
- Recipient Leaked The p tag names Bob, so the relay can deliver it.
- Sent at Leaked Exact moment: Oct 3, 2026, 7:56 PM.
- Message type Leaked Kind 4: screams “this is a DM!”.
- Message size Leaked 64 bytes of ciphertext for a 55-byte message: the size gives it away.
Try to open it
- Encrypted DM kind 4 Locked
Sender, recipient and time are on the outside.
JSON
{ "kind": 4 ,"created_at": 1791057408 , "tags": [ [ ] ] ,"content": "QcHTYEvkAW+QVixVz9jHh3vS+gGsJqsuz5COZT5k/Sq8XbGWv3Tor43ApeujHk2GPK/FD8vRSEqvFpHyCVkazg==?iv=HAGq5kmgqis+msDjNfowXQ==" ,"pubkey": "e550c6e92808f358382b7b8c14345344f5ef9a299529834d1c26d57b6ace44cc" ,"id": "0dd22b9915d02492eeb5a06646640501341b4c229342543dd676ade537c7c781" ,"sig": "71f6441422bdf9590a169c1ad8d3447547f266582a65927c4dacebd12aa610a6f83ad6469bfa0bdceba134c5660581d866c09312b2fbe5a83040fdd51d6e9990" }
Fresh randomness: new nonce, new timestamps, and for NIP-17 a brand-new throwaway key.
Things to try:
- Compare the red badges in the relay’s view as you go from NIP-04 to NIP-17.
- Pick Carol and try to open the envelope. Her key is a perfectly good key, just not the right one.
- On NIP-17, press Send it again a few times and watch the outer sender change every time.
Step 1: NIP-04, the leaky postcard
Alice then scrambles her text with that secret and publishes a kind 4 event. The words are hidden, but look at what’s left on the outside:
pubkey: Alice’s real .public key Public key: Your public identity: a 32-byte secp256k1 key, shown as 64 hex characters or as an npub. Safe to share.- a
p : Bob’s public key, so the relay knows where to deliver it.tag Tag: An array of strings attached to an event, such as ["p", <pubkey>] to mention someone or ["e", <event id>] to reply to a note. created_at: the exact second she hit send.- the ciphertext length, which tracks the message length to within 16 bytes.
Anyone can draw a map of who talks to whom, when, and how much. That’s why NIP-04 is officially deprecated: the spec itself carries a warning that it leaks metadata and is nowhere near the state of the art.
NIP-04 has no integrity check. If Carol tries her key, she usually gets an error, but sometimes she gets random garbage that looks like a successful decryption. A good scheme should always say “this isn’t for you”.
Under the hood: a NIP-04 event Show details
A kind 4 event. Only content is encrypted; everything else is public:
{
"kind": 4,
"pubkey": "<alice's pubkey>",
"created_at": 1735689600,
"tags": [["p", "<bob's pubkey>"]],
"content": "J7d9…base64 ciphertext…==?iv=AQEBAQEBAQEBAQEBAQEBAQ==",
"id": "…",
"sig": "…"
}- Key: the raw x-coordinate of the ECDH shared point, used directly as an AES-256 key (no hashing, no key derivation).
- Cipher: AES-256-CBC with a random 16-byte IV, and no MAC, so tampering and wrong keys aren’t reliably detected.
- Format:
base64(ciphertext) + "?iv=" + base64(iv).
Spec: NIP-04 (deprecated in favor of NIP-17).
import * as nip04 from "nostr-tools/nip04";
// Legacy only: read old DMs, don't send new ones.
const content = nip04.encrypt(aliceSecretKey, bobPubkey, "Meet me at 9");
const text = nip04.decrypt(bobSecretKey, alicePubkey, content);Step 2: NIP-44, a lock worth trusting
- Tamper-proof: every blob carries an authentication code. Change one bit, or use the wrong key, and decryption refuses loudly instead of handing back garbage.
- Fresh keys per message: a random nonce makes every message use its own key material, so sending the same text twice never gives the same blob.
- Padding: messages are rounded up into size buckets, so “yes” and “no way!” look identical.
Try it: type something short, then something a bit longer, and watch which sizes stay the same.
Can a relay guess your message by its size?
Type a message and watch how many bytes each scheme reveals. NIP-44 rounds sizes up into buckets so short messages look alike.
| Scheme | Visible size |
|---|---|
| Real message | 3 bytes |
| NIP-04 ciphertext | 16 bytes |
| NIP-44 padded | 32 bytes |
Under NIP-44, every message from 1 to 32 bytes looks exactly this big.
But notice what didn’t change in the lab when you switched from NIP-04 to NIP-44: if you put a NIP-44 blob into the same kind of event, Alice’s key, Bob’s tag and the timestamp are still on the outside. A better lock doesn’t fix a see-through envelope.
Under the hood: NIP-44 v2, step by step Show details
- Shared secret: ECDH between Alice’s secret key and Bob’s public key (x-coordinate only).
- Conversation key:
HKDF-extract(SHA-256, ikm = shared_x, salt = "nip44-v2"). Same for both directions. - Message keys: pick a random 32-byte nonce, then
HKDF-expand(conversation_key, info = nonce, 76)and split it into a ChaCha20 key (32 bytes), ChaCha20 nonce (12) and HMAC key (32). - Pad: prefix the UTF-8 text with its length (big-endian u16) and zero-pad to the bucket size (minimum 32 bytes, then powers of two split into chunks).
- Encrypt the padded bytes with ChaCha20.
- Authenticate:
HMAC-SHA256(hmac_key, nonce ‖ ciphertext). - Payload:
base64(0x02 ‖ nonce ‖ ciphertext ‖ mac); the leading0x02is the version.
What it does not give you: forward secrecy (a stolen secret key opens every past message) and any metadata protection. Spec: NIP-44.
import * as nip44 from "nostr-tools/nip44";
const conversationKey = nip44.getConversationKey(aliceSecretKey, bobPubkey);
const payload = nip44.encrypt("Meet me at 9", conversationKey);
// Bob derives the very same key from his side:
const text = nip44.decrypt(payload, nip44.getConversationKey(bobSecretKey, alicePubkey));Step 3: NIP-17, envelopes all the way down
- The rumor (kind 14): the actual chat message, written by Alice. It is a normal event with an
id, but no signature. That’s on purpose: if it ever leaks, nobody can prove Alice wrote it.
A
, literally.rumor Rumor: An event with an id but no signature: the innermost content of a gift-wrapped message. - The seal (kind 13): the rumor, encrypted to Bob and signed by Alice. This is how Bob knows
it’s really her. The
has no tags at all, so it doesn’t say who it’s for.seal Seal: The middle layer of a gift-wrapped message: a kind 13 event signed by the real sender that contains the encrypted rumor. - The gift wrap (kind 1059): the seal, encrypted again and signed by a brand-new random key
that’s used once and thrown away. It carries a single
ptag so the relay can deliver it to Bob.
The relay only ever sees layer 3. The sender is a stranger who will never post again, the timestamp is deliberately fuzzed (up to two days in the past), the real kind is hidden, and the size is padded.
Go back to the lab, pick NIP-17, and peel it as Bob: wrap, then seal, then rumor. When the seal opens, your browser checks that the seal’s signer is the same key as the rumor’s author, otherwise anyone could forge a message “from Alice”.
The wrap must say who it’s for, so the relay learns that Bob received something. NIP-17 asks
relays to only hand gift wraps to the person they’re addressed to, after Bob proves who he is with
Under the hood: the three layers in JSON Show details
Rumor, kind 14, unsigned (no sig):
{
"id": "…",
"pubkey": "<alice's pubkey>",
"created_at": 1735689600,
"kind": 14,
"tags": [["p", "<bob's pubkey>"]],
"content": "Meet me at 9"
}Seal, kind 13, signed by Alice, tags must be empty:
{
"kind": 13,
"pubkey": "<alice's pubkey>",
"created_at": 1735601234,
"tags": [],
"content": "<NIP-44(rumor JSON), Alice → Bob>",
"id": "…",
"sig": "…"
}Gift wrap, kind 1059, signed by a one-time random key:
{
"kind": 1059,
"pubkey": "<random throwaway pubkey>",
"created_at": 1735555555,
"tags": [["p", "<bob's pubkey>"]],
"content": "<NIP-44(seal JSON), throwaway key → Bob>",
"id": "…",
"sig": "…"
}Rules worth knowing:
- Receivers must check that the seal’s
pubkeyequals the rumor’spubkey. - Seal and wrap
created_atshould be randomized up to two days in the past. - The sender also gift-wraps a copy to themselves, so their own sent messages can be recovered on any device. Group chats wrap one copy per participant.
- Kind 15 is the file-message sibling of kind 14;
subjecttags name a conversation. - DMs go to the relays in the recipient’s kind 10050 list.
Specs: NIP-17 (private DMs) and NIP-59 (gift wrap).
import * as nip17 from "nostr-tools/nip17";
// One call builds rumor → seal → wrap with a fresh throwaway key.
const wrap = nip17.wrapEvent(aliceSecretKey, { publicKey: bobPubkey }, "Meet me at 9");
// Bob peels both layers and gets the kind 14 rumor back.
const rumor = nip17.unwrapEvent(wrap, bobSecretKey);
console.log(rumor.kind, rumor.content); // 14 "Meet me at 9"NIP-17 hides a lot, but it isn’t a full secure messenger: there’s no forward secrecy, so if your
Quick check
Check your envelopes
Takeaways
- Encrypting the text is not enough: who, when and how big can say as much as what.
- NIP-04 is deprecated: it hides the words but leaves sender, recipient, time and size in plain sight.
- NIP-44 is the modern lock (ChaCha20 + HMAC + padding), but on its own it doesn't hide metadata.
- NIP-17 nests a kind 14 rumor inside a kind 13 seal inside a kind 1059 gift wrap signed by a throwaway key.
- The relay still learns the recipient, which is why DM relays should only hand wraps to their owner after AUTH.