diff --git a/44.md b/44.md new file mode 100644 index 00000000..9982156b --- /dev/null +++ b/44.md @@ -0,0 +1,170 @@ +NIP-44 +====== + +Encrypted Direct Message (Versioned) +------------------------------------ + +`optional` `author:paulmillr` `author:staab` + +The NIP introduces versioned encryption, allowing multiple algorithm choices to exist simultaneously. + +The algorithm described in 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. + +An encrypted payload MUST be encoded as a JSON object. Different versions may have different parameters. Every format has a `v` field specifying its version. + +Currently defined encryption algorithms: + +- `0x00` - Reserved +- `0x01` - XChaCha with same key `sha256(ecdh)` per conversation + +# Version 0 + +Version 0 is not defined, however implementations depending on this NIP MAY choose to support the payload described in NIP 04 in the same places a NIP 44 payload would otherwise be expected. This is intended to allow a smooth transition while clients and signing software adopt the new standard. + +# Version 1 + +Params: + +1. `nonce`: base64-encoded xchacha nonce +2. `ciphertext`: base64-encoded xchacha ciphertext, created from (key, nonce) against `plaintext`. + +Example: + +``` +{ + "ciphertext": "FvQi1H4atMwU+FzUR/0CJ7kowjs+", + "nonce": "3dBKd83Pg2Q4Tu2A2e8N++c+ZW2IBc2f", + "v": 1 +} +``` + +**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. + +## Code Samples + +### Javascript + +```javascript +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" + +export const utf8Decoder = new TextDecoder() + +export const utf8Encoder = new TextEncoder() + +export const getSharedSecret = (privkey: string, pubkey: string): Uint8Array => + sha256(secp256k1.getSharedSecret(privkey, "02" + pubkey).subarray(1, 33)) + +export function encrypt(privkey: string, pubkey: string, text: string, v = 1) { + if (v !== 1) { + throw new Error("NIP44: unknown encryption version") + } + + const key = getSharedSecret(privkey, pubkey) + const nonce = randomBytes(24) + const plaintext = utf8Encoder.encode(text) + const ciphertext = xchacha20(key, nonce, plaintext) + + return JSON.stringify({ + ciphertext: base64.encode(ciphertext), + nonce: base64.encode(nonce), + v, + }) +} + +export function decrypt(privkey: string, pubkey: string, payload: string) { + try { + payload = JSON.parse(payload) as { + ciphertext: string + nonce: string + v: number + } + } catch (e) { + throw new Error("NIP44: failed to parse payload") + } + + if (data.v !== 1) { + throw new Error("NIP44: unknown encryption version") + } + + const key = getSharedSecret(privkey, pubkey) + const nonce = base64.decode(data.nonce) + const ciphertext = base64.decode(data.ciphertext) + const plaintext = xchacha20(key, nonce, ciphertext) + + return utf8Decoder.decode(plaintext) +} +``` + +### Kotlin + +```kotlin +// implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.10.1' +// implementation "com.goterl:lazysodium-android:5.1.0@aar" +// implementation "net.java.dev.jna:jna:5.12.1@aar" + +fun getSharedSecretNIP44(privKey: ByteArray, pubKey: ByteArray): ByteArray = + MessageDigest.getInstance("SHA-256").digest( + Secp256k1.get().pubKeyTweakMul( + Hex.decode("02") + pubKey, + privKey + ).copyOfRange(1, 33) + ) + +fun encryptNIP44(msg: String, privKey: ByteArray, pubKey: ByteArray): EncryptedInfo { + val nonce = ByteArray(24).apply { + SecureRandom.getInstanceStrong().nextBytes(this) + } + + val cipher = streamXChaCha20Xor( + message = msg.toByteArray(), + nonce = nonce, + key = getSharedSecretNIP44(privKey, pubKey) + ) + + return EncryptedInfo( + ciphertext = Base64.getEncoder().encodeToString(cipher), + nonce = Base64.getEncoder().encodeToString(nonce), + v = Nip24Version.XChaCha20.code + ) +} + +fun decryptNIP44(encInfo: EncryptedInfo, privKey: ByteArray, pubKey: ByteArray): String? { + require(encInfo.v == Nip24Version.XChaCha20.code) { "NIP44: unknown encryption version" } + + return streamXChaCha20Xor( + message = Base64.getDecoder().decode(encInfo.ciphertext), + nonce = Base64.getDecoder().decode(encInfo.nonce), + key = getSharedSecretNIP44(privKey, pubKey) + )?.decodeToString() +} + +// This method is not exposed in AndroidSodium yet, but it will be in the next version. +fun streamXChaCha20Xor(message: ByteArray, nonce: ByteArray, key: ByteArray): ByteArray? { + return with (SodiumAndroid()) { + val resultCipher = ByteArray(message.size) + + val isSuccessful = crypto_stream_chacha20_xor_ic( + resultCipher, + message, + message.size.toLong(), + nonce.drop(16).toByteArray(), // chacha nonce is just the last 8 bytes. + 0, + ByteArray(32).apply { + crypto_core_hchacha20(this, nonce, key, null) + } + ) == 0 + + if (isSuccessful) resultCipher else null + } +} + +data class EncryptedInfo(val ciphertext: String, val nonce: String, val v: Int) + +enum class Nip24Version(val code: Int) { + Reserved(0), + XChaCha20(1) +}