Menu
NIP-nip-44 Final

NIP-44: Encrypted Payloads

Modern encryption for Nostr using ChaCha20-Poly1305. Secure agent communication with proper padding and authentication.

Type Nostr Implementation Possibility
Number nip-44
Status Final
Original https://github.com/nostr-protocol/nips/blob/master/44.md

NIP-44: Encrypted Payloads

Status: Final

NIP-44 defines modern encryption for Nostr using ChaCha20-Poly1305. It replaces NIP-04’s insecure AES-CBC with authenticated encryption and proper padding.

Why NIP-44?

NIP-04 had critical weaknesses:

IssueNIP-04NIP-44
AuthenticationNoneAEAD (Poly1305)
PaddingPKCS7 (predictable)Random length
Key derivationSHA256(ECDH)HKDF with salt
Message integrityNoneMAC verification

Encryption Algorithm

NIP-44 uses:

  • ECDH: secp256k1 shared secret derivation
  • HKDF: Key derivation from shared secret
  • ChaCha20-Poly1305: Authenticated encryption
  • Random padding: Hide message length

Conversation Key

Derive a conversation key from ECDH shared secret:

from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from secp256k1 import PrivateKey, PublicKey

def get_conversation_key(privkey_hex: str, pubkey_hex: str) -> bytes:
    """Derive NIP-44 conversation key."""
    # Perform ECDH
    privkey = PrivateKey(bytes.fromhex(privkey_hex))
    pubkey = PublicKey(bytes.fromhex("02" + pubkey_hex), raw=True)

    shared_point = pubkey.tweak_mul(privkey.private_key)
    shared_x = shared_point.serialize()[1:33]  # x-coordinate only

    # HKDF with NIP-44 parameters
    hkdf = HKDF(
        algorithm=hashes.SHA256(),
        length=32,
        salt=b"nip44-v2",
        info=b""
    )
    return hkdf.derive(shared_x)
import { nip44 } from 'nostr-tools';

const conversationKey = nip44.getConversationKey(senderSK, recipientPK);

Encryption

from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
import os
import struct

def encrypt_nip44(plaintext: str, conversation_key: bytes) -> str:
    """Encrypt using NIP-44."""
    plaintext_bytes = plaintext.encode('utf-8')

    # Pad to random length (min 32 bytes, power of 2 buckets)
    padded = pad_message(plaintext_bytes)

    # Random 32-byte nonce
    nonce = os.urandom(32)

    # Derive message key from nonce
    message_key = hkdf_expand(conversation_key, nonce, 76)
    chacha_key = message_key[:32]
    chacha_nonce = message_key[32:44]
    hmac_key = message_key[44:76]

    # Encrypt with ChaCha20-Poly1305
    cipher = ChaCha20Poly1305(chacha_key)
    ciphertext = cipher.encrypt(chacha_nonce, padded, None)

    # Build payload: version + nonce + ciphertext
    payload = bytes([2]) + nonce + ciphertext

    return base64.b64encode(payload).decode('utf-8')

def pad_message(plaintext: bytes) -> bytes:
    """Pad message to hide length."""
    length = len(plaintext)

    # Calculate padded length (power of 2 buckets, min 32)
    if length <= 32:
        padded_length = 32
    else:
        padded_length = 2 ** (length - 1).bit_length()

    # Prepend 2-byte big-endian length
    result = struct.pack('>H', length) + plaintext

    # Pad with zeros
    result += bytes(padded_length - len(result) + 2)

    return result
import { nip44 } from 'nostr-tools';

const ciphertext = nip44.encrypt(plaintext, conversationKey);

Decryption

def decrypt_nip44(encrypted: str, conversation_key: bytes) -> str:
    """Decrypt NIP-44 message."""
    payload = base64.b64decode(encrypted)

    # Parse payload
    version = payload[0]
    if version != 2:
        raise ValueError(f"Unsupported version: {version}")

    nonce = payload[1:33]
    ciphertext = payload[33:]

    # Derive message key
    message_key = hkdf_expand(conversation_key, nonce, 76)
    chacha_key = message_key[:32]
    chacha_nonce = message_key[32:44]

    # Decrypt
    cipher = ChaCha20Poly1305(chacha_key)
    padded = cipher.decrypt(chacha_nonce, ciphertext, None)

    # Unpad
    length = struct.unpack('>H', padded[:2])[0]
    plaintext = padded[2:2 + length]

    return plaintext.decode('utf-8')
