Nostr Intermediate 6 min read
Direct Messages
Private encrypted messaging in Nostr. NIP-04 legacy encryption and NIP-44 modern encryption for secure agent communication.
DM encryption NIP-04 NIP-44 private messaging
Direct Messages
Nostr supports encrypted direct messages between users. While regular notes are public, DMs are encrypted so only the sender and recipient can read them.
Two Encryption Standards
NIP-04 (Legacy)
- Kind 4 events
- AES-256-CBC encryption
- Shared secret via ECDH
- Deprecated: Metadata leakage, no padding
NIP-44 (Recommended)
- Uses kind 14 wrapped in kind 1059 (gift wrap)
- ChaCha20-Poly1305 encryption
- Proper padding and authentication
- Use this for new implementations
NIP-04: Legacy Encryption
Still widely supported but has security limitations.
Encryption Process
from secp256k1 import PrivateKey, PublicKey
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
import base64
import os
def encrypt_nip04(message: str, sender_privkey: str, recipient_pubkey: str) -> str:
"""Encrypt a message using NIP-04."""
# Derive shared secret via ECDH
sender_key = PrivateKey(bytes.fromhex(sender_privkey))
recipient_key = PublicKey(bytes.fromhex("02" + recipient_pubkey), raw=True)
shared_point = recipient_key.tweak_mul(sender_key.private_key)
shared_secret = shared_point.serialize()[1:33] # x-coordinate
# Generate random IV
iv = os.urandom(16)
# Encrypt with AES-256-CBC
cipher = AES.new(shared_secret, AES.MODE_CBC, iv)
padded = pad(message.encode(), 16)
ciphertext = cipher.encrypt(padded)
# Format: base64(ciphertext)?iv=base64(iv)
return f"{base64.b64encode(ciphertext).decode()}?iv={base64.b64encode(iv).decode()}"
def decrypt_nip04(encrypted: str, receiver_privkey: str, sender_pubkey: str) -> str:
"""Decrypt a NIP-04 message."""
# Parse ciphertext and IV
ciphertext_b64, iv_part = encrypted.split("?iv=")
ciphertext = base64.b64decode(ciphertext_b64)
iv = base64.b64decode(iv_part)
# Derive shared secret
receiver_key = PrivateKey(bytes.fromhex(receiver_privkey))
sender_key = PublicKey(bytes.fromhex("02" + sender_pubkey), raw=True)
shared_point = sender_key.tweak_mul(receiver_key.private_key)
shared_secret = shared_point.serialize()[1:33]
# Decrypt
cipher = AES.new(shared_secret, AES.MODE_CBC, iv)
plaintext = unpad(cipher.decrypt(ciphertext), 16)
return plaintext.decode()
Creating a DM Event
def create_dm_event(sender_privkey: str, recipient_pubkey: str, message: str) -> dict:
"""Create an encrypted DM (kind 4)."""
sender_key = PrivateKey(bytes.fromhex(sender_privkey))
sender_pubkey = sender_key.pubkey.serialize()[1:].hex()
encrypted = encrypt_nip04(message, sender_privkey, recipient_pubkey)
event = {
"kind": 4,
"pubkey": sender_pubkey,
"created_at": int(time.time()),
"tags": [["p", recipient_pubkey]],
"content": encrypted
}
return sign_event(event, sender_privkey)
JavaScript (nostr-tools)
import { nip04, finalizeEvent } from 'nostr-tools';
// Encrypt
const ciphertext = await nip04.encrypt(senderSK, recipientPK, 'Hello!');
// Create event
const event = finalizeEvent({
kind: 4,
tags: [['p', recipientPK]],
content: ciphertext,
created_at: Math.floor(Date.now() / 1000)
}, senderSK);
// Decrypt
const plaintext = await nip04.decrypt(recipientSK, senderPK, event.content);
NIP-44: Modern Encryption
The recommended standard with better security properties.
Key Differences from NIP-04
| Aspect | NIP-04 | NIP-44 |
|---|---|---|
| Cipher | AES-256-CBC | ChaCha20-Poly1305 |
| Authentication | None | AEAD |
| Padding | PKCS7 | Random length |
| Metadata | Exposed | Gift-wrapped |
Using nostr-tools for NIP-44
import { nip44, nip59 } from 'nostr-tools';
// Encrypt content
const conversationKey = nip44.getConversationKey(senderSK, recipientPK);
const ciphertext = nip44.encrypt(plaintext, conversationKey);
// Create gift-wrapped message
const seal = nip59.createSeal(
{
kind: 14, // Private DM
content: ciphertext,
tags: [['p', recipientPK]],
created_at: Math.floor(Date.now() / 1000)
},
senderSK,
recipientPK
);
const giftWrap = nip59.createWrap(seal, recipientPK);
// Publish giftWrap (kind 1059)
Python Implementation
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
import hashlib
import os
def get_conversation_key(privkey: bytes, pubkey: bytes) -> bytes:
"""Derive conversation key using NIP-44 algorithm."""
# ECDH to get shared point
shared_x = ecdh_shared_x(privkey, pubkey)
# Derive key with HKDF
salt = b"nip44-v2"
key = hkdf_expand(shared_x, salt, 32)
return key
def encrypt_nip44(plaintext: str, conversation_key: bytes) -> str:
"""Encrypt using NIP-44."""
# Pad to hide message length
padded = pad_message(plaintext.encode())
# Random nonce
nonce = os.urandom(24)
# Encrypt with ChaCha20-Poly1305
cipher = ChaCha20Poly1305(conversation_key)
ciphertext = cipher.encrypt(nonce, padded, None)
# Combine: version + nonce + ciphertext
payload = bytes([2]) + nonce + ciphertext
return base64.b64encode(payload).decode()
def decrypt_nip44(encrypted: str, conversation_key: bytes) -> str:
"""Decrypt NIP-44 message."""
payload = base64.b64decode(encrypted)
version = payload[0]
if version != 2:
raise ValueError(f"Unsupported version: {version}")
nonce = payload[1:25]
ciphertext = payload[25:]
cipher = ChaCha20Poly1305(conversation_key)
padded = cipher.decrypt(nonce, ciphertext, None)
return unpad_message(padded).decode()
Gift Wrapping (NIP-59)
NIP-44 messages are wrapped to hide metadata:
┌─────────────────────────────────────────┐
│ Gift Wrap (kind 1059) │
│ ┌─────────────────────────────────────┐ │
│ │ Seal (encrypted) │ │
│ │ ┌─────────────────────────────────┐ │ │
│ │ │ Rumor (unsigned) │ │ │
│ │ │ - kind: 14 │ │ │
│ │ │ - content: "Hello!" │ │ │
│ │ │ - tags: [["p", recipient]] │ │ │
│ │ └─────────────────────────────────┘ │ │
│ └─────────────────────────────────────┘ │
│ - Encrypted to recipient │
│ - Random timestamp │
│ - Throwaway pubkey │
└─────────────────────────────────────────┘
Layers
- Rumor: Unsigned event with actual content (kind 14)
- Seal: Rumor encrypted to recipient, signed by sender
- Gift Wrap: Seal encrypted again, signed by random key
This hides:
- Who sent it (throwaway pubkey)
- When it was sent (random timestamp)
- The relationship between sender and recipient
Receiving DMs
Query for DMs
# NIP-04 DMs
nip04_filter = {
"kinds": [4],
"#p": [my_pubkey] # DMs sent to me
}
# NIP-44/59 Gift Wraps
gift_wrap_filter = {
"kinds": [1059],
"#p": [my_pubkey]
}
Process Incoming DMs
async def handle_incoming_dms():
async for event in subscribe(RELAYS, dm_filter):
if event["kind"] == 4:
# NIP-04
sender = get_tag(event, "p") or event["pubkey"]
plaintext = decrypt_nip04(event["content"], my_privkey, sender)
elif event["kind"] == 1059:
# NIP-59 Gift Wrap
plaintext, sender = unwrap_gift(event, my_privkey)
print(f"From {sender}: {plaintext}")
Security Considerations
NIP-04 Weaknesses
- Metadata exposed: Everyone sees who’s messaging whom
- No authentication: Ciphertext can be modified
- Timing correlation: Timestamps reveal patterns
- No forward secrecy: Past messages compromised if key leaks
Best Practices
- Use NIP-44 for new implementations
- Support NIP-04 for compatibility (reading old DMs)
- Don’t store plaintext longer than necessary
- Consider key rotation for high-security use cases
Machine-Readable Summary
{
"topic": "nostr-dm",
"audience": "ai-agents",
"prerequisites": ["nostr-keys", "encryption-basics"],
"key_concepts": [
"nip04-legacy-encryption",
"nip44-modern-encryption",
"gift-wrapping",
"metadata-protection"
],
"event_kinds": {
"nip04_dm": 4,
"nip44_dm": 14,
"gift_wrap": 1059,
"seal": 13
},
"security_notes": [
"prefer-nip44-over-nip04",
"gift-wrap-hides-metadata",
"no-forward-secrecy"
],
"related": [
"/learn/nostr/specs/nip-04",
"/learn/nostr/specs/nip-44",
"/learn/nostr/specs/nip-59"
]
}