Chapter 44 - Encryption
44
Nostr Implementation Possibility - Hex № 44

Encrypted Payloads (Versioned)

A versioned encryption format for keypair-to-keypair payloads: ECDH, HKDF, ChaCha20, HMAC-SHA256, padding, and base64, audited and test-vectored.

1 Overview

This NIP defines a data format for keypair-based encryption. Two users derive a shared secret from their keys. The sender encrypts a payload with that secret. The recipient decrypts it with the same secret. Nobody else can read it.

The format is versioned. The first byte of every payload names an encryption algorithm suite. This lets multiple algorithm choices exist at the same time. Today only version 2 is defined and usable.

The format may carry many things. It MUST be used inside a signed event as defined in NIP-01. The outer Schnorr signature authenticates the full payload. This NIP defines no event kinds. It is only the encryption layer that a messaging standard can build on. It SHOULD NOT be used as a drop-in replacement for NIP-04 payloads.

Hint!

Version 2 of this format was audited by Cure53 in December 2023. The audit report is published as audit-2023.12.pdf in the paulmillr/nip44 repository and on the auditor's website. Reference implementations in many languages live in the same repository.

2 Versions

The first decoded byte of a payload is the version. Three values are defined:

Version bytes
VersionStatusAlgorithms
0x00ReservedNone. Do not use.
0x01Deprecated and undefinedNone. Do not use.
0x02Currentsecp256k1 ECDH - HKDF - padding - ChaCha20 - HMAC-SHA256 - base64

A payload that starts with the character # signals a future non-base64 encoding. When a decoder sees an unknown version or a leading #, it MUST report "encryption version not supported". It must not report a base64 error.

3 Threat Model and Limitations

Every Nostr user has a public key. This solves the key distribution problem of other systems. But the relay architecture makes strong private-messaging features hard: metadata hiding, forward secrecy, and post-compromise secrecy are out of scope here.

The goal of this NIP is a simple way to encrypt payloads inside a signed event. Weigh your users' threat model before you apply it. For high-risk situations, users should chat in specialized E2EE messaging software. They should limit Nostr to exchanging contacts.

On its own, this scheme has these shortcomings:

  • No deniability. Anyone can prove that a particular key signed an event.
  • No forward secrecy. A compromised key decrypts all previous conversations.
  • No post-compromise security. A compromised key decrypts all future conversations.
  • No post-quantum security. A powerful quantum computer could decrypt the messages.
  • IP address leak. Relays and intermediaries can see the user's IP address.
  • Date leak. The created_at field of a NIP-01 event is public.
  • Limited message size leak. Padding only partially hides the true message length.
  • No attachments. The format does not support them.
Warning!

Lack of forward secrecy is only partially mitigated. Send messages to trusted relays only. Ask relays to delete stored messages after a set duration.

4 Version 2 Design Choices

Version 2 makes deliberate trade-offs. Each primitive was chosen for a reason:

Why these algorithms
ChoiceInstead ofReason
MAC before signingMAC afterEvents are already signed per NIP-01. The outer signature authenticates the full payload. Validate it before you decrypt.
ChaCha20AESChaCha is faster and has better security against multi-key attacks.
ChaCha20XChaChaXChaCha is not standardized. Its improved nonce collision resistance is unnecessary: every message gets a fresh (key, nonce) pair.
HMAC-SHA256Poly1305Polynomial MACs are much easier to forge.
SHA256SHA3 / BLAKESHA256 is already used in Nostr. BLAKE's speed advantage shrinks in non-parallel environments.
Custom paddingpadméBetter leakage reduction for small messages.
Base64other encodingsWidely available and already used in Nostr.

5 Key Derivation

Two key derivation stages exist. The conversation key is long-term and shared by one pair of users. The message keys are unique per message.

Conversation key

Execute ECDH: multiply public key B by private key a (scalar multiplication as defined in BIP-340). The output shared_x must be the unhashed 32-byte encoded x coordinate of the shared point. Then run HKDF-extract with SHA256, IKM=shared_x and salt=utf8_encode('nip44-v2'). The output is the conversation_key. It is symmetric: conv(a, B) == conv(b, A).

Validate both keys per BIP-340. The public key must be a valid non-zero point on the secp256k1 curve. The private key must be a scalar in the range [1, secp256k1_order - 1].

Warning!

NIP-44 does not hash the ECDH output. Some libraries hash it with sha256 by default. In libsecp256k1, the unhashed version is available as secp256k1_ec_pubkey_tweak_mul.

Message keys

