From f84c6148c3ace8abb55c85612e3eaaccf701f127 Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Wed, 31 May 2023 15:19:31 +0200 Subject: [PATCH 1/4] NIP-44: Encrypted Direct Message (Versioned), replaces NIP-4 --- 44.md | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 44.md diff --git a/44.md b/44.md new file mode 100644 index 0000000000..a46f45ab2f --- /dev/null +++ b/44.md @@ -0,0 +1,62 @@ +NIP-44 +====== + +Encrypted Direct Message (Versioned) +------------------------------------ + +`optional` `author:paulmillr` + +The NIP replaces NIP4, which is deficient with regards to algorithm choices, and introduces cryptography versioning. NIP4 is potentially vulnerable to [padding oracle attacks](https://en.wikipedia.org/wiki/Padding_oracle_attack) and uses keys which are not indistinguishable from random. + +A special event with kind `44`, meaning "encrypted direct message". It is supposed to have the following attributes: + +**`content`** MUST be equal to the base64-encoded, ALGORITHM encrypted string of anything a user wants to write, encrypted using a shared cipher generated by combining the recipient's public-key with the sender's private-key; this appended by the base64-encoded initialization vector as if it was a querystring parameter named "iv". The format is the following: `"content": "?iv="`. + +**`tags`** MUST contain an entry identifying the receiver of the message (such that relays may naturally forward this event to them), in the form `["p", ""]`. + +**`tags`** MAY contain an entry identifying the previous message in a conversation or a message we are explicitly replying to (such that contextual, more organized conversations may happen), in the form `["e", ""]`. + +**Note**: By default in the [libsecp256k1](https://github.com/bitcoin-core/secp256k1) ECDH implementation, the secret is the SHA256 hash of the shared point (both X and Y coordinates). We are using this exact implementation. In NIP4, unhashed shared point was used. + +## Versioning + +Clients MUST throw a descriptive error if they receive NIP44 message, version of which they don't support. + +Currently defined encryption algorithms: + +- `0x00` - RESERVED +- `0x01` - ChaCha20 + sha256(ecdh) + +## Security Warning + +This standard does not go anywhere near what is considered the state-of-the-art in encrypted communication between peers, and it leaks metadata in the events, therefore it must not be used for anything you really need to keep secret, and only with relays that use `AUTH` to restrict who can fetch your `kind:4` events. + +## Client Implementation Warning + +Clients *should not* search and replace public key or note references from the `.content`. If processed like a regular text note (where `@npub...` is replaced with `#[0]` with a `["p", "..."]` tag) the tags are leaked and the mentioned user will receive the message in their inbox. + +## Algorithm + +```js +import { secp256k1 } from '@noble/curves/secp256k1'; +import { sha256 } from '@noble/hashes/sha256'; +import { ensureBytes, randomBytes } from '@noble/hashes/utils'; +import { xchacha20 } from 'micro-ciphers'; + +function getConversationKey(person1PrivateKey, person2PublicKey) { + const point = secp256k1.getSharedSecret(person1PrivateKey, person2PublicKey).slice(1, 33); + ensureBytes(point, 32); + return sha256(point); +} +function encrypt(key, event) { + if (version !== 1) throw new Error('Unsupported encryption'); + const iv = randomBytes(24); + const ciphertext = xchacha20.encrypt(key, ciphertext, iv); + return { ciphertext, iv } +} +function decrypt(key, event) { + if (version !== 1) throw new Error('Unsupported encryption'); + const plaintext = xchacha20.decrypt(key, ciphertext, iv); + return plaintext; +} +``` From cc4be11b475b5f50f46ded8acfe70dd335adf6ee Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Wed, 31 May 2023 16:41:59 +0200 Subject: [PATCH 2/4] Update code. --- 44.md | 54 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/44.md b/44.md index a46f45ab2f..428d28957a 100644 --- a/44.md +++ b/44.md @@ -38,25 +38,43 @@ Clients *should not* search and replace public key or note references from the ` ## Algorithm ```js -import { secp256k1 } from '@noble/curves/secp256k1'; -import { sha256 } from '@noble/hashes/sha256'; -import { ensureBytes, randomBytes } from '@noble/hashes/utils'; -import { xchacha20 } from 'micro-ciphers'; - -function getConversationKey(person1PrivateKey, person2PublicKey) { - const point = secp256k1.getSharedSecret(person1PrivateKey, person2PublicKey).slice(1, 33); - ensureBytes(point, 32); - return sha256(point); +import {randomBytes} from '@noble/hashes/utils' +import {secp256k1} from '@noble/curves/secp256k1' +import {base64} from '@scure/base' +import {streamXOR as xchacha20_stream} from '@stablelib/xchacha20' +import {utf8Decoder, utf8Encoder} from './utils.ts' +import {sha256} from '@noble/hashes/sha256' + +export function getConversationKey(privkeyA: string, pubkeyB: string) { + const key = secp256k1.getSharedSecret(privkeyA, '02' + pubkeyB) + return sha256(key.slice(1, 33)) } -function encrypt(key, event) { - if (version !== 1) throw new Error('Unsupported encryption'); - const iv = randomBytes(24); - const ciphertext = xchacha20.encrypt(key, ciphertext, iv); - return { ciphertext, iv } + +export function encrypt( + privkey: string, + pubkey: string, + text: string, + ver = 1 +): string { + if (ver !== 1) throw new Error('NIP44: unknown encryption version') + let key = getConversationKey(privkey, pubkey) + let nonce = randomBytes(24) + let plaintext = utf8Encoder.encode(text) + let ciphertext = xchacha20_stream(key, nonce, plaintext, plaintext) + let ctb64 = base64.encode(ciphertext) + let nonceb64 = base64.encode(nonce) + return JSON.stringify({ciphertext: ctb64, nonce: nonceb64, v: 1}) } -function decrypt(key, event) { - if (version !== 1) throw new Error('Unsupported encryption'); - const plaintext = xchacha20.decrypt(key, ciphertext, iv); - return plaintext; + +export function decrypt(privkey: string, pubkey: string, data: string): string { + let dt = JSON.parse(data) + if (dt.v !== 1) throw new Error('NIP44: unknown encryption version') + let {ciphertext, nonce} = dt + ciphertext = base64.decode(ciphertext) + nonce = base64.decode(nonce) + let key = getConversationKey(privkey, pubkey) + let plaintext = xchacha20_stream(key, nonce, ciphertext, ciphertext) + let text = utf8Decoder.decode(plaintext) + return text } ``` From 7f9627ebb3dfe1a175a8cb8108be102a062df328 Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Wed, 31 May 2023 17:14:10 +0200 Subject: [PATCH 3/4] Clarify JSON. --- 44.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/44.md b/44.md index 428d28957a..eb10592d79 100644 --- a/44.md +++ b/44.md @@ -10,7 +10,7 @@ The NIP replaces NIP4, which is deficient with regards to algorithm choices, and A special event with kind `44`, meaning "encrypted direct message". It is supposed to have the following attributes: -**`content`** MUST be equal to the base64-encoded, ALGORITHM encrypted string of anything a user wants to write, encrypted using a shared cipher generated by combining the recipient's public-key with the sender's private-key; this appended by the base64-encoded initialization vector as if it was a querystring parameter named "iv". The format is the following: `"content": "?iv="`. +**`content`** MUST be equal to the JSON structure `{ciphertext, nonce, v}`. `ciphertext` is base64-encoded, ALGORITHM encrypted string of anything a user wants to write, encrypted using a shared cipher generated by combining the recipient's public-key with the sender's private-key. `nonce` is base64-encoded algorithm nonce / iv. `version` is algorithm version. **`tags`** MUST contain an entry identifying the receiver of the message (such that relays may naturally forward this event to them), in the form `["p", ""]`. @@ -25,7 +25,7 @@ Clients MUST throw a descriptive error if they receive NIP44 message, version of Currently defined encryption algorithms: - `0x00` - RESERVED -- `0x01` - ChaCha20 + sha256(ecdh) +- `0x01` - XChaCha20 + sha256(ecdh) ## Security Warning @@ -38,12 +38,12 @@ Clients *should not* search and replace public key or note references from the ` ## Algorithm ```js -import {randomBytes} from '@noble/hashes/utils' +// npm install @noble/curves @noble/hashes @scure/base @stablelib/xchacha20 import {secp256k1} from '@noble/curves/secp256k1' -import {base64} from '@scure/base' -import {streamXOR as xchacha20_stream} from '@stablelib/xchacha20' -import {utf8Decoder, utf8Encoder} from './utils.ts' import {sha256} from '@noble/hashes/sha256' +import {randomBytes} from '@noble/hashes/utils' +import {base64} from '@scure/base' +import {streamXOR as xchacha20} from '@stablelib/xchacha20' export function getConversationKey(privkeyA: string, pubkeyB: string) { const key = secp256k1.getSharedSecret(privkeyA, '02' + pubkeyB) @@ -59,8 +59,8 @@ export function encrypt( if (ver !== 1) throw new Error('NIP44: unknown encryption version') let key = getConversationKey(privkey, pubkey) let nonce = randomBytes(24) - let plaintext = utf8Encoder.encode(text) - let ciphertext = xchacha20_stream(key, nonce, plaintext, plaintext) + let plaintext = new TextEncoder().encode(text) + let ciphertext = xchacha20(key, nonce, plaintext, plaintext) let ctb64 = base64.encode(ciphertext) let nonceb64 = base64.encode(nonce) return JSON.stringify({ciphertext: ctb64, nonce: nonceb64, v: 1}) @@ -73,8 +73,8 @@ export function decrypt(privkey: string, pubkey: string, data: string): string { ciphertext = base64.decode(ciphertext) nonce = base64.decode(nonce) let key = getConversationKey(privkey, pubkey) - let plaintext = xchacha20_stream(key, nonce, ciphertext, ciphertext) - let text = utf8Decoder.decode(plaintext) + let plaintext = xchacha20(key, nonce, ciphertext, ciphertext) + let text = new TextDecoder().decode(plaintext) return text } ``` From ec80ceafdde6403514277595728f7c7c1aeb5be9 Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Wed, 12 Jul 2023 05:52:52 +0200 Subject: [PATCH 4/4] Add AUTH and relay signaling --- 44.md | 60 +++++++++++++++++++++++++++++++++++++---------------------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/44.md b/44.md index eb10592d79..1b7ce0b5ff 100644 --- a/44.md +++ b/44.md @@ -10,7 +10,11 @@ The NIP replaces NIP4, which is deficient with regards to algorithm choices, and A special event with kind `44`, meaning "encrypted direct message". It is supposed to have the following attributes: -**`content`** MUST be equal to the JSON structure `{ciphertext, nonce, v}`. `ciphertext` is base64-encoded, ALGORITHM encrypted string of anything a user wants to write, encrypted using a shared cipher generated by combining the recipient's public-key with the sender's private-key. `nonce` is base64-encoded algorithm nonce / iv. `version` is algorithm version. +**`content`** MUST be equal to the CSV structure `v,param1,param2...`. Different versions may have different param count. First part is always algorithm version, a number. Params of version 1: + + 1. `nonce`: base64-encoded xchacha nonce + 2. `ciphertext`: base64-encoded xchacha ciphertext, created from (key, nonce) against `plaintext`. + Example: `1,3dBKd83Pg2Q4Tu2A2e8N++c+ZW2IBc2f,FvQi1H4atMwU+FzUR/0CJ7kowjs+` **`tags`** MUST contain an entry identifying the receiver of the message (such that relays may naturally forward this event to them), in the form `["p", ""]`. @@ -18,14 +22,28 @@ A special event with kind `44`, meaning "encrypted direct message". It is suppos **Note**: By default in the [libsecp256k1](https://github.com/bitcoin-core/secp256k1) ECDH implementation, the secret is the SHA256 hash of the shared point (both X and Y coordinates). We are using this exact implementation. In NIP4, unhashed shared point was used. +## Metadata leakage + +All nostr events are passed over the internet, which means some metadata would always leak. + +All compatible clients MUST implement following flow to limit leakage: + +1. Alice authenticates on `wss://nostr.example.com` using NIP-42. This would mean sending and receiving + messages would only be available to proper, authenticated users. +2. On success, Alice creates NIP-65 event specifying `wss://nostr.example.com` as her preferred relay. + The setting is public, and the network would see Alice's preferred DM relays. + Whenever a new user wants to start a conversation with Alice, they would use one of Alice's relays. + +If user did not specify NIP-65 preferred relays, they SHOULD NOT receive direct messages. + ## Versioning -Clients MUST throw a descriptive error if they receive NIP44 message, version of which they don't support. +Clients MUST throw a descriptive error if they receive NIP44 message, version of which they don't support. The error must mention the message version is not supported and suggest appropriate action, such as upgrading, or switching to a different client. Currently defined encryption algorithms: - `0x00` - RESERVED -- `0x01` - XChaCha20 + sha256(ecdh) +- `0x01` - XChaCha with same key `sha256(ecdh)` per conversation ## Security Warning @@ -39,42 +57,40 @@ Clients *should not* search and replace public key or note references from the ` ```js // npm install @noble/curves @noble/hashes @scure/base @stablelib/xchacha20 +import {xchacha20} from '@noble/ciphers/chacha' import {secp256k1} from '@noble/curves/secp256k1' import {sha256} from '@noble/hashes/sha256' import {randomBytes} from '@noble/hashes/utils' import {base64} from '@scure/base' -import {streamXOR as xchacha20} from '@stablelib/xchacha20' +import {utf8Decoder, utf8Encoder} from './utils.ts' -export function getConversationKey(privkeyA: string, pubkeyB: string) { +export function getConversationKey(privkeyA: string, pubkeyB: string): Uint8Array { const key = secp256k1.getSharedSecret(privkeyA, '02' + pubkeyB) - return sha256(key.slice(1, 33)) + return sha256(key.subarray(1, 33)) } export function encrypt( - privkey: string, - pubkey: string, + key: Uint8Array, text: string, ver = 1 ): string { if (ver !== 1) throw new Error('NIP44: unknown encryption version') - let key = getConversationKey(privkey, pubkey) let nonce = randomBytes(24) - let plaintext = new TextEncoder().encode(text) + let plaintext = utf8Encoder.encode(text) let ciphertext = xchacha20(key, nonce, plaintext, plaintext) - let ctb64 = base64.encode(ciphertext) - let nonceb64 = base64.encode(nonce) - return JSON.stringify({ciphertext: ctb64, nonce: nonceb64, v: 1}) + return `1,${base64.encode(nonce)},${base64.encode(ciphertext)}` } -export function decrypt(privkey: string, pubkey: string, data: string): string { - let dt = JSON.parse(data) - if (dt.v !== 1) throw new Error('NIP44: unknown encryption version') - let {ciphertext, nonce} = dt - ciphertext = base64.decode(ciphertext) - nonce = base64.decode(nonce) - let key = getConversationKey(privkey, pubkey) - let plaintext = xchacha20(key, nonce, ciphertext, ciphertext) - let text = new TextDecoder().decode(plaintext) +export function decrypt(key: Uint8Array, data: string): string { + let dt = data.split(',') + if (dt.length !== 3) throw new Error('NIP44: unknown encryption version'); + let v = Number.parseInt(dt[0]) + if (v !== 1) throw new Error('NIP44: unknown encryption version') + + let nonce = base64.decode(dt[1]) + let ciphertext = base64.decode(dt[2]) + let plaintext = xchacha20(key, nonce, ciphertext) + let text = utf8Decoder.decode(plaintext) return text } ```