diff options
Diffstat (limited to '44.md')
| -rw-r--r-- | 44.md | 295 |
1 files changed, 295 insertions, 0 deletions
| @@ -0,0 +1,295 @@ | |||
| 1 | NIP-44 | ||
| 2 | ===== | ||
| 3 | |||
| 4 | Encrypted Payloads (Versioned) | ||
| 5 | ------------------------------ | ||
| 6 | |||
| 7 | `optional` | ||
| 8 | |||
| 9 | The NIP introduces a new data format for keypair-based encryption. This NIP is versioned | ||
| 10 | to allow multiple algorithm choices to exist simultaneously. This format may be used for | ||
| 11 | many things, but MUST be used in the context of a signed event as described in NIP 01. | ||
| 12 | |||
| 13 | *Note*: this format DOES NOT define any `kind`s related to a new direct messaging standard, | ||
| 14 | only the encryption required to define one. It SHOULD NOT be used as a drop-in replacement | ||
| 15 | for NIP 04 payloads. | ||
| 16 | |||
| 17 | ## Versions | ||
| 18 | |||
| 19 | Currently defined encryption algorithms: | ||
| 20 | |||
| 21 | - `0x00` - Reserved | ||
| 22 | - `0x01` - Deprecated and undefined | ||
| 23 | - `0x02` - secp256k1 ECDH, HKDF, padding, ChaCha20, HMAC-SHA256, base64 | ||
| 24 | |||
| 25 | ## Limitations | ||
| 26 | |||
| 27 | Every nostr user has their own public key, which solves key distribution problems present | ||
| 28 | in other solutions. However, nostr's relay-based architecture makes it difficult to implement | ||
| 29 | more robust private messaging protocols with things like metadata hiding, forward secrecy, | ||
| 30 | and post compromise secrecy. | ||
| 31 | |||
| 32 | The goal of this NIP is to have a _simple_ way to encrypt payloads used in the context of a signed | ||
| 33 | event. When applying this NIP to any use case, it's important to keep in mind your users' threat | ||
| 34 | model and this NIP's limitations. For high-risk situations, users should chat in specialized E2EE | ||
| 35 | messaging software and limit use of nostr to exchanging contacts. | ||
| 36 | |||
| 37 | On its own, messages sent using this scheme have a number of important shortcomings: | ||
| 38 | |||
| 39 | - No deniability: it is possible to prove an event was signed by a particular key | ||
| 40 | - No forward secrecy: when a key is compromised, it is possible to decrypt all previous conversations | ||
| 41 | - No post-compromise security: when a key is compromised, it is possible to decrypt all future conversations | ||
| 42 | - No post-quantum security: a powerful quantum computer would be able to decrypt the messages | ||
| 43 | - IP address leak: user IP may be seen by relays and all intermediaries between user and relay | ||
| 44 | - Date leak: `created_at` is public, since it is a part of NIP 01 event | ||
| 45 | - Limited message size leak: padding only partially obscures true message length | ||
| 46 | - No attachments: they are not supported | ||
| 47 | |||
| 48 | Lack of forward secrecy may be partially mitigated by only sending messages to trusted relays, and asking | ||
| 49 | relays to delete stored messages after a certain duration has elapsed. | ||
| 50 | |||
| 51 | ## Version 2 | ||
| 52 | |||
| 53 | NIP-44 version 2 has the following design characteristics: | ||
| 54 | |||
| 55 | - Payloads are authenticated using a MAC before signing rather than afterwards because events are assumed | ||
| 56 | to be signed as specified in NIP-01. The outer signature serves to authenticate the full payload, and MUST | ||
| 57 | be validated before decrypting. | ||
| 58 | - ChaCha is used instead of AES because it's faster and has | ||
| 59 | [better security against multi-key attacks](https://datatracker.ietf.org/doc/draft-irtf-cfrg-aead-limits/). | ||
| 60 | - ChaCha is used instead of XChaCha because XChaCha has not been standardized. Also, xChaCha's improved collision | ||
| 61 | resistance of nonces isn't necessary since every message has a new (key, nonce) pair. | ||
| 62 | - HMAC-SHA256 is used instead of Poly1305 because polynomial MACs are much easier to forge. | ||
| 63 | - SHA256 is used instead of SHA3 or BLAKE because it is already used in nostr. Also BLAKE's speed advantage | ||
| 64 | is smaller in non-parallel environments. | ||
| 65 | - A custom padding scheme is used instead of padmé because it provides better leakage reduction for small messages. | ||
| 66 | - Base64 encoding is used instead of another compression algorithm because it is widely available, and is already used in nostr. | ||
| 67 | |||
| 68 | ### Encryption | ||
| 69 | |||
| 70 | 1. Calculate a conversation key | ||
| 71 | - Execute ECDH (scalar multiplication) of public key B by private key A | ||
| 72 | Output `shared_x` must be unhashed, 32-byte encoded x coordinate of the shared point | ||
| 73 | - Use HKDF-extract with sha256, `IKM=shared_x` and `salt=utf8_encode('nip44-v2')` | ||
| 74 | - HKDF output will be a `conversation_key` between two users. | ||
| 75 | - It is always the same, when key roles are swapped: `conv(a, B) == conv(b, A)` | ||
| 76 | 2. Generate a random 32-byte nonce | ||
| 77 | - Always use [CSPRNG](https://en.wikipedia.org/wiki/Cryptographically_secure_pseudorandom_number_generator) | ||
| 78 | - Don't generate a nonce from message content | ||
| 79 | - Don't re-use the same nonce between messages: doing so would make them decryptable, | ||
| 80 | but won't leak the long-term key | ||
| 81 | 3. Calculate message keys | ||
| 82 | - The keys are generated from `conversation_key` and `nonce`. Validate that both are 32 bytes long | ||
| 83 | - Use HKDF-expand, with sha256, `PRK=conversation_key`, `info=nonce` and `L=76` | ||
| 84 | - Slice 76-byte HKDF output into: `chacha_key` (bytes 0..32), `chacha_nonce` (bytes 32..44), `hmac_key` (bytes 44..76) | ||
| 85 | 4. Add padding | ||
| 86 | - Content must be encoded from UTF-8 into byte array | ||
| 87 | - Validate plaintext length. Minimum is 1 byte, maximum is 65535 bytes | ||
| 88 | - Padding format is: `[plaintext_length: u16][plaintext][zero_bytes]` | ||
| 89 | - Padding algorithm is related to powers-of-two, with min padded msg size of 32 | ||
| 90 | - Plaintext length is encoded in big-endian as first 2 bytes of the padded blob | ||
| 91 | 5. Encrypt padded content | ||
| 92 | - Use ChaCha20, with key and nonce from step 3 | ||
| 93 | 6. Calculate MAC (message authentication code) | ||
| 94 | - AAD (additional authenticated data) is used - instead of calculating MAC on ciphertext, | ||
| 95 | it's calculated over a concatenation of `nonce` and `ciphertext` | ||
| 96 | - Validate that AAD (nonce) is 32 bytes | ||
| 97 | 7. Base64-encode (with padding) params using `concat(version, nonce, ciphertext, mac)` | ||
| 98 | |||
| 99 | Encrypted payloads MUST be included in an event's payload, hashed, and signed as defined in NIP 01, using schnorr | ||
| 100 | signature scheme over secp256k1. | ||
| 101 | |||
| 102 | ### Decryption | ||
| 103 | |||
| 104 | Before decryption, the event's pubkey and signature MUST be validated as defined in NIP 01. The public key MUST be | ||
| 105 | a valid non-zero secp256k1 curve point, and the signature must be valid secp256k1 schnorr signature. For exact | ||
| 106 | validation rules, refer to BIP-340. | ||
| 107 | |||
| 108 | 1. Check if first payload's character is `#` | ||
| 109 | - `#` is an optional future-proof flag that means non-base64 encoding is used | ||
| 110 | - The `#` is not present in base64 alphabet, but, instead of throwing `base64 is invalid`, | ||
| 111 | implementations MUST indicate that the encryption version is not yet supported | ||
| 112 | 2. Decode base64 | ||
| 113 | - Base64 is decoded into `version, nonce, ciphertext, mac` | ||
| 114 | - If the version is unknown, implementations must indicate that the encryption version is not supported | ||
| 115 | - Validate length of base64 message to prevent DoS on base64 decoder: it can be in range from 132 to 87472 chars | ||
| 116 | - Validate length of decoded message to verify output of the decoder: it can be in range from 99 to 65603 bytes | ||
| 117 | 3. Calculate conversation key | ||
| 118 | - See step 1 of [encryption](#Encryption) | ||
| 119 | 4. Calculate message keys | ||
| 120 | - See step 3 of [encryption](#Encryption) | ||
| 121 | 5. Calculate MAC (message authentication code) with AAD and compare | ||
| 122 | - Stop and throw an error if MAC doesn't match the decoded one from step 2 | ||
| 123 | - Use constant-time comparison algorithm | ||
| 124 | 6. Decrypt ciphertext | ||
| 125 | - Use ChaCha20 with key and nonce from step 3 | ||
| 126 | 7. Remove padding | ||
| 127 | - Read the first two BE bytes of plaintext that correspond to plaintext length | ||
| 128 | - Verify that the length of sliced plaintext matches the value of the two BE bytes | ||
| 129 | - Verify that calculated padding from step 3 of the [encryption](#Encryption) process matches the actual padding | ||
| 130 | |||
| 131 | ### Details | ||
| 132 | |||
| 133 | - Cryptographic methods | ||
| 134 | - `secure_random_bytes(length)` fetches randomness from CSPRNG. | ||
| 135 | - `hkdf(IKM, salt, info, L)` represents HKDF [(RFC 5869)](https://datatracker.ietf.org/doc/html/rfc5869) | ||
| 136 | with SHA256 hash function comprised of methods `hkdf_extract(IKM, salt)` and `hkdf_expand(OKM, info, L)`. | ||
| 137 | - `chacha20(key, nonce, data)` is ChaCha20 [(RFC 8439)](https://datatracker.ietf.org/doc/html/rfc8439) with | ||
| 138 | starting counter set to 0. | ||
| 139 | - `hmac_sha256(key, message)` is HMAC [(RFC 2104)](https://datatracker.ietf.org/doc/html/rfc2104). | ||
| 140 | - `secp256k1_ecdh(priv_a, pub_b)` is multiplication of point B by scalar a (`a ⋅ B`), defined in | ||
| 141 | [BIP340](https://github.com/bitcoin/bips/blob/e918b50731397872ad2922a1b08a5a4cd1d6d546/bip-0340.mediawiki). | ||
| 142 | The operation produces a shared point, and we encode the shared point's 32-byte x coordinate, using method | ||
| 143 | `bytes(P)` from BIP340. Private and public keys must be validated as per BIP340: pubkey must be a valid, | ||
| 144 | on-curve point, and private key must be a scalar in range `[1, secp256k1_order - 1]`. | ||
| 145 | - Operators | ||
| 146 | - `x[i:j]`, where `x` is a byte array and `i, j <= 0` returns a `(j - i)`-byte array with a copy of the | ||
| 147 | `i`-th byte (inclusive) to the `j`-th byte (exclusive) of `x`. | ||
| 148 | - Constants `c`: | ||
| 149 | - `min_plaintext_size` is 1. 1b msg is padded to 32b. | ||
| 150 | - `max_plaintext_size` is 65535 (64kb - 1). It is padded to 65536. | ||
| 151 | - Functions | ||
| 152 | - `base64_encode(string)` and `base64_decode(bytes)` are Base64 ([RFC 4648](https://datatracker.ietf.org/doc/html/rfc4648), with padding) | ||
| 153 | - `concat` refers to byte array concatenation | ||
| 154 | - `is_equal_ct(a, b)` is constant-time equality check of 2 byte arrays | ||
| 155 | - `utf8_encode(string)` and `utf8_decode(bytes)` transform string to byte array and back | ||
| 156 | - `write_u8(number)` restricts number to values 0..255 and encodes into Big-Endian uint8 byte array | ||
| 157 | - `write_u16_be(number)` restricts number to values 0..65535 and encodes into Big-Endian uint16 byte array | ||
| 158 | - `zeros(length)` creates byte array of length `length >= 0`, filled with zeros | ||
| 159 | - `floor(number)` and `log2(number)` are well-known mathematical methods | ||
| 160 | |||
| 161 | ### Implementation pseudocode | ||
| 162 | |||
| 163 | The following is a collection of python-like pseudocode functions which implement the above primitives, | ||
| 164 | intended to guide implementers. A collection of implementations in different languages is available at https://github.com/paulmillr/nip44. | ||
| 165 | |||
| 166 | ```py | ||
| 167 | # Calculates length of the padded byte array. | ||
| 168 | def calc_padded_len(unpadded_len): | ||
| 169 | next_power = 1 << (floor(log2(unpadded_len - 1))) + 1 | ||
| 170 | if next_power <= 256: | ||
| 171 | chunk = 32 | ||
| 172 | else: | ||
| 173 | chunk = next_power / 8 | ||
| 174 | if unpadded_len <= 32: | ||
| 175 | return 32 | ||
| 176 | else: | ||
| 177 | return chunk * (floor((len - 1) / chunk) + 1) | ||
| 178 | |||
| 179 | # Converts unpadded plaintext to padded bytearray | ||
| 180 | def pad(plaintext): | ||
| 181 | unpadded = utf8_encode(plaintext) | ||
| 182 | unpadded_len = len(plaintext) | ||
| 183 | if (unpadded_len < c.min_plaintext_size or | ||
| 184 | unpadded_len > c.max_plaintext_size): raise Exception('invalid plaintext length') | ||
| 185 | prefix = write_u16_be(unpadded_len) | ||
| 186 | suffix = zeros(calc_padded_len(unpadded_len) - unpadded_len) | ||
| 187 | return concat(prefix, unpadded, suffix) | ||
| 188 | |||
| 189 | # Converts padded bytearray to unpadded plaintext | ||
| 190 | def unpad(padded): | ||
| 191 | unpadded_len = read_uint16_be(padded[0:2]) | ||
| 192 | unpadded = padded[2:2+unpadded_len] | ||
| 193 | if (unpadded_len == 0 or | ||
| 194 | len(unpadded) != unpadded_len or | ||
| 195 | len(padded) != 2 + calc_padded_len(unpadded_len)): raise Exception('invalid padding') | ||
| 196 | return utf8_decode(unpadded) | ||
| 197 | |||
| 198 | # metadata: always 65b (version: 1b, nonce: 32b, max: 32b) | ||
| 199 | # plaintext: 1b to 0xffff | ||
| 200 | # padded plaintext: 32b to 0xffff | ||
| 201 | # ciphertext: 32b+2 to 0xffff+2 | ||
| 202 | # raw payload: 99 (65+32+2) to 65603 (65+0xffff+2) | ||
| 203 | # compressed payload (base64): 132b to 87472b | ||
| 204 | def decode_payload(payload): | ||
| 205 | plen = len(payload) | ||
| 206 | if plen == 0 or payload[0] == '#': raise Exception('unknown version') | ||
| 207 | if plen < 132 or plen > 87472: raise Exception('invalid payload size') | ||
| 208 | data = base64_decode(payload) | ||
| 209 | dlen = len(d) | ||
| 210 | if dlen < 99 or dlen > 65603: raise Exception('invalid data size'); | ||
| 211 | vers = data[0] | ||
| 212 | if vers != 2: raise Exception('unknown version ' + vers) | ||
| 213 | nonce = data[1:33] | ||
| 214 | ciphertext = data[33:dlen - 32] | ||
| 215 | mac = data[dlen - 32:dlen] | ||
| 216 | return (nonce, ciphertext, mac) | ||
| 217 | |||
| 218 | def hmac_aad(key, message, aad): | ||
| 219 | if len(aad) != 32: raise Exception('AAD associated data must be 32 bytes'); | ||
| 220 | return hmac(sha256, key, concat(aad, message)); | ||
| 221 | |||
| 222 | # Calculates long-term key between users A and B: `get_key(Apriv, Bpub) == get_key(Bpriv, Apub)` | ||
| 223 | def get_conversation_key(private_key_a, public_key_b): | ||
| 224 | shared_x = secp256k1_ecdh(private_key_a, public_key_b) | ||
| 225 | return hkdf_extract(IKM=shared_x, salt=utf8_encode('nip44-v2')) | ||
| 226 | |||
| 227 | # Calculates unique per-message key | ||
| 228 | def get_message_keys(conversation_key, nonce): | ||
| 229 | if len(conversation_key) != 32: raise Exception('invalid conversation_key length') | ||
| 230 | if len(nonce) != 32: raise Exception('invalid nonce length') | ||
| 231 | keys = hkdf_expand(OKM=conversation_key, info=nonce, L=76) | ||
| 232 | chacha_key = keys[0:32] | ||
| 233 | chacha_nonce = keys[32:44] | ||
| 234 | hmac_key = keys[44:76] | ||
| 235 | return (chacha_key, chacha_nonce, hmac_key) | ||
| 236 | |||
| 237 | def encrypt(plaintext, conversation_key, nonce): | ||
| 238 | (chacha_key, chacha_nonce, hmac_key) = get_message_keys(conversation_key, nonce) | ||
| 239 | padded = pad(plaintext) | ||
| 240 | ciphertext = chacha20(key=chacha_key, nonce=chacha_nonce, data=padded) | ||
| 241 | mac = hmac_aad(key=hmac_key, message=ciphertext, aad=nonce) | ||
| 242 | return base64_encode(concat(write_u8(2), nonce, ciphertext, mac)) | ||
| 243 | |||
| 244 | def decrypt(payload, conversation_key): | ||
| 245 | (nonce, ciphertext, mac) = decode_payload(payload) | ||
| 246 | (chacha_key, chacha_nonce, hmac_key) = get_message_keys(conversation_key, nonce) | ||
| 247 | calculated_mac = hmac_aad(key=hmac_key, message=ciphertext, aad=nonce) | ||
| 248 | if not is_equal_ct(calculated_mac, mac): raise Exception('invalid MAC') | ||
| 249 | padded_plaintext = chacha20(key=chacha_key, nonce=chacha_nonce, data=ciphertext) | ||
| 250 | return unpad(padded_plaintext) | ||
| 251 | |||
| 252 | # Usage: | ||
| 253 | # conversation_key = get_conversation_key(sender_privkey, recipient_pubkey) | ||
| 254 | # nonce = secure_random_bytes(32) | ||
| 255 | # payload = encrypt('hello world', conversation_key, nonce) | ||
| 256 | # 'hello world' == decrypt(payload, conversation_key) | ||
| 257 | ``` | ||
| 258 | |||
| 259 | ### Audit | ||
| 260 | |||
| 261 | The v2 of the standard was audited by [Cure53](https://cure53.de) in December 2023. | ||
| 262 | Check out [audit-2023.12.pdf](https://github.com/paulmillr/nip44/blob/ce63c2eaf345e9f7f93b48f829e6bdeb7e7d7964/audit-2023.12.pdf) | ||
| 263 | and [auditor's website](https://cure53.de/audit-report_nip44-implementations.pdf). | ||
| 264 | |||
| 265 | ### Tests and code | ||
| 266 | |||
| 267 | A collection of implementations in different languages is available at https://github.com/paulmillr/nip44. | ||
| 268 | |||
| 269 | We publish extensive test vectors. Instead of having it in the document directly, a sha256 checksum of vectors is provided: | ||
| 270 | |||
| 271 | 269ed0f69e4c192512cc779e78c555090cebc7c785b609e338a62afc3ce25040 nip44.vectors.json | ||
| 272 | |||
| 273 | Example of a test vector from the file: | ||
| 274 | |||
| 275 | ```json | ||
| 276 | { | ||
| 277 | "sec1": "0000000000000000000000000000000000000000000000000000000000000001", | ||
| 278 | "sec2": "0000000000000000000000000000000000000000000000000000000000000002", | ||
| 279 | "conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d", | ||
| 280 | "nonce": "0000000000000000000000000000000000000000000000000000000000000001", | ||
| 281 | "plaintext": "a", | ||
| 282 | "payload": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb" | ||
| 283 | } | ||
| 284 | ``` | ||
| 285 | |||
| 286 | The file also contains intermediate values. A quick guidance with regards to its usage: | ||
| 287 | |||
| 288 | - `valid.get_conversation_key`: calculate conversation_key from secret key sec1 and public key pub2 | ||
| 289 | - `valid.get_message_keys`: calculate chacha_key, chacha_nonce, hmac_key from conversation_key and nonce | ||
| 290 | - `valid.calc_padded_len`: take unpadded length (first value), calculate padded length (second value) | ||
| 291 | - `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. | ||
| 292 | - `valid.encrypt_decrypt_long_msg`: same as previous step, but instead of a full plaintext and payload, their checksum is provided. | ||
| 293 | - `invalid.encrypt_msg_lengths` | ||
| 294 | - `invalid.get_conversation_key`: calculating conversation_key must throw an error | ||
| 295 | - `invalid.decrypt`: decrypting message content must throw an error | ||