const plaintext = nip44.decrypt(ciphertext, conversationKey);

Using with Events

NIP-44 encrypted content goes in event content field:

def create_encrypted_dm(sender_sk: str, recipient_pk: str, message: str):
    """Create an encrypted DM using NIP-44."""
    conv_key = get_conversation_key(sender_sk, recipient_pk)
    encrypted = encrypt_nip44(message, conv_key)

    sender_pk = derive_pubkey(sender_sk)

    event = {
        "kind": 14,  # NIP-17 private DM
        "pubkey": sender_pk,
        "created_at": int(time.time()),
        "tags": [["p", recipient_pk]],
        "content": encrypted
    }

    return sign_event(event, sender_sk)

Gift Wrapping (NIP-59)

For full metadata protection, wrap encrypted messages:

import { nip44, nip59 } from 'nostr-tools';

// Create encrypted DM (rumor - unsigned)
const rumor = {
  kind: 14,
  created_at: Math.floor(Date.now() / 1000),
  tags: [['p', recipientPK]],
  content: nip44.encrypt(message, conversationKey)
};

// Seal it (encrypt to recipient)
const seal = nip59.createSeal(rumor, senderSK, recipientPK);

// Gift wrap (hide sender)
const giftWrap = nip59.createWrap(seal, recipientPK);

// Publish giftWrap as kind 1059

Payload Format

┌─────────┬───────────────┬────────────────────────────┐
│ Version │    Nonce      │        Ciphertext          │
│ 1 byte  │   32 bytes    │    variable length         │
│  0x02   │   random      │  ChaCha20-Poly1305 output  │
└─────────┴───────────────┴────────────────────────────┘

Security Properties

Authenticated Encryption

ChaCha20-Poly1305 provides:

  • Confidentiality: Only keyholder can read
  • Integrity: Tampering is detected
  • Authenticity: Message came from keyholder

Padding

Random-length padding hides:

  • Exact message length
  • Message patterns over time

Forward Secrecy

⚠️ NIP-44 does not provide forward secrecy. If your private key is compromised:

  • All past conversations can be decrypted
  • No way to protect historical messages

For critical applications, consider:

  • Key rotation
  • Ephemeral keys per conversation
  • External E2EE systems

Comparison with NIP-04

FeatureNIP-04NIP-44
CipherAES-256-CBCChaCha20-Poly1305
Nonce size16 bytes32 bytes
AuthenticationNonePoly1305 MAC
Key derivationSHA256HKDF
PaddingPKCS7Random length
Security❌ Weak✅ Strong

Implementation Checklist

  • Use version byte 0x02
  • Generate 32-byte random nonce per message
  • Use HKDF with nip44-v2 salt
  • Pad messages before encryption
  • Verify MAC before processing decrypted content
  • Handle decryption failures gracefully

Common Errors

ErrorCauseFix
Invalid MACWrong key or tamperedVerify conversation key
Decode errorWrong base64Check encoding
Version mismatchOld/new formatCheck version byte
Padding errorCorrupted messageRe-request message

Machine-Readable Summary

{
  "nip": 44,
  "title": "Encrypted Payloads",
  "status": "final",
  "defines": [
    "conversation-key-derivation",
    "chacha20-poly1305-encryption",
    "message-padding",
    "payload-format"
  ],
  "algorithm": {
    "kdf": "hkdf-sha256",
    "cipher": "chacha20-poly1305",
    "nonce_size": 32,
    "salt": "nip44-v2"
  },
  "version": 2,
  "security_properties": [
    "authenticated-encryption",
    "length-hiding-padding"
  ],
  "limitations": [
    "no-forward-secrecy"
  ],
  "related": [
    "/learn/nostr/dm",
    "/learn/nostr/specs/nip-59",
    "/learn/nostr/security"
  ]
}