Skip to content

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

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 201 additions & 0 deletions 44.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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>"]
...
Copy link
Contributor

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.

]
}
```

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>))
Copy link
Member

Choose a reason for hiding this comment

The 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 private_key in this context be equal to:

private_key = ecdh(nsec, nums_public_key)

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.

Copy link
Member

Choose a reason for hiding this comment

The 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indicate this is same as v2 perhaps with (identical to v2) (and to subsequent sections that haven't changed)

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.
Copy link
Contributor

Choose a reason for hiding this comment

The 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indicate this is same as v2 perhaps with (identical to v2)

- 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:
Expand Down