NIP44 spec and implementations of encrypted messages for nostr
NIP44 encrypted messages for nostr. Spec copied from github.com/nostr-protocol/nips. Audited on 2023.12 by Cure53, Dr M. Heiderich, Dr. Mazaheri, Dr. Bleichenbacher.
The implementations besides JS have not been audited: use at your own risk.
Language | License | Copied from |
---|---|---|
F# | GPL 2 | https://github.com/lontivero/Nostra |
Go | MIT | https://github.com/ekzyis/nip44 |
Kotlin | MIT | https://github.com/vitorpamplona/amethyst |
Rust | MIT | https://github.com/mikedilger/nip44 |
Swift | MIT | https://github.com/nostr-sdk/nostr-sdk-ios |
TypeScript / JS | Public domain | https://github.com/nostr-protocol/nips |
C | LGPL 2.1+ | https://github.com/vnuge/noscrypt |
optional
The NIP introduces a new data format for keypair-based encryption. This NIP is versioned to allow multiple algorithm choices to exist simultaneously. This format may be used for many things, but MUST be used in the context of a signed event as described in NIP 01.
Note: this format DOES NOT define any kind
s related to a new direct messaging standard,
only the encryption required to define one. It SHOULD NOT be used as a drop-in replacement
for NIP 04 payloads.
Currently defined encryption algorithms:
0x00
- Reserved0x01
- Deprecated and undefined0x02
- secp256k1 ECDH, HKDF, padding, ChaCha20, HMAC-SHA256, base64Every nostr user has their own public key, which solves key distribution problems present in other solutions. However, nostr's relay-based architecture makes it difficult to implement more robust private messaging protocols with things like metadata hiding, forward secrecy, and post compromise secrecy.
The goal of this NIP is to have a simple way to encrypt payloads used in the context of a signed event. When applying this NIP to any use case, it's important to keep in mind your users' threat model and this NIP's limitations. For high-risk situations, users should chat in specialized E2EE messaging software and limit use of nostr to exchanging contacts.
On its own, messages sent using this scheme have a number of important shortcomings:
created_at
is public, since it is a part of NIP 01 eventLack of forward secrecy may be partially mitigated by only sending messages to trusted relays, and asking relays to delete stored messages after a certain duration has elapsed.
NIP-44 version 2 has the following design characteristics:
shared_x
must be unhashed, 32-byte encoded x coordinate of the shared pointIKM=shared_x
and salt=utf8_encode('nip44-v2')
conversation_key
between two users.conv(a, B) == conv(b, A)
conversation_key
and nonce
. Validate that both are 32 bytes longPRK=conversation_key
, info=nonce
and L=76
chacha_key
(bytes 0..32), chacha_nonce
(bytes 32..44), hmac_key
(bytes 44..76)[plaintext_length: u16][plaintext][zero_bytes]
nonce
and ciphertext
concat(version, nonce, ciphertext, mac)
Encrypted payloads MUST be included in an event's payload, hashed, and signed as defined in NIP 01, using schnorr signature scheme over secp256k1.
Before decryption, the event's pubkey and signature MUST be validated as defined in NIP 01. The public key MUST be a valid non-zero secp256k1 curve point, and the signature must be valid secp256k1 schnorr signature. For exact validation rules, refer to BIP-340.
#
#
is an optional future-proof flag that means non-base64 encoding is used#
is not present in base64 alphabet, but, instead of throwing base64 is invalid
,version, nonce, ciphertext, mac
secure_random_bytes(length)
fetches randomness from CSPRNG.hkdf(IKM, salt, info, L)
represents HKDF (RFC 5869)hkdf_extract(IKM, salt)
and hkdf_expand(OKM, info, L)
.chacha20(key, nonce, data)
is ChaCha20 (RFC 8439) withhmac_sha256(key, message)
is HMAC (RFC 2104).secp256k1_ecdh(priv_a, pub_b)
is multiplication of point B by scalar a (a ⋅ B
), defined inbytes(P)
from BIP340. Private and public keys must be validated as per BIP340: pubkey must be a valid,[1, secp256k1_order - 1]
.secp256k1_ec_pubkey_tweak_mul
x[i:j]
, where x
is a byte array and i, j <= 0
returns a (j - i)
-byte array with a copy of thei
-th byte (inclusive) to the j
-th byte (exclusive) of x
.c
:
min_plaintext_size
is 1. 1b msg is padded to 32b.max_plaintext_size
is 65535 (64kb - 1). It is padded to 65536.base64_encode(string)
and base64_decode(bytes)
are Base64 (RFC 4648, with padding)concat
refers to byte array concatenationis_equal_ct(a, b)
is constant-time equality check of 2 byte arraysutf8_encode(string)
and utf8_decode(bytes)
transform string to byte array and backwrite_u8(number)
restricts number to values 0..255 and encodes into Big-Endian uint8 byte arraywrite_u16_be(number)
restricts number to values 0..65535 and encodes into Big-Endian uint16 byte arrayzeros(length)
creates byte array of length length >= 0
, filled with zerosfloor(number)
and log2(number)
are well-known mathematical methodsThe following is a collection of python-like pseudocode functions which implement the above primitives, intended to guide implementers. A collection of implementations in different languages is available at https://github.com/paulmillr/nip44.
# 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((len - 1) / chunk) + 1)
# Converts unpadded plaintext to padded bytearray
def pad(plaintext):
unpadded = utf8_encode(plaintext)
unpadded_len = len(plaintext)
if (unpadded_len < c.min_plaintext_size or
unpadded_len > c.max_plaintext_size): raise Exception('invalid plaintext length')
prefix = write_u16_be(unpadded_len)
suffix = zeros(calc_padded_len(unpadded_len) - unpadded_len)
return concat(prefix, unpadded, suffix)
# Converts padded bytearray to unpadded plaintext
def unpad(padded):
unpadded_len = read_uint16_be(padded[0:2])
unpadded = padded[2:2+unpadded_len]
if (unpadded_len == 0 or
len(unpadded) != unpadded_len or
len(padded) != 2 + calc_padded_len(unpadded_len)): raise Exception('invalid padding')
return utf8_decode(unpadded)
# metadata: always 65b (version: 1b, nonce: 32b, max: 32b)
# plaintext: 1b to 0xffff
# padded plaintext: 32b to 0xffff
# ciphertext: 32b+2 to 0xffff+2
# raw payload: 99 (65+32+2) to 65603 (65+0xffff+2)
# compressed payload (base64): 132b to 87472b
def decode_payload(payload):
plen = len(payload)
if plen == 0 or payload[0] == '#': raise Exception('unknown version')
if plen < 132 or plen > 87472: raise Exception('invalid payload size')
data = base64_decode(payload)
dlen = len(d)
if dlen < 99 or dlen > 65603: raise Exception('invalid data size');
vers = data[0]
if vers != 2: raise Exception('unknown version ' + vers)
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 Exception('AAD associated data must be 32 bytes');
return hmac(sha256, key, concat(aad, message));
# Calculates long-term key between users 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'))
# Calculates unique per-message key
def get_message_keys(conversation_key, nonce):
if len(conversation_key) != 32: raise Exception('invalid conversation_key length')
if len(nonce) != 32: raise Exception('invalid nonce length')
keys = hkdf_expand(OKM=conversation_key, info=nonce, L=76)
chacha_key = keys[0:32]
chacha_nonce = keys[32:44]
hmac_key = keys[44:76]
return (chacha_key, chacha_nonce, hmac_key)
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 Exception('invalid MAC')
padded_plaintext = chacha20(key=chacha_key, nonce=chacha_nonce, data=ciphertext)
return unpad(padded_plaintext)
# Usage:
# conversation_key = get_conversation_key(sender_privkey, recipient_pubkey)
# nonce = secure_random_bytes(32)
# payload = encrypt('hello world', conversation_key, nonce)
# 'hello world' == decrypt(payload, conversation_key)
The v2 of the standard was audited by Cure53 in December 2023. Check out audit-2023.12.pdf and auditor's website.
A collection of implementations in different languages is available at https://github.com/paulmillr/nip44.
We publish extensive test vectors. Instead of having it in the document directly, a sha256 checksum of vectors is provided:
269ed0f69e4c192512cc779e78c555090cebc7c785b609e338a62afc3ce25040 nip44.vectors.json
Example of a test vector from the file:
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000001",
"sec2": "0000000000000000000000000000000000000000000000000000000000000002",
"conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d",
"nonce": "0000000000000000000000000000000000000000000000000000000000000001",
"plaintext": "a",
"payload": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb"
}
The file also contains intermediate values. A quick guidance with regards to its usage:
valid.get_conversation_key
: calculate conversation_key from secret key sec1 and public key pub2valid.get_message_keys
: calculate chacha_key, chacha_nonce, hmac_key from conversation_key and noncevalid.calc_padded_len
: take unpadded length (first value), calculate padded length (second value)valid.encrypt_decrypt
: emulate real conversation. Calculate pub2 from sec2, verify conversation_key from (sec1, pub2), encrypt, verify payload, then calculate pub1 from sec1, verify conversation_key from (sec2, pub1), decrypt, verify plaintext.valid.encrypt_decrypt_long_msg
: same as previous step, but instead of a full plaintext and payload, their checksum is provided.invalid.encrypt_msg_lengths
invalid.get_conversation_key
: calculating conversation_key must throw an errorinvalid.decrypt
: decrypting message content must throw an error