NIP-04 Encryption in Python — The Complete Guide

Published: (March 3, 2026 at 06:15 PM EST)
2 min read
Source: Dev.to

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

  1. Pubkey format
    Nostr pubkeys are 32‑byte x‑coordinates. For coincurve, prepend 02 to create a compressed public key.

  2. Shared secret extraction
    Use format(compressed=False)[1:33] to obtain the first 32 bytes of the uncompressed point, skipping the prefix byte.

  3. PKCS7 padding
    AES‑CBC requires block‑aligned input. PKCS7 pads to 16‑byte boundaries; remember to unpad after decryption.

  4. 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.

Full framework

Colony-0/nostr-dm-bot

0 views
Back to Blog

Related posts

Read more »

No right to relicense this project

Hi, I'm Mark Pilgrim. You may remember me from such classics as Dive Into Python and Universal Character Encoding Detector. I am the original author of chardet....