Generate a random 32-byte nonce for every message. Always use a CSPRNG. Do not derive the nonce from message content. Do not reuse a nonce between messages: reuse makes those messages decryptable, though it does not leak the long-term key.

Validate that conversation_key and nonce are both 32 bytes. Run HKDF-expand with SHA256, PRK=conversation_key, info=nonce, and L=76. Slice the 76-byte output:

Message key slices (HKDF-expand output, 76 bytes)
SliceBytesSizeUsed for
chacha_key0..3232 bytesChaCha20 encryption key
chacha_nonce32..4412 bytesChaCha20 nonce
hmac_key44..7632 bytesHMAC-SHA256 key
priv a - pub B ECDH HKDF-extract salt "nip44-v2" HKDF-expand info=nonce, L=76 chacha_key 32B chacha_nonce 12B hmac_key 32B nonce 32B CSPRNG conversation_key
Fig. 44-1 — Key derivation. ECDH plus HKDF-extract makes the long-term conversation key. HKDF-expand with a fresh nonce makes the per-message keys.

6 Padding

Padding hides part of the true message length. Encode the content from UTF-8 into a byte array first. Then validate the plaintext length: minimum 1 byte, maximum 4,294,967,295 bytes.

The padded form is a length prefix, the plaintext, then zero bytes. The prefix is big-endian and has two shapes:

  • Length below 65536: 2-byte u16 prefix. Format: [plaintext_length: u16][plaintext][zero_bytes].
  • Length 65536 or greater: 6-byte prefix, 2 zero bytes plus a u32. Format: [0x00, 0x00][plaintext_length: u32][plaintext][zero_bytes].

A zero value in the first 2 bytes signals the extended format. This is unambiguous: valid plaintext is at least 1 byte, so a u16 length of 0 is otherwise invalid.

The padded size follows a powers-of-two rule with a minimum of 32 bytes. Messages of 32 bytes or less pad to exactly 32 bytes. Larger messages round up to the next multiple of a chunk. The chunk is 32 bytes while the next power of two is at most 256; above that, the chunk is one eighth of the next power of two.

Pseudocode - padding
# Calculates length of the padded byte array.
def calc_padded_len(unpadded_len):
  next_power = 1 << (floor(log2(unpadded_len - 1))) + 1
  if next_power <= 256: chunk = 32
  else:                 chunk = next_power / 8
  if unpadded_len <= 32: return 32
  else: return chunk * (floor((unpadded_len - 1) / chunk) + 1)

# Converts unpadded plaintext to padded bytearray.
def pad(plaintext):
  unpadded = utf8_encode(plaintext)
  unpadded_len = len(unpadded)
  if (unpadded_len < c.min_plaintext_size or
      unpadded_len > c.max_plaintext_size): raise 'invalid plaintext length'
  if unpadded_len >= c.extended_prefix_threshold:
    prefix = concat([0, 0], write_u32_be(unpadded_len))  # 6 bytes
  else:
    prefix = write_u16_be(unpadded_len)                  # 2 bytes
  suffix = zeros(calc_padded_len(unpadded_len) - unpadded_len)
  return concat(prefix, unpadded, suffix)

# Converts padded bytearray to unpadded plaintext.
def unpad(padded):
  first_two = read_uint16_be(padded[0:2])
  if first_two == 0:
    unpadded_len = read_uint32_be(padded[2:6])
    if unpadded_len < c.extended_prefix_threshold: raise 'invalid padding'
    prefix_len = 6
  else:
    unpadded_len = first_two
    prefix_len = 2
  unpadded = padded[prefix_len : prefix_len + unpadded_len]
  if (unpadded_len == 0 or
      len(unpadded) != unpadded_len or
      len(padded) != prefix_len + calc_padded_len(unpadded_len)): raise 'invalid padding'
  return utf8_decode(unpadded)

Note that unpad rejects an extended prefix that carries a length below 65536. Encoding a small length in the 6-byte form is invalid padding.

Warning!

calc_padded_len can return values up to 232. That exceeds the range of unsigned 32-bit integers. Use 64-bit or larger arithmetic for padding calculations.

7 Encryption

Follow these seven steps to encrypt a message from user A to user B:

  1. Calculate the conversation key. ECDH of public key B by private key a, then HKDF-extract with salt nip44-v2. See §5.
  2. Generate a random 32-byte nonce from a CSPRNG. Never derive it from content. Never reuse it.
  3. Calculate the message keys. Validate both inputs are 32 bytes. HKDF-expand to 76 bytes; slice into chacha_key, chacha_nonce, hmac_key. See §5.
  4. Add padding. UTF-8 encode, validate the length (1 to 4,294,967,295 bytes), prepend the length prefix, append zero bytes. See §6.
  5. Encrypt the padded content with ChaCha20, using the key and nonce from step 3.
  6. Calculate the MAC. Use AAD (additional authenticated data): compute HMAC-SHA256 over the concatenation of nonce and ciphertext, not over the ciphertext alone. Validate that the AAD (the nonce) is 32 bytes.
  7. Base64-encode (with padding) the value concat(version, nonce, ciphertext, mac).

The encrypted payload MUST then be placed in an event, hashed, and signed as defined in NIP-01, using the Schnorr signature scheme over secp256k1.

base64( ... ) 0x02 nonce ciphertext mac 1 byte 32 bytes len(padded plaintext) 32 bytes metadata overhead is always 65 bytes - MAC covers nonce + ciphertext
Fig. 44-2 — Payload layout. Version, nonce, ciphertext, and MAC are concatenated, then base64-encoded with padding.

8 Decryption

Before decryption, validate the event's pubkey and sig as defined in NIP-01. The public key MUST be a valid non-zero secp256k1 curve point. The signature must be a valid secp256k1 Schnorr signature. For exact validation rules, refer to BIP-340. Then follow these seven steps, in this order:

  1. Check the first character. If it is #, the payload uses a future non-base64 encoding. # is not in the base64 alphabet. Do not report a base64 error; report that the encryption version is not yet supported.
  2. Decode base64 into version, nonce, ciphertext, mac. If the version is unknown, report that the version is not supported. First validate the minimum base64 length: at least 132 characters. This prevents DoS on the base64 decoder. Then validate the decoded length: at least 99 bytes.
  3. Calculate the conversation key. Same as encryption step 1 (§5).
  4. Calculate the message keys. Same as encryption step 3 (§5).
  5. Calculate the MAC with AAD and compare. Compute HMAC-SHA256 over concat(nonce, ciphertext). Compare it to the decoded MAC with a constant-time algorithm. If it does not match, stop and throw an error.
  6. Decrypt the ciphertext with ChaCha20, using the key and nonce from step 4.
  7. Remove the padding. Read the first 2 bytes as a big-endian u16. If zero, read the next 4 bytes as a big-endian u32 plaintext length (6-byte prefix total). Otherwise, use the u16 as the plaintext length (2-byte prefix total). Verify that the sliced plaintext matches the decoded length. Verify that the total padded length equals prefix length plus calc_padded_len of the plaintext length.
NIP-01 sig valid? Decode version + length MAC constant-time ChaCha20 decrypt unpad verify any failed check stops decryption with an error
Fig. 44-3 — Validation order. Signature first, then decode checks, then the MAC, and only then decryption and unpadding.

Constants and limits

Limits (version 2)
NameValueMeaning
min_plaintext_size1 byteA 1-byte message pads to 32 bytes.
max_plaintext_size4,294,967,295 bytes232 - 1, about 4 GB.
extended_prefix_threshold65,536Lengths below use a 2-byte u16 prefix. Lengths at or above use the 6-byte prefix.
min padded size32 bytesSmallest padded plaintext.
metadata size65 bytesVersion (1) + nonce (32) + MAC (32). Constant overhead.
min base64 length132 charsReject shorter payloads before base64 decoding.
min decoded length99 bytes65 metadata + 34 (32 padded + 2 prefix). Reject shorter decoder output.
raw payload (small)99 to 65,603 bytes65 + 34 up to 65 + 65,536 + 2, for plaintext below 65,536.
raw payload (large)65,607 to 4,294,967,367 bytes65 + 65,542 up to 65 + 232 + 6, for plaintext at or above 65,536.

9 Implementation Notes

The theoretical maximum plaintext is about 4 GB. Implementations SHOULD enforce their own maximum payload size based on platform and resource limits. Reject oversized payloads early, in decode_payload before base64 decoding, to prevent denial of service. Decryption can need several times the payload size in working memory: base64 decoding, byte array slicing, and padding all make copies. JVM systems are limited to about 2 GB per contiguous array. Mobile devices may have much less memory.

Primitives and helper functions

The specification is written against these named methods:

Cryptographic methods
MethodDefinition
secure_random_bytes(length)Fetches randomness from a CSPRNG.
hkdf(IKM, salt, info, L)HKDF (RFC 5869) with SHA256, split into hkdf_extract(IKM, salt) and hkdf_expand(OKM, info, L).
chacha20(key, nonce, data)ChaCha20 (RFC 8439) with the starting counter set to 0.
hmac_sha256(key, message)HMAC (RFC 2104) with SHA256.
secp256k1_ecdh(priv_a, pub_b)Multiplication of point B by scalar a, per BIP-340. Encode the shared point's 32-byte x coordinate with bytes(P) from BIP-340. Output is unhashed.
Helper functions and operators
FunctionDefinition
x[i:j]Byte-slice operator: a (j - i)-byte copy from byte i (inclusive) to byte j (exclusive).
base64_encode / base64_decodeBase64 (RFC 4648), with padding.
concatByte array concatenation.
is_equal_ct(a, b)Constant-time equality check of two byte arrays.
utf8_encode / utf8_decodeString to byte array and back.
write_u8(number)Restricts to 0..255; encodes as a big-endian uint8 byte array.
write_u16_be(number)Restricts to 0..65535; encodes as a big-endian uint16 byte array.
write_u32_be(number)Restricts to 0..4294967295; encodes as a big-endian uint32 byte array.
read_uint16_be(bytes)Reads 2 bytes as a big-endian unsigned 16-bit integer.
read_uint32_be(bytes)Reads 4 bytes as a big-endian unsigned 32-bit integer.
zeros(length)Byte array of the given non-negative length, filled with zeros.
floor / log2The well-known mathematical functions.
Tech Note

The source spec defines the slice operator x[i:j] "where i, j <= 0". That condition is a typo: indexes must satisfy 0 <= i <= j. Also, decryption step 7 tells you to verify padding against "step 3 of the encryption process"; padding is encryption step 4. Step 3 derives message keys.

Pseudocode - encrypt / decrypt core
def decode_payload(payload):
  plen = len(payload)
  if plen == 0 or payload[0] == '#': raise 'unknown version'
  if plen < 132: raise 'invalid payload size'
  data = base64_decode(payload)
  dlen = len(data)
  if dlen < 99: raise 'invalid data size'
  vers = data[0]
  if vers != 2: raise 'unknown version'
  nonce      = data[1:33]
  ciphertext = data[33 : dlen - 32]
  mac        = data[dlen - 32 : dlen]
  return (nonce, ciphertext, mac)

def hmac_aad(key, message, aad):
  if len(aad) != 32: raise 'AAD associated data must be 32 bytes'
  return hmac(sha256, key, concat(aad, message))

# Long-term key between A and B: get_key(Apriv, Bpub) == get_key(Bpriv, Apub)
def get_conversation_key(private_key_a, public_key_b):
  shared_x = secp256k1_ecdh(private_key_a, public_key_b)
  return hkdf_extract(IKM=shared_x, salt=utf8_encode('nip44-v2'))

# Unique per-message keys
def get_message_keys(conversation_key, nonce):
  if len(conversation_key) != 32: raise 'invalid conversation_key length'
  if len(nonce) != 32: raise 'invalid nonce length'
  keys = hkdf_expand(OKM=conversation_key, info=nonce, L=76)
  return (keys[0:32], keys[32:44], keys[44:76])

def encrypt(plaintext, conversation_key, nonce):
  (chacha_key, chacha_nonce, hmac_key) = get_message_keys(conversation_key, nonce)
  padded     = pad(plaintext)
  ciphertext = chacha20(key=chacha_key, nonce=chacha_nonce, data=padded)
  mac        = hmac_aad(key=hmac_key, message=ciphertext, aad=nonce)
  return base64_encode(concat(write_u8(2), nonce, ciphertext, mac))

def decrypt(payload, conversation_key):
  (nonce, ciphertext, mac) = decode_payload(payload)
  (chacha_key, chacha_nonce, hmac_key) = get_message_keys(conversation_key, nonce)
  calculated_mac = hmac_aad(key=hmac_key, message=ciphertext, aad=nonce)
  if not is_equal_ct(calculated_mac, mac): raise 'invalid MAC'
  padded_plaintext = chacha20(key=chacha_key, nonce=chacha_nonce, data=ciphertext)
  return unpad(padded_plaintext)

10 Worked Example

The transcript below encrypts the one-byte message a. It uses a published test vector, so every intermediate value is reproducible. The nonce is fixed only for the test; real nonces come from a CSPRNG.

Test vector - encrypt "a" from sec1 to sec2
# inputs
sec1  = 0000000000000000000000000000000000000000000000000000000000000001
sec2  = 0000000000000000000000000000000000000000000000000000000000000002
nonce = 0000000000000000000000000000000000000000000000000000000000000001

→ conversation_key = get_conversation_key(sec1, pub2)
← c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d

→ payload = encrypt("a", conversation_key, nonce)
← AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtD
  KfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb

# "a" is 1 byte -> padded to 32 bytes (2-byte prefix + 1 + 29 zeros = 34)
# decoded payload = 65 metadata + 34 ciphertext = 99 bytes, the minimum

→ decrypt(payload, get_conversation_key(sec2, pub1))
← "a"

The full test vector file contains many more cases, including invalid ones. Instead of embedding the file, the spec pins its checksum:

nip44.vectors.json - sha256
269ed0f69e4c192512cc779e78c555090cebc7c785b609e338a62afc3ce25040  nip44.vectors.json

The vector file groups cases by purpose:

Test vector groups
GroupWhat it checks
valid.get_conversation_keyCalculate conversation_key from secret key sec1 and public key pub2.
valid.get_message_keysCalculate chacha_key, chacha_nonce, hmac_key from conversation_key and nonce.
valid.calc_padded_lenTake an unpadded length; calculate the padded length.
valid.encrypt_decryptEmulate a real conversation: derive keys both ways, encrypt, verify the payload, decrypt, verify the plaintext.
valid.encrypt_decrypt_long_msgSame, but plaintext and payload are given as checksums.
invalid.encrypt_msg_lengthsPlaintext lengths that must be rejected.
invalid.get_conversation_keyKey pairs where conversation-key calculation must throw an error.
invalid.decryptPayloads where decryption must throw an error.

Extended length prefix boundary

Three vectors exercise the boundary between the 2-byte and 6-byte prefixes. They reuse the conversation_key and nonce above. The plaintext is the byte 0x61 (a) repeated. The payloads are too large to inline, so sha256 checksums are given, following the encrypt_decrypt_long_msg pattern:

Extended prefix test vectors
plaintext_lenprefixpadded_lenplaintext_sha256payload_sha256
65535u16 (2 bytes)655366e1bebca6a8229364a162a72ef064826c4cd7457bf54f190ef782bd9deff3e426d8c2810d1e870fbaa1f0a0937126cca837a15f9260e27060c331d70a3c0bc84
65536extended (6 bytes)65536bf718b6f653bebc184e1479f1935b8da974d701b893afcf49e701f3e2f9f9c5ab7b4edb36ba92e267d322d56d9aebc22e7fa96ff52e3c12adc07f07a43cbc616
65537extended (6 bytes)81920008ffc88d3c96a9f307524eb361e47c5222a887fc45fa0c1fb8d429c5c23b430eeb7c7c5373894ea2c1547cfd3ccb15d5a0b2d619da852e5c79df792dcc9e435

Note: 65,535 and 65,536 both pad to 65,536 bytes, but the prefixed totals differ: 65,538 (2 + 65,536) versus 65,542 (6 + 65,536). The jump to 65,537 triggers the next padding bucket at 81,920.

11 Version History

The number 44 briefly held an unrelated "Video Events" draft (December 7 to 9, 2023) that moved to NIP-71. The encryption standard landed days later, after roughly a year of design revisions and a professional audit. Notable changes, newest first:

  • 20262026-06Extended 6-byte length prefix added; maximum plaintext raised from 65,535 bytes to 232 - 1. Implementation considerations and boundary test vectors added.
  • 20242024-12Clarified units, base64 explanation, and other nits.
  • 20242024-09Clarified that the ECDH output is unhashed; named the libsecp256k1 function secp256k1_ec_pubkey_tweak_mul.
  • 20232023-12Cleanup separated encryption from messaging; added the note that this is not a drop-in replacement for NIP-04; added the Cure53 audit notice.
  • Origin2023-12-20Paul Miller merged "NIP44 encryption standard, revision 3" (#746) after a year of iteration on versions 1 and 2.

Original Proposal - Credits

AuthorPaul Miller (with fiatjaf, Jon Staab, and the nip44 working group)
First commit2023-12-20 — “NIP44 encryption standard, revision 3 (#746)”
Pull request#746
Statusactive - optional — audited by Cure53, 2023-12
Source44.md on GitHub - full change history

12 See Also

  • NIP-01 — the signed event that must wrap every payload; Schnorr signatures over secp256k1.
  • NIP-04 — the older encrypted direct message scheme this format does not replace in-place.
  • NIP-17 — private direct messages; a messaging standard built on this encryption.
  • NIP-59 — gift wrap; seals and wraps events using NIP-44 encryption.
  • NIP-07 — browser signer extensions that expose nip44.encrypt and nip44.decrypt.