diff options
| author | Alex Gleason <alex@alexgleason.me> | 2026-03-17 15:33:00 -0500 |
|---|---|---|
| committer | Alex Gleason <alex@alexgleason.me> | 2026-03-17 15:33:00 -0500 |
| commit | ac2f6a6cf9c2368f1c6a87c1716751fdf7496707 (patch) | |
| tree | a873560e18da7ab6a6c5750d0fb75eb0622d6111 | |
| parent | 3492eb1affa5e93e658224a81bb832d2b6090ecd (diff) | |
nip44: allow encryption of payloads larger than 65535 bytes
Extend the v2 padding format with a backwards-compatible sentinel:
when the first 2 bytes of the length prefix are zero, the next 4
bytes encode the plaintext length as a big-endian u32. This raises
the maximum from 65535 bytes to 2^32-1 bytes without requiring a
version bump.
Fixes from nostr-protocol/nips#1907:
- Fix off-by-one: use >= 65536 (not > 65536) for the extended path,
since u16 can only represent 0..65535
- Fix padding validation: use dynamic prefix_len (2 or 6) instead of
hardcoded 2 in the unpad() size check
- Fix len(d) typo in decode_payload (should be len(data))
- Remove upper-bound size checks in decode_payload that would reject
large payloads
- Add write_u32_be, read_uint16_be, read_uint32_be to function list
- Add extended_prefix_threshold constant
- Update size range comments for both small and large payload paths
| -rw-r--r-- | 44.md | 62 |
1 files changed, 40 insertions, 22 deletions
| @@ -84,10 +84,12 @@ NIP-44 version 2 has the following design characteristics: | |||
| 84 | - Slice 76-byte HKDF output into: `chacha_key` (bytes 0..32), `chacha_nonce` (bytes 32..44), `hmac_key` (bytes 44..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 | 85 | 4. Add padding |
| 86 | - Content must be encoded from UTF-8 into byte array | 86 | - Content must be encoded from UTF-8 into byte array |
| 87 | - Validate plaintext length. Minimum is 1 byte, maximum is 65535 bytes | 87 | - Validate plaintext length. Minimum is 1 byte, maximum is 4,294,967,295 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 bytes | 88 | - Padding algorithm is related to powers-of-two, with min padded msg size of 32 bytes |
| 90 | - Plaintext length is encoded in big-endian as first 2 bytes of the padded blob | 89 | - Plaintext length prefix is encoded in big-endian: |
| 90 | - If length is less than 65536: prefix is 2 bytes (`u16`), format is `[plaintext_length: u16][plaintext][zero_bytes]` | ||
| 91 | - If length is 65536 or greater: prefix is 6 bytes (2 zero bytes + `u32`), format is `[0x00, 0x00][plaintext_length: u32][plaintext][zero_bytes]` | ||
| 92 | - A zero value in the first 2 bytes signals the extended format; since valid plaintext is at least 1 byte, a u16 length of 0 is otherwise invalid | ||
| 91 | 5. Encrypt padded content | 93 | 5. Encrypt padded content |
| 92 | - Use ChaCha20, with key and nonce from step 3 | 94 | - Use ChaCha20, with key and nonce from step 3 |
| 93 | 6. Calculate MAC (message authentication code) | 95 | 6. Calculate MAC (message authentication code) |
| @@ -112,8 +114,8 @@ validation rules, refer to BIP-340. | |||
| 112 | 2. Decode base64 | 114 | 2. Decode base64 |
| 113 | - Base64 is decoded into `version, nonce, ciphertext, mac` | 115 | - Base64 is decoded into `version, nonce, ciphertext, mac` |
| 114 | - If the version is unknown, implementations must indicate that the encryption version is not supported | 116 | - 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 | 117 | - Validate minimum length of base64 message to prevent DoS on base64 decoder: it must be at least 132 chars |
| 116 | - Validate length of decoded message to verify output of the decoder: it can be in range from 99 to 65603 bytes | 118 | - Validate minimum length of decoded message to verify output of the decoder: it must be at least 99 bytes |
| 117 | 3. Calculate conversation key | 119 | 3. Calculate conversation key |
| 118 | - See step 1 of [encryption](#Encryption) | 120 | - See step 1 of [encryption](#Encryption) |
| 119 | 4. Calculate message keys | 121 | 4. Calculate message keys |
| @@ -124,8 +126,10 @@ validation rules, refer to BIP-340. | |||
| 124 | 6. Decrypt ciphertext | 126 | 6. Decrypt ciphertext |
| 125 | - Use ChaCha20 with key and nonce from step 3 | 127 | - Use ChaCha20 with key and nonce from step 3 |
| 126 | 7. Remove padding | 128 | 7. Remove padding |
| 127 | - Read the first two BE bytes of plaintext that correspond to plaintext length | 129 | - Read the first 2 bytes as a big-endian u16 |
| 128 | - Verify that the length of sliced plaintext matches the value of the two BE bytes | 130 | - If zero, read the next 4 bytes as a big-endian u32 plaintext length (6-byte prefix total) |
| 131 | - Otherwise, use those 2 bytes as the u16 plaintext length (2-byte prefix total) | ||
| 132 | - Verify that the length of sliced plaintext matches the decoded length | ||
| 129 | - Verify that calculated padding from step 3 of the [encryption](#Encryption) process matches the actual padding | 133 | - Verify that calculated padding from step 3 of the [encryption](#Encryption) process matches the actual padding |
| 130 | 134 | ||
| 131 | ### Details | 135 | ### Details |
| @@ -149,7 +153,8 @@ validation rules, refer to BIP-340. | |||
| 149 | `i`-th byte (inclusive) to the `j`-th byte (exclusive) of `x`. | 153 | `i`-th byte (inclusive) to the `j`-th byte (exclusive) of `x`. |
| 150 | - Constants `c`: | 154 | - Constants `c`: |
| 151 | - `min_plaintext_size` is 1. 1 byte msg is padded to 32 bytes. | 155 | - `min_plaintext_size` is 1. 1 byte msg is padded to 32 bytes. |
| 152 | - `max_plaintext_size` is 65535 (64kB - 1). It is padded to 65536 bytes. | 156 | - `max_plaintext_size` is 4294967295 (2^32 - 1). |
| 157 | - `extended_prefix_threshold` is 65536. Lengths below this use a 2-byte u16 prefix; lengths at or above use a 6-byte prefix (2 zero bytes + u32). | ||
| 153 | - Functions | 158 | - Functions |
| 154 | - `base64_encode(string)` and `base64_decode(bytes)` are Base64 ([RFC 4648](https://datatracker.ietf.org/doc/html/rfc4648), with padding) | 159 | - `base64_encode(string)` and `base64_decode(bytes)` are Base64 ([RFC 4648](https://datatracker.ietf.org/doc/html/rfc4648), with padding) |
| 155 | - `concat` refers to byte array concatenation | 160 | - `concat` refers to byte array concatenation |
| @@ -157,6 +162,9 @@ validation rules, refer to BIP-340. | |||
| 157 | - `utf8_encode(string)` and `utf8_decode(bytes)` transform string to byte array and back | 162 | - `utf8_encode(string)` and `utf8_decode(bytes)` transform string to byte array and back |
| 158 | - `write_u8(number)` restricts number to values 0..255 and encodes into Big-Endian uint8 byte array | 163 | - `write_u8(number)` restricts number to values 0..255 and encodes into Big-Endian uint8 byte array |
| 159 | - `write_u16_be(number)` restricts number to values 0..65535 and encodes into Big-Endian uint16 byte array | 164 | - `write_u16_be(number)` restricts number to values 0..65535 and encodes into Big-Endian uint16 byte array |
| 165 | - `write_u32_be(number)` restricts number to values 0..4294967295 and encodes into Big-Endian uint32 byte array | ||
| 166 | - `read_uint16_be(bytes)` reads 2 bytes as a Big-Endian unsigned 16-bit integer | ||
| 167 | - `read_uint32_be(bytes)` reads 4 bytes as a Big-Endian unsigned 32-bit integer | ||
| 160 | - `zeros(length)` creates byte array of length `length >= 0`, filled with zeros | 168 | - `zeros(length)` creates byte array of length `length >= 0`, filled with zeros |
| 161 | - `floor(number)` and `log2(number)` are well-known mathematical methods | 169 | - `floor(number)` and `log2(number)` are well-known mathematical methods |
| 162 | 170 | ||
| @@ -181,35 +189,45 @@ def calc_padded_len(unpadded_len): | |||
| 181 | # Converts unpadded plaintext to padded bytearray | 189 | # Converts unpadded plaintext to padded bytearray |
| 182 | def pad(plaintext): | 190 | def pad(plaintext): |
| 183 | unpadded = utf8_encode(plaintext) | 191 | unpadded = utf8_encode(plaintext) |
| 184 | unpadded_len = len(plaintext) | 192 | unpadded_len = len(unpadded) |
| 185 | if (unpadded_len < c.min_plaintext_size or | 193 | if (unpadded_len < c.min_plaintext_size or |
| 186 | unpadded_len > c.max_plaintext_size): raise Exception('invalid plaintext length') | 194 | unpadded_len > c.max_plaintext_size): raise Exception('invalid plaintext length') |
| 187 | prefix = write_u16_be(unpadded_len) | 195 | if unpadded_len >= c.extended_prefix_threshold: |
| 196 | prefix = concat([0, 0], write_u32_be(unpadded_len)) # 6 bytes | ||
| 197 | else: | ||
| 198 | prefix = write_u16_be(unpadded_len) # 2 bytes | ||
| 188 | suffix = zeros(calc_padded_len(unpadded_len) - unpadded_len) | 199 | suffix = zeros(calc_padded_len(unpadded_len) - unpadded_len) |
| 189 | return concat(prefix, unpadded, suffix) | 200 | return concat(prefix, unpadded, suffix) |
| 190 | 201 | ||
| 191 | # Converts padded bytearray to unpadded plaintext | 202 | # Converts padded bytearray to unpadded plaintext |
| 192 | def unpad(padded): | 203 | def unpad(padded): |
| 193 | unpadded_len = read_uint16_be(padded[0:2]) | 204 | first_two = read_uint16_be(padded[0:2]) |
| 194 | unpadded = padded[2:2+unpadded_len] | 205 | if first_two == 0: |
| 206 | unpadded_len = read_uint32_be(padded[2:6]) | ||
| 207 | prefix_len = 6 | ||
| 208 | else: | ||
| 209 | unpadded_len = first_two | ||
| 210 | prefix_len = 2 | ||
| 211 | unpadded = padded[prefix_len:prefix_len+unpadded_len] | ||
| 195 | if (unpadded_len == 0 or | 212 | if (unpadded_len == 0 or |
| 196 | len(unpadded) != unpadded_len or | 213 | len(unpadded) != unpadded_len or |
| 197 | len(padded) != 2 + calc_padded_len(unpadded_len)): raise Exception('invalid padding') | 214 | len(padded) != prefix_len + calc_padded_len(unpadded_len)): raise Exception('invalid padding') |
| 198 | return utf8_decode(unpadded) | 215 | return utf8_decode(unpadded) |
| 199 | 216 | ||
| 200 | # metadata: always 65b (version: 1b, nonce: 32b, max: 32b) | 217 | # metadata: always 65b (version: 1b, nonce: 32b, mac: 32b) |
| 201 | # plaintext: 1b to 0xffff | 218 | # plaintext: 1b to 0xffffffff |
| 202 | # padded plaintext: 32b to 0xffff | 219 | # padded plaintext (small, <65536): 32b to 0xffff, with 2b prefix -> 34b to 0xffff+2 |
| 203 | # ciphertext: 32b+2 to 0xffff+2 | 220 | # padded plaintext (large, >=65536): 0x10000 to 0xffffffff, with 6b prefix -> 0x10006 to 0xffffffff+6 |
| 204 | # raw payload: 99 (65+32+2) to 65603 (65+0xffff+2) | 221 | # ciphertext: same as padded plaintext (chacha20 doesn't change length) |
| 205 | # compressed payload (base64): 132b to 87472b | 222 | # raw payload (small): 99 (65+34) to 65603 (65+0xffff+2) |
| 223 | # raw payload (large): 65607 (65+0x10006) to 4294967362 (65+0xffffffff+6) | ||
| 206 | def decode_payload(payload): | 224 | def decode_payload(payload): |
| 207 | plen = len(payload) | 225 | plen = len(payload) |
| 208 | if plen == 0 or payload[0] == '#': raise Exception('unknown version') | 226 | if plen == 0 or payload[0] == '#': raise Exception('unknown version') |
| 209 | if plen < 132 or plen > 87472: raise Exception('invalid payload size') | 227 | if plen < 132: raise Exception('invalid payload size') |
| 210 | data = base64_decode(payload) | 228 | data = base64_decode(payload) |
| 211 | dlen = len(d) | 229 | dlen = len(data) |
| 212 | if dlen < 99 or dlen > 65603: raise Exception('invalid data size'); | 230 | if dlen < 99: raise Exception('invalid data size'); |
| 213 | vers = data[0] | 231 | vers = data[0] |
| 214 | if vers != 2: raise Exception('unknown version ' + vers) | 232 | if vers != 2: raise Exception('unknown version ' + vers) |
| 215 | nonce = data[1:33] | 233 | nonce = data[1:33] |