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:
| Issue | NIP-04 | NIP-44 |
|---|---|---|
| Authentication | None | AEAD (Poly1305) |
| Padding | PKCS7 (predictable) | Random length |
| Key derivation | SHA256(ECDH) | HKDF with salt |
| Message integrity | None | MAC 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
| Feature | NIP-04 | NIP-44 |
|---|---|---|
| Cipher | AES-256-CBC | ChaCha20-Poly1305 |
| Nonce size | 16 bytes | 32 bytes |
| Authentication | None | Poly1305 MAC |
| Key derivation | SHA256 | HKDF |
| Padding | PKCS7 | Random length |
| Security | ❌ Weak | ✅ Strong |
Implementation Checklist
- Use version byte
0x02 - Generate 32-byte random nonce per message
- Use HKDF with
nip44-v2salt - Pad messages before encryption
- Verify MAC before processing decrypted content
- Handle decryption failures gracefully
Common Errors
| Error | Cause | Fix |
|---|---|---|
| Invalid MAC | Wrong key or tampered | Verify conversation key |
| Decode error | Wrong base64 | Check encoding |
| Version mismatch | Old/new format | Check version byte |
| Padding error | Corrupted message | Re-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"
]
}