-
Notifications
You must be signed in to change notification settings - Fork 645
First draft of NIP-44v3 #1838
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
First draft of NIP-44v3 #1838
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -21,6 +21,7 @@ Currently defined encryption algorithms: | |
- `0x00` - Reserved | ||
- `0x01` - Deprecated and undefined | ||
- `0x02` - secp256k1 ECDH, HKDF, padding, ChaCha20, HMAC-SHA256, base64 | ||
- `0x03` - Extends v2 for bigger messages and adds key derivation options | ||
|
||
## Limitations | ||
|
||
|
@@ -48,6 +49,206 @@ On its own, messages sent using this scheme have a number of important shortcomi | |
Lack 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. | ||
|
||
## Version 3 | ||
|
||
NIP-44 version 3 improves on version 2 with the following additions: | ||
- The maximum message length of 65,535 bytes is increased to 4,294,967,295 bytes | ||
- The encoding includes a 4th parameter to define the source pubkey used in the encryption to decouple the | ||
encrypting pubkey from the event's pubkey | ||
- The encoding includes a 5th parameter to be used as a key derivation nonce (not to be confused with | ||
the message nonce). This kd-nonce is designed to decouple Nostr's encryption from Nostr's identity, allowing | ||
the creation of stable, but rotatable long-term keys for all encryptions in Nostr. | ||
- The new ciphertext encoding includes a description to be shown to the user when asking for permissions | ||
to decrypt the content. | ||
|
||
### Setup | ||
|
||
Any Nostr user can publish a set of pubkeys to be replace the user's main pubkey when performing encryptions. | ||
Kind `10044` lists all active pubkeys and their respective 32-byte nonce in a lowercase hex as `n` tags. | ||
|
||
``` | ||
{ | ||
"kind": 10044, | ||
"tags": [ | ||
["n", "<pubkey-in-lowercase-hex>", "<32-byte-nonce-in-lowercase-hex>"] | ||
... | ||
] | ||
} | ||
``` | ||
|
||
To create a new pubkey, a random 32-byte kd-nonce should be generated. The private key of the item is computed by | ||
applying HKDF using a salt that is the concatenation of `nip44v3kd` in UTF-8 bytes with the kd-nonce in bytes: | ||
|
||
``` | ||
sha256(hkdf(private_key, salt: utf8_encode('nip44v3kd') + <kd-nonce-byte-array>)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instead of using the user's nsec directly there is an alternative that would be immediately compatible with FROST and MuSig2 bunkers without requiring them to use a "root encryption key" different than their nsec: we make the
The public key should be a NUMS otherwise it becomes possible for anyone to gather the root encryption key of anyone else. As a cool but suspiciously biased suggestion we could use the id of this event: https://njump.me/nevent1qqstnl4ddmhc0kzqpj7p543pvq9nvppc4laewc9x5ppucz7aagsa4dsppamhxue69uhkummnw3ezumt0d5pzpq35r7yzkm4te5460u00jz4djcw0qa90zku7739qn7wj4ralhe4z4huyw2, or, I don't know, something like the hash of Bitcoin block 900000. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I actually think we don't need a NUMS key here because we hash the thing afterwards. Consult a cryptographer before taking this seriously. |
||
``` | ||
|
||
This private key doesn't need to be stored anywhere and can be cached in memory. | ||
|
||
The pubkey is produced from the private key and then added to event kind `10044` | ||
|
||
In the absense of any `10044` event, users should encrypt using the destination's main public key. | ||
|
||
### Encryption | ||
|
||
1. Pick the destination's pubkey B | ||
- 1.1 If no kind `10044` is found, use the destination's main pubkey and set the `destination-kd-nonce` to zeros | ||
- 1.2 If kind `10044` is found, pick one key as pubkey and set the `destination-kd-nonce` to the nonce of that key. | ||
2. Pick the source's private key A | ||
- 2.1 If no kind `10044` is found, use the source's main pubkey and set the `source-kd-nonce` to zeros | ||
- 2.2 If kind `10044` is found, set the `source-kd-nonce` to the nonce of that key and derive private key A | ||
from the source's main private_key as `sha256(hkdf(main_private_key, salt: utf8('nip44v3kd') + <kd-nonce-as-byte-array>))` | ||
- Set `source-public-key` as the pubkey of the picked private key A. | ||
3. Calculate a conversation key | ||
- Execute ECDH (scalar multiplication) of public key B by private key A | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Indicate this is same as v2 perhaps with |
||
Output `shared_x` must be unhashed, 32-byte encoded x coordinate of the shared point | ||
- Use HKDF-extract with sha256, `IKM=shared_x` and `salt=utf8_encode('nip44-v2')` | ||
- HKDF output will be a `conversation_key` between two users. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. would you change this 'nip44-v2' salt to 'nip44-v3'? |
||
- It is always the same, when key roles are swapped: `conv(a, B) == conv(b, A)` | ||
4. Generate a random 32-byte nonce | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Indicate this is same as v2 perhaps with |
||
- Always use [CSPRNG](https://en.wikipedia.org/wiki/Cryptographically_secure_pseudorandom_number_generator) | ||
- Don't generate a nonce from message content | ||
- Don't re-use the same nonce between messages: doing so would make them decryptable, | ||
but won't leak the long-term key | ||
5. Calculate message keys | ||
- The keys are generated from `conversation_key` and `nonce`. Validate that both are 32 bytes long | ||
- Use HKDF-expand, with sha256, `PRK=conversation_key`, `info=nonce` and `L=76` | ||
- Slice 76-byte HKDF output into: `chacha_key` (bytes 0..32), `chacha_nonce` (bytes 32..44), `hmac_key` (bytes 44..76) | ||
6. Add padding | ||
- Content must be encoded from UTF-8 into byte array | ||
- Validate plaintext length. Minimum is 1 byte, maximum is 4,294,967,295 bytes | ||
- Padding format is: `[plaintext_length: u32][plaintext][zero_bytes]` | ||
- Padding algorithm is related to powers-of-two, with min padded msg size of 32 bytes | ||
- Plaintext length is encoded in big-endian as first 4 bytes of the padded blob | ||
7. Encrypt padded content | ||
- Use ChaCha20, with key and nonce from step 3 | ||
8. Calculate MAC (message authentication code) | ||
- AAD (additional authenticated data) is used - instead of calculating MAC on ciphertext, | ||
it's calculated over a concatenation of `nonce` and `ciphertext` | ||
- Validate that AAD (nonce) is 32 bytes | ||
9. Base64-encode (with padding) params using `concat(version, nonce, ciphertext, mac, source-public-key, destination-kd-nonce)` | ||
- `destination-kd-nonce` must be filled with zeros when using their main keys | ||
|
||
### Decryption | ||
|
||
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. | ||
|
||
1. Check if first payload's character is `#` | ||
- `#` is an optional future-proof flag that means non-base64 encoding is used | ||
- The `#` is not present in base64 alphabet, but, instead of throwing `base64 is invalid`, | ||
implementations MUST indicate that the encryption version is not yet supported | ||
2. Decode base64 | ||
- Base64 is decoded into `version, nonce, ciphertext, mac, source-public-key, destination-kd-nonce` | ||
- If the version is unknown, implementations must indicate that the encryption version is not supported | ||
- Validate length of base64 message to prevent DoS on base64 decoder: it can be in range from 224 to 5,732,652,648 chars | ||
- Validate length of decoded message to verify output of the decoder: it can be in range from 169 to 4,294,967,432 bytes | ||
3. Calculate private key A: | ||
- Use the `destination-kd-nonce` to derive a private key as in Step 2 of [encryption](#Encryption | ||
4. Calculate conversation key using `source-public-key` as pubkey B | ||
- See step 3 of [encryption](#Encryption) | ||
5. Calculate message keys | ||
- See step 5 of [encryption](#Encryption) | ||
6. Calculate MAC (message authentication code) with AAD and compare | ||
- Stop and throw an error if MAC doesn't match the decoded one from step 2 | ||
- Use constant-time comparison algorithm | ||
7. Decrypt ciphertext | ||
- Use ChaCha20 with key and nonce from step 3 | ||
8. Remove padding | ||
- Read the first two BE bytes of plaintext that correspond to plaintext length | ||
- Verify that the length of sliced plaintext matches the value of the two BE bytes | ||
- Verify that calculated padding from step 3 of the [encryption](#Encryption) process matches the actual padding | ||
|
||
### Implementation pseudocode | ||
|
||
The following methods have slightly changed from version 2 to account for the new sizes and encodings. | ||
|
||
```py | ||
|
||
# Converts unpadded plaintext and alt to padded bytearray | ||
def pad(plaintext, alt): | ||
text_utf8_bytes = utf8_encode(plaintext) | ||
text_len = len(text_utf8_bytes) | ||
alt_utf8_bytes = utf8_encode(alt) | ||
alt_len = len(alt_utf8_bytes) | ||
|
||
unpadded = text_utf8_bytes + alt_utf8_bytes | ||
|
||
unpadded_len = len(unpadded) | ||
if (unpadded_len < c.min_plaintext_size or | ||
unpadded_len > c.max_plaintext_size): raise Exception('invalid plaintext length') | ||
|
||
text_len_bytes = write_u32_be(text_len) | ||
alt_len_bytes = write_u32_be(alt_len) | ||
suffix = zeros(calc_padded_len(unpadded_len) - unpadded_len) | ||
return concat(text_len_bytes, alt_len_bytes, unpadded, suffix) | ||
|
||
# Converts padded bytearray to unpadded plaintext and alt | ||
def unpad(padded): | ||
text_len = read_uint32_be(padded[0:4]) | ||
alt_len = read_uint32_be(padded[4:8]) | ||
|
||
unpadded_len = text_len + alt_len | ||
unpadded = padded[8:8+unpadded_len] | ||
if (unpadded_len == 0 or | ||
len(unpadded) != unpadded_len or | ||
len(padded) != 2 + calc_padded_len(unpadded_len)): raise Exception('invalid padding') | ||
|
||
plaintext = utf8_decode(unpadded[0:text_len]) | ||
alt = utf8_decode(unpadded[text_len:text_len+alt_len]) | ||
return (plaintext, alt) | ||
|
||
# metadata: always 129b (version: 1b, nonce: 32b, max: 32b, s-pubkey: 32b, d-nonce: 32b) | ||
# plaintext: 1b to 0xffff | ||
# padded plaintext: 32b to 0xffff | ||
# ciphertext: 32b+8 to 0xffff+8 | ||
# raw payload: 169 (129+32+8) to 4,294,967,432 (129+0xffff+8) | ||
# compressed payload (base64): 224b to 5,732,652,648b | ||
def decode_payload(payload): | ||
plen = len(payload) | ||
if plen == 0 or payload[0] == '#': raise Exception('unknown version') | ||
if plen < 224 or plen > 5,732,652,648: raise Exception('invalid payload size') | ||
data = base64_decode(payload) | ||
dlen = len(d) | ||
if dlen < 169 or dlen > 4,294,967,432: raise Exception('invalid data size'); | ||
vers = data[0] | ||
if vers != 3: raise Exception('unknown version ' + vers) | ||
nonce = data[1:33] | ||
ciphertext = data[33:dlen - 96] | ||
mac = data[dlen - (96):dlen - (64)] | ||
src_public_key = data[dlen - (64):dlen - (32)] | ||
dst_kd_nonce = data[dlen - (32):dlen] | ||
return (nonce, ciphertext, mac, src_public_key, dst_kd_nonce) | ||
|
||
# derives a private key from a nonce over the current key | ||
def derive(private_key, nonce): | ||
if (nonce == ZEROS) | ||
return private_key | ||
return sha256(hkdf_extract(IKM=private_key, salt=utf8_encode('nip44v3kd')+nonce)) | ||
|
||
# encrypts from a variant of the private key to a variant of the pubkey | ||
def encrypt(plaintext, alt, src_main_private_key, src_kd_nonce, dst_pubkey, dst_kd_nonce): | ||
private_key = derive(src_main_private_key, src_kd_nonce) | ||
src_public_key = public_key(private_key) | ||
conversation_key = get_conversation_key(private_key, dst_pubkey) | ||
(chacha_key, chacha_nonce, hmac_key) = get_message_keys(conversation_key, nonce) | ||
padded = pad(plaintext, alt) | ||
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, src_public_key, dst_kd_nonce)) | ||
|
||
def decrypt(payload, main_private_key): | ||
(nonce, ciphertext, mac, src_public_key, dst_kd_nonce) = decode_payload(payload) | ||
private_key = derive(main_private_key, dst_kd_nonce) | ||
conversation_key = get_conversation_key(private_key, src_public_key) | ||
(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) | ||
``` | ||
|
||
## Version 2 | ||
|
||
NIP-44 version 2 has the following design characteristics: | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are people searching for these 'n' tags? If not, no need for a single letter.