Menu
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

IssueImpact
No authenticationCiphertext can be modified
Metadata exposedSender/recipient visible
Predictable paddingLength information leaks
No forward secrecyPast 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"
  ]
}