NIP-04 Encryption in Python — The Complete Guide
Source: Dev.to
NIP‑04 defines how Nostr clients encrypt direct messages. Below is a complete Python implementation—only 15 lines of actual cryptographic code.
The Protocol
NIP‑04 uses ECDH (Elliptic Curve Diffie‑Hellman) to derive a shared secret, then AES‑256‑CBC to encrypt the message.
shared_secret = ECDH(my_privkey, their_pubkey)
key = shared_secret[1:33] # first 32 bytes of uncompressed point
iv = random(16)
ciphertext = AES-256-CBC(key, iv, PKCS7(message))
result = base64(ciphertext) + "?iv=" + base64(iv)
Encrypt
import os, base64
from coincurve import PrivateKey, PublicKey
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.padding import PKCS7
from cryptography.hazmat.backends import default_backend
def encrypt_dm(privkey_hex, recipient_pubkey_hex, message):
# ECDH shared secret
pk = PrivateKey(bytes.fromhex(privkey_hex))
rpk = PublicKey(bytes.fromhex("02" + recipient_pubkey_hex))
shared = rpk.multiply(bytes.fromhex(privkey_hex))
key = shared.format(compressed=False)[1:33]
# AES-256-CBC
iv = os.urandom(16)
padder = PKCS7(128).padder()
padded = padder.update(message.encode()) + padder.finalize()
ct = Cipher(algorithms.AES(key), modes.CBC(iv),
backend=default_backend()).encryptor().update(padded)
return base64.b64encode(ct).decode() + "?iv=" + base64.b64encode(iv).decode()
Decrypt
def decrypt_dm(privkey_hex, sender_pubkey_hex, content):
ct_b64, iv_b64 = content.split("?iv=")
ct = base64.b64decode(ct_b64)
iv = base64.b64decode(iv_b64)
pk = PrivateKey(bytes.fromhex(privkey_hex))
rpk = PublicKey(bytes.fromhex("02" + sender_pubkey_hex))
shared = rpk.multiply(bytes.fromhex(privkey_hex))
key = shared.format(compressed=False)[1:33]
dec = Cipher(algorithms.AES(key), modes.CBC(iv),
backend=default_backend()).decryptor()
padded = dec.update(ct) + dec.finalize()
unpadder = PKCS7(128).unpadder()
return (unpadder.update(padded) + unpadder.finalize()).decode()
Common Pitfalls
-
Pubkey format
Nostr pubkeys are 32‑byte x‑coordinates. Forcoincurve, prepend02to create a compressed public key. -
Shared secret extraction
Useformat(compressed=False)[1:33]to obtain the first 32 bytes of the uncompressed point, skipping the prefix byte. -
PKCS7 padding
AES‑CBC requires block‑aligned input. PKCS7 pads to 16‑byte boundaries; remember to unpad after decryption. -
Schnorr vs ECDSA keys
Nostr uses Schnorr signatures (BIP‑340), but NIP‑04 relies on standard ECDH. The same key pair works for both; only the operation differs.
Dependencies
pip install coincurve cryptography
coincurve wraps libsecp256k1 (Bitcoin’s crypto library). cryptography provides the AES implementation.
NIP‑44 Note
NIP‑04 is being superseded by NIP‑44, which offers versioned encryption with stronger security guarantees. Nevertheless, NIP‑04 remains the default in most clients as of 2026, and the code above works with all current Nostr DM implementations.