{
"author": {
"about": "🎵Die Gedanken sind frei.",
"banner": "https://i.nostr.build/FEZrgz9lthZrfndJ.jpg",
"display_name": "Laeserin",
"lud16": "silberengel@minibits.cash",
"name": "Laeserin",
"nip05": "laeserin@gitcitadel.com",
"website": "https://jumble.imwald.eu",
"picture": "https://i.nostr.build/thumb/RlkuVFMWOXpshf8k.webp",
"displayName": "Laeserin"
},
"event": {
"content": "NIP-44\n=====\n\nEncrypted Payloads (Versioned)\n------------------------------\n\n`optional`\n\nThe NIP introduces a new data format for keypair-based encryption. This NIP is versioned\nto allow multiple algorithm choices to exist simultaneously. This format may be used for\nmany things, but MUST be used in the context of a signed event as described in NIP 01.\n\n*Note*: this format DOES NOT define any `kind`s related to a new direct messaging standard,\nonly the encryption required to define one. It SHOULD NOT be used as a drop-in replacement\nfor NIP 04 payloads.\n\n## Versions\n\nCurrently defined encryption algorithms:\n\n- `0x00` - Reserved\n- `0x01` - Deprecated and undefined\n- `0x02` - secp256k1 ECDH, HKDF, padding, ChaCha20, HMAC-SHA256, base64\n\n## Limitations\n\nEvery nostr user has their own public key, which solves key distribution problems present\nin other solutions. However, nostr's relay-based architecture makes it difficult to implement\nmore robust private messaging protocols with things like metadata hiding, forward secrecy,\nand post compromise secrecy.\n\nThe goal of this NIP is to have a _simple_ way to encrypt payloads used in the context of a signed\nevent. When applying this NIP to any use case, it's important to keep in mind your users' threat\nmodel and this NIP's limitations. For high-risk situations, users should chat in specialized E2EE\nmessaging software and limit use of nostr to exchanging contacts.\n\nOn its own, messages sent using this scheme have a number of important shortcomings:\n\n- No deniability: it is possible to prove an event was signed by a particular key\n- No forward secrecy: when a key is compromised, it is possible to decrypt all previous conversations\n- No post-compromise security: when a key is compromised, it is possible to decrypt all future conversations\n- No post-quantum security: a powerful quantum computer would be able to decrypt the messages\n- IP address leak: user IP may be seen by relays and all intermediaries between user and relay\n- Date leak: `created_at` is public, since it is a part of NIP 01 event\n- Limited message size leak: padding only partially obscures true message length\n- No attachments: they are not supported\n\nLack of forward secrecy may be partially mitigated by only sending messages to trusted relays, and asking\nrelays to delete stored messages after a certain duration has elapsed.\n\n## Version 2\n\nNIP-44 version 2 has the following design characteristics:\n\n- Payloads are authenticated using a MAC before signing rather than afterwards because events are assumed\n to be signed as specified in NIP-01. The outer signature serves to authenticate the full payload, and MUST\n be validated before decrypting.\n- ChaCha is used instead of AES because it's faster and has\n [better security against multi-key attacks](https://datatracker.ietf.org/doc/draft-irtf-cfrg-aead-limits/).\n- ChaCha is used instead of XChaCha because XChaCha has not been standardized. Also, xChaCha's improved collision\n resistance of nonces isn't necessary since every message has a new (key, nonce) pair.\n- HMAC-SHA256 is used instead of Poly1305 because polynomial MACs are much easier to forge.\n- SHA256 is used instead of SHA3 or BLAKE because it is already used in nostr. Also BLAKE's speed advantage\n is smaller in non-parallel environments.\n- A custom padding scheme is used instead of padmé because it provides better leakage reduction for small messages.\n- Base64 encoding is used instead of another compression algorithm because it is widely available, and is already used in nostr.\n\n### Encryption\n\n1. Calculate a conversation key\n - Execute ECDH (scalar multiplication) of public key B by private key A\n Output `shared_x` must be unhashed, 32-byte encoded x coordinate of the shared point\n - Use HKDF-extract with sha256, `IKM=shared_x` and `salt=utf8_encode('nip44-v2')`\n - HKDF output will be a `conversation_key` between two users.\n - It is always the same, when key roles are swapped: `conv(a, B) == conv(b, A)`\n2. Generate a random 32-byte nonce\n - Always use [CSPRNG](https://en.wikipedia.org/wiki/Cryptographically_secure_pseudorandom_number_generator)\n - Don't generate a nonce from message content\n - Don't re-use the same nonce between messages: doing so would make them decryptable,\n but won't leak the long-term key\n3. Calculate message keys\n - The keys are generated from `conversation_key` and `nonce`. Validate that both are 32 bytes long\n - Use HKDF-expand, with sha256, `PRK=conversation_key`, `info=nonce` and `L=76`\n - Slice 76-byte HKDF output into: `chacha_key` (bytes 0..32), `chacha_nonce` (bytes 32..44), `hmac_key` (bytes 44..76)\n4. Add padding\n - Content must be encoded from UTF-8 into byte array\n - Validate plaintext length. Minimum is 1 byte, maximum is 65535 bytes\n - Padding format is: `[plaintext_length: u16][plaintext][zero_bytes]`\n - Padding algorithm is related to powers-of-two, with min padded msg size of 32\n - Plaintext length is encoded in big-endian as first 2 bytes of the padded blob\n5. Encrypt padded content\n - Use ChaCha20, with key and nonce from step 3\n6. Calculate MAC (message authentication code)\n - AAD (additional authenticated data) is used - instead of calculating MAC on ciphertext,\n it's calculated over a concatenation of `nonce` and `ciphertext`\n - Validate that AAD (nonce) is 32 bytes\n7. Base64-encode (with padding) params using `concat(version, nonce, ciphertext, mac)`\n\nEncrypted payloads MUST be included in an event's payload, hashed, and signed as defined in NIP 01, using schnorr\nsignature scheme over secp256k1.\n\n### Decryption\n\nBefore decryption, the event's pubkey and signature MUST be validated as defined in NIP 01. The public key MUST be\na valid non-zero secp256k1 curve point, and the signature must be valid secp256k1 schnorr signature. For exact\nvalidation rules, refer to BIP-340.\n\n1. Check if first payload's character is `#`\n - `#` is an optional future-proof flag that means non-base64 encoding is used\n - The `#` is not present in base64 alphabet, but, instead of throwing `base64 is invalid`,\n implementations MUST indicate that the encryption version is not yet supported\n2. Decode base64\n - Base64 is decoded into `version, nonce, ciphertext, mac`\n - If the version is unknown, implementations must indicate that the encryption version is not supported\n - Validate length of base64 message to prevent DoS on base64 decoder: it can be in range from 132 to 87472 chars\n - Validate length of decoded message to verify output of the decoder: it can be in range from 99 to 65603 bytes\n3. Calculate conversation key\n - See step 1 of [encryption](#Encryption)\n4. Calculate message keys\n - See step 3 of [encryption](#Encryption)\n5. Calculate MAC (message authentication code) with AAD and compare\n - Stop and throw an error if MAC doesn't match the decoded one from step 2\n - Use constant-time comparison algorithm\n6. Decrypt ciphertext\n - Use ChaCha20 with key and nonce from step 3\n7. Remove padding\n - Read the first two BE bytes of plaintext that correspond to plaintext length\n - Verify that the length of sliced plaintext matches the value of the two BE bytes\n - Verify that calculated padding from step 3 of the [encryption](#Encryption) process matches the actual padding\n\n### Details\n\n- Cryptographic methods\n - `secure_random_bytes(length)` fetches randomness from CSPRNG.\n - `hkdf(IKM, salt, info, L)` represents HKDF [(RFC 5869)](https://datatracker.ietf.org/doc/html/rfc5869)\n with SHA256 hash function comprised of methods `hkdf_extract(IKM, salt)` and `hkdf_expand(OKM, info, L)`.\n - `chacha20(key, nonce, data)` is ChaCha20 [(RFC 8439)](https://datatracker.ietf.org/doc/html/rfc8439) with\n starting counter set to 0.\n - `hmac_sha256(key, message)` is HMAC [(RFC 2104)](https://datatracker.ietf.org/doc/html/rfc2104).\n - `secp256k1_ecdh(priv_a, pub_b)` is multiplication of point B by scalar a (`a ⋅ B`), defined in\n [BIP340](https://github.com/bitcoin/bips/blob/e918b50731397872ad2922a1b08a5a4cd1d6d546/bip-0340.mediawiki).\n The operation produces a shared point, and we encode the shared point's 32-byte x coordinate, using method\n `bytes(P)` from BIP340. Private and public keys must be validated as per BIP340: pubkey must be a valid,\n on-curve point, and private key must be a scalar in range `[1, secp256k1_order - 1]`.\n- Operators\n - `x[i:j]`, where `x` is a byte array and `i, j <= 0` returns a `(j - i)`-byte array with a copy of the\n `i`-th byte (inclusive) to the `j`-th byte (exclusive) of `x`.\n- Constants `c`:\n - `min_plaintext_size` is 1. 1b msg is padded to 32b.\n - `max_plaintext_size` is 65535 (64kb - 1). It is padded to 65536.\n- Functions\n - `base64_encode(string)` and `base64_decode(bytes)` are Base64 ([RFC 4648](https://datatracker.ietf.org/doc/html/rfc4648), with padding)\n - `concat` refers to byte array concatenation\n - `is_equal_ct(a, b)` is constant-time equality check of 2 byte arrays\n - `utf8_encode(string)` and `utf8_decode(bytes)` transform string to byte array and back\n - `write_u8(number)` restricts number to values 0..255 and encodes into Big-Endian uint8 byte array\n - `write_u16_be(number)` restricts number to values 0..65535 and encodes into Big-Endian uint16 byte array\n - `zeros(length)` creates byte array of length `length >= 0`, filled with zeros\n - `floor(number)` and `log2(number)` are well-known mathematical methods\n\n### Implementation pseudocode\n\nThe following is a collection of python-like pseudocode functions which implement the above primitives,\nintended to guide implementers. A collection of implementations in different languages is available at https://github.com/paulmillr/nip44.\n\n```py\n# Calculates length of the padded byte array.\ndef calc_padded_len(unpadded_len):\n next_power = 1 << (floor(log2(unpadded_len - 1))) + 1\n if next_power <= 256:\n chunk = 32\n else:\n chunk = next_power / 8\n if unpadded_len <= 32:\n return 32\n else:\n return chunk * (floor((len - 1) / chunk) + 1)\n\n# Converts unpadded plaintext to padded bytearray\ndef pad(plaintext):\n unpadded = utf8_encode(plaintext)\n unpadded_len = len(plaintext)\n if (unpadded_len < c.min_plaintext_size or\n unpadded_len > c.max_plaintext_size): raise Exception('invalid plaintext length')\n prefix = write_u16_be(unpadded_len)\n suffix = zeros(calc_padded_len(unpadded_len) - unpadded_len)\n return concat(prefix, unpadded, suffix)\n\n# Converts padded bytearray to unpadded plaintext\ndef unpad(padded):\n unpadded_len = read_uint16_be(padded[0:2])\n unpadded = padded[2:2+unpadded_len]\n if (unpadded_len == 0 or\n len(unpadded) != unpadded_len or\n len(padded) != 2 + calc_padded_len(unpadded_len)): raise Exception('invalid padding')\n return utf8_decode(unpadded)\n\n# metadata: always 65b (version: 1b, nonce: 32b, max: 32b)\n# plaintext: 1b to 0xffff\n# padded plaintext: 32b to 0xffff\n# ciphertext: 32b+2 to 0xffff+2\n# raw payload: 99 (65+32+2) to 65603 (65+0xffff+2)\n# compressed payload (base64): 132b to 87472b\ndef decode_payload(payload):\n plen = len(payload)\n if plen == 0 or payload[0] == '#': raise Exception('unknown version')\n if plen < 132 or plen > 87472: raise Exception('invalid payload size')\n data = base64_decode(payload)\n dlen = len(d)\n if dlen < 99 or dlen > 65603: raise Exception('invalid data size');\n vers = data[0]\n if vers != 2: raise Exception('unknown version ' + vers)\n nonce = data[1:33]\n ciphertext = data[33:dlen - 32]\n mac = data[dlen - 32:dlen]\n return (nonce, ciphertext, mac)\n\ndef hmac_aad(key, message, aad):\n if len(aad) != 32: raise Exception('AAD associated data must be 32 bytes');\n return hmac(sha256, key, concat(aad, message));\n\n# Calculates long-term key between users A and B: `get_key(Apriv, Bpub) == get_key(Bpriv, Apub)`\ndef get_conversation_key(private_key_a, public_key_b):\n shared_x = secp256k1_ecdh(private_key_a, public_key_b)\n return hkdf_extract(IKM=shared_x, salt=utf8_encode('nip44-v2'))\n\n# Calculates unique per-message key\ndef get_message_keys(conversation_key, nonce):\n if len(conversation_key) != 32: raise Exception('invalid conversation_key length')\n if len(nonce) != 32: raise Exception('invalid nonce length')\n keys = hkdf_expand(OKM=conversation_key, info=nonce, L=76)\n chacha_key = keys[0:32]\n chacha_nonce = keys[32:44]\n hmac_key = keys[44:76]\n return (chacha_key, chacha_nonce, hmac_key)\n\ndef encrypt(plaintext, conversation_key, nonce):\n (chacha_key, chacha_nonce, hmac_key) = get_message_keys(conversation_key, nonce)\n padded = pad(plaintext)\n ciphertext = chacha20(key=chacha_key, nonce=chacha_nonce, data=padded)\n mac = hmac_aad(key=hmac_key, message=ciphertext, aad=nonce)\n return base64_encode(concat(write_u8(2), nonce, ciphertext, mac))\n\ndef decrypt(payload, conversation_key):\n (nonce, ciphertext, mac) = decode_payload(payload)\n (chacha_key, chacha_nonce, hmac_key) = get_message_keys(conversation_key, nonce)\n calculated_mac = hmac_aad(key=hmac_key, message=ciphertext, aad=nonce)\n if not is_equal_ct(calculated_mac, mac): raise Exception('invalid MAC')\n padded_plaintext = chacha20(key=chacha_key, nonce=chacha_nonce, data=ciphertext)\n return unpad(padded_plaintext)\n\n# Usage:\n# conversation_key = get_conversation_key(sender_privkey, recipient_pubkey)\n# nonce = secure_random_bytes(32)\n# payload = encrypt('hello world', conversation_key, nonce)\n# 'hello world' == decrypt(payload, conversation_key)\n```\n\n### Audit\n\nThe v2 of the standard was audited by [Cure53](https://cure53.de) in December 2023.\nCheck out [audit-2023.12.pdf](https://github.com/paulmillr/nip44/blob/ce63c2eaf345e9f7f93b48f829e6bdeb7e7d7964/audit-2023.12.pdf)\nand [auditor's website](https://cure53.de/audit-report_nip44-implementations.pdf).\n\n### Tests and code\n\nA collection of implementations in different languages is available at https://github.com/paulmillr/nip44.\n\nWe publish extensive test vectors. Instead of having it in the document directly, a sha256 checksum of vectors is provided:\n\n 269ed0f69e4c192512cc779e78c555090cebc7c785b609e338a62afc3ce25040 nip44.vectors.json\n\nExample of a test vector from the file:\n\n```json\n{\n \"sec1\": \"0000000000000000000000000000000000000000000000000000000000000001\",\n \"sec2\": \"0000000000000000000000000000000000000000000000000000000000000002\",\n \"conversation_key\": \"c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d\",\n \"nonce\": \"0000000000000000000000000000000000000000000000000000000000000001\",\n \"plaintext\": \"a\",\n \"payload\": \"AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb\"\n}\n```\n\nThe file also contains intermediate values. A quick guidance with regards to its usage:\n\n- `valid.get_conversation_key`: calculate conversation_key from secret key sec1 and public key pub2\n- `valid.get_message_keys`: calculate chacha_key, chacha_nonce, hmac_key from conversation_key and nonce\n- `valid.calc_padded_len`: take unpadded length (first value), calculate padded length (second value)\n- `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.\n- `valid.encrypt_decrypt_long_msg`: same as previous step, but instead of a full plaintext and payload, their checksum is provided.\n- `invalid.encrypt_msg_lengths`\n- `invalid.get_conversation_key`: calculating conversation_key must throw an error\n- `invalid.decrypt`: decrypting message content must throw an error\n",
"created_at": 1716822602,
"id": "325083124088660386675331bb8592d2fceb8306047a0f25b8258169f3bec897",
"kind": 30818,
"pubkey": "dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319",
"sig": "aede535dc47dc9531e1da5a7b9786d4c8b4583b76bf5e98471831b13e2d579b6f6614f768f9c95344c3be412cd21bd3026d1d4114caf97ffd3bf4b40930b40d8",
"tags": [
[
"d",
"nip-44"
],
[
"client",
"wikifreedia",
"31990:fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52:1716498133442"
],
[
"title",
"NIP-44"
],
[
"c",
"Nostr"
],
[
"published_at",
"1716822602"
]
]
},
"relays": [
"wss://theforest.nostr1.com/",
"wss://nostr.land/",
"wss://nostr21.com/",
"wss://relay.layer.systems/"
],
"address": {
"identifier": "nip-44",
"pubkey": "dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319",
"kind": 30818,
"relays": [
"wss://nos.lol/"
]
}
}