NIP-nip-04 Final
NIP-04: Encrypted Direct Messages
Legacy encryption standard for Nostr DMs. AES-256-CBC encryption with ECDH key exchange.
| Type | Nostr Implementation Possibility |
| Number | nip-04 |
| Status | Final |
| Original | https://github.com/nostr-protocol/nips/blob/master/04.md |
NIP-04: Encrypted Direct Messages
Status: Final (but deprecated for new implementations)
⚠️ Use NIP-44 instead for new implementations. NIP-04 has known security weaknesses. This documentation is for compatibility with existing systems.
NIP-04 defines encrypted direct messages using AES-256-CBC with ECDH key exchange.
Security Issues
| Issue | Impact |
|---|---|
| No authentication | Ciphertext can be modified |
| Metadata exposed | Sender/recipient visible |
| Predictable padding | Length information leaks |
| No forward secrecy | Past messages compromised if key leaks |
Event Format
{
"kind": 4,
"pubkey": "sender-pubkey",
"created_at": 1234567890,
"tags": [["p", "recipient-pubkey"]],
"content": "base64-ciphertext?iv=base64-iv",
"sig": "..."
}
Encryption Process
1. ECDH Shared Secret
from secp256k1 import PrivateKey, PublicKey
def get_shared_secret(my_privkey: str, their_pubkey: str) -> bytes:
"""Derive shared secret via ECDH."""
priv = PrivateKey(bytes.fromhex(my_privkey))
pub = PublicKey(bytes.fromhex("02" + their_pubkey), raw=True)
shared_point = pub.tweak_mul(priv.private_key)
# Use x-coordinate as shared secret
return shared_point.serialize()[1:33]
2. Encrypt Message
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
import base64
import os
def encrypt_nip04(message: str, shared_secret: bytes) -> str:
"""Encrypt a message using NIP-04."""
# Random 16-byte IV
iv = os.urandom(16)
# AES-256-CBC encryption
cipher = AES.new(shared_secret, AES.MODE_CBC, iv)
padded = pad(message.encode('utf-8'), 16)
ciphertext = cipher.encrypt(padded)
# Format: base64(ciphertext)?iv=base64(iv)
ct_b64 = base64.b64encode(ciphertext).decode()
iv_b64 = base64.b64encode(iv).decode()
return f"{ct_b64}?iv={iv_b64}"
3. Create DM Event
def create_dm(sender_privkey: str, recipient_pubkey: str, message: str) -> dict:
"""Create an encrypted DM event."""
# Get shared secret
shared = get_shared_secret(sender_privkey, recipient_pubkey)
# Encrypt
encrypted = encrypt_nip04(message, shared)
# Create event
sender_pubkey = derive_pubkey(sender_privkey)
event = {
"kind": 4,
"pubkey": sender_pubkey,
"created_at": int(time.time()),
"tags": [["p", recipient_pubkey]],
"content": encrypted
}
return sign_event(event, sender_privkey)
Decryption Process
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import base64
def decrypt_nip04(encrypted: str, shared_secret: bytes) -> str:
"""Decrypt a NIP-04 message."""
# Parse content
parts = encrypted.split("?iv=")
if len(parts) != 2:
raise ValueError("Invalid NIP-04 format")
ciphertext = base64.b64decode(parts[0])
iv = base64.b64decode(parts[1])
# Decrypt
cipher = AES.new(shared_secret, AES.MODE_CBC, iv)
padded = cipher.decrypt(ciphertext)
plaintext = unpad(padded, 16)
return plaintext.decode('utf-8')
def read_dm(event: dict, my_privkey: str) -> str:
"""Read an encrypted DM."""
my_pubkey = derive_pubkey(my_privkey)
# Determine the other party
if event["pubkey"] == my_pubkey:
# I sent this, decrypt with recipient's pubkey
other_pubkey = get_tag(event, "p")
else:
# Sent to me, decrypt with sender's pubkey
other_pubkey = event["pubkey"]
# Get shared secret
shared = get_shared_secret(my_privkey, other_pubkey)
# Decrypt
return decrypt_nip04(event["content"], shared)
JavaScript Implementation
import { nip04, finalizeEvent } from 'nostr-tools';
// Encrypt
const ciphertext = await nip04.encrypt(senderSK, recipientPK, message);
// Create event
const event = finalizeEvent({
kind: 4,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', recipientPK]],
content: ciphertext
}, senderSK);
// Decrypt
const plaintext = await nip04.decrypt(recipientSK, senderPK, event.content);
Querying DMs
Messages Sent to Me
{
"kinds": [4],
"#p": ["my-pubkey"]
}
Messages I Sent
{
"kinds": [4],
"authors": ["my-pubkey"]
}
All My DMs
async def get_all_dms(my_pubkey: str, relays: list) -> list:
"""Get all DMs involving me."""
# Received
received_filter = {"kinds": [4], "#p": [my_pubkey]}
# Sent
sent_filter = {"kinds": [4], "authors": [my_pubkey]}
received = await query_events(relays, received_filter)
sent = await query_events(relays, sent_filter)
all_dms = received + sent
return sorted(all_dms, key=lambda e: e["created_at"])
Migration to NIP-44
Check Encryption Version
def detect_encryption_version(content: str) -> str:
"""Detect if content is NIP-04 or NIP-44."""
if "?iv=" in content:
return "nip-04"
else:
# NIP-44 uses base64 without ?iv=
try:
payload = base64.b64decode(content)
if payload[0] == 2: # NIP-44 version byte
return "nip-44"
except:
pass
return "unknown"
Support Both
def decrypt_dm(event: dict, my_privkey: str) -> str:
"""Decrypt DM supporting both NIP-04 and NIP-44."""
content = event["content"]
other_pubkey = get_other_party(event, my_privkey)
version = detect_encryption_version(content)
if version == "nip-04":
shared = get_shared_secret_nip04(my_privkey, other_pubkey)
return decrypt_nip04(content, shared)
elif version == "nip-44":
conv_key = get_conversation_key_nip44(my_privkey, other_pubkey)
return decrypt_nip44(content, conv_key)
else:
raise ValueError("Unknown encryption format")
When to Use NIP-04
Still use NIP-04 for:
- Reading legacy DMs
- Compatibility with old clients
- NIP-47 (Wallet Connect) — uses NIP-04
Use NIP-44 for:
- All new DM implementations
- When security matters
- Gift-wrapped messages (NIP-59)
Machine-Readable Summary
{
"nip": 4,
"title": "Encrypted Direct Messages",
"status": "final",
"deprecated": true,
"replacement": "nip-44",
"defines": [
"dm-event-format",
"aes-cbc-encryption",
"ecdh-key-exchange"
],
"event_kind": 4,
"encryption": {
"algorithm": "aes-256-cbc",
"key_exchange": "ecdh-secp256k1",
"iv_size": 16
},
"security_issues": [
"no-authentication",
"metadata-exposure",
"predictable-padding"
],
"related": [
"/learn/nostr/dm",
"/learn/nostr/specs/nip-44",
"/learn/nostr/specs/nip-59"
]
}