diff options
| author | Jonathan Staab <shtaab@gmail.com> | 2023-08-11 08:34:18 -0700 |
|---|---|---|
| committer | Jonathan Staab <shtaab@gmail.com> | 2023-08-11 08:34:18 -0700 |
| commit | 95103569b54b14e5cff55e1da1879fcfe6076349 (patch) | |
| tree | c4b2310d23bf2e8f858665d56a736000e3f5f3e4 /44.md | |
| parent | a5047326d4d7c28e66c5d1262c252b86a1c8fe67 (diff) | |
Introduce NIP-44 encryption standard
Diffstat (limited to '44.md')
| -rw-r--r-- | 44.md | 170 |
1 files changed, 170 insertions, 0 deletions
| @@ -0,0 +1,170 @@ | |||
| 1 | NIP-44 | ||
| 2 | ====== | ||
| 3 | |||
| 4 | Encrypted Direct Message (Versioned) | ||
| 5 | ------------------------------------ | ||
| 6 | |||
| 7 | `optional` `author:paulmillr` `author:staab` | ||
| 8 | |||
| 9 | The NIP introduces versioned encryption, allowing multiple algorithm choices to exist simultaneously. | ||
| 10 | |||
| 11 | 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. | ||
| 12 | |||
| 13 | 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. | ||
| 14 | |||
| 15 | Currently defined encryption algorithms: | ||
| 16 | |||
| 17 | - `0x00` - Reserved | ||
| 18 | - `0x01` - XChaCha with same key `sha256(ecdh)` per conversation | ||
| 19 | |||
| 20 | # Version 0 | ||
| 21 | |||
| 22 | 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. | ||
| 23 | |||
| 24 | # Version 1 | ||
| 25 | |||
| 26 | Params: | ||
| 27 | |||
| 28 | 1. `nonce`: base64-encoded xchacha nonce | ||
| 29 | 2. `ciphertext`: base64-encoded xchacha ciphertext, created from (key, nonce) against `plaintext`. | ||
| 30 | |||
| 31 | Example: | ||
| 32 | |||
| 33 | ``` | ||
| 34 | { | ||
| 35 | "ciphertext": "FvQi1H4atMwU+FzUR/0CJ7kowjs+", | ||
| 36 | "nonce": "3dBKd83Pg2Q4Tu2A2e8N++c+ZW2IBc2f", | ||
| 37 | "v": 1 | ||
| 38 | } | ||
| 39 | ``` | ||
| 40 | |||
| 41 | **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. | ||
| 42 | |||
| 43 | ## Code Samples | ||
| 44 | |||
| 45 | ### Javascript | ||
| 46 | |||
| 47 | ```javascript | ||
| 48 | import {xchacha20} from "@noble/ciphers/chacha" | ||
| 49 | import {secp256k1} from "@noble/curves/secp256k1" | ||
| 50 | import {sha256} from "@noble/hashes/sha256" | ||
| 51 | import {randomBytes} from "@noble/hashes/utils" | ||
| 52 | import {base64} from "@scure/base" | ||
| 53 | |||
| 54 | export const utf8Decoder = new TextDecoder() | ||
| 55 | |||
| 56 | export const utf8Encoder = new TextEncoder() | ||
| 57 | |||
| 58 | export const getSharedSecret = (privkey: string, pubkey: string): Uint8Array => | ||
| 59 | sha256(secp256k1.getSharedSecret(privkey, "02" + pubkey).subarray(1, 33)) | ||
| 60 | |||
| 61 | export function encrypt(privkey: string, pubkey: string, text: string, v = 1) { | ||
| 62 | if (v !== 1) { | ||
| 63 | throw new Error("NIP44: unknown encryption version") | ||
| 64 | } | ||
| 65 | |||
| 66 | const key = getSharedSecret(privkey, pubkey) | ||
| 67 | const nonce = randomBytes(24) | ||
| 68 | const plaintext = utf8Encoder.encode(text) | ||
| 69 | const ciphertext = xchacha20(key, nonce, plaintext) | ||
| 70 | |||
| 71 | return JSON.stringify({ | ||
| 72 | ciphertext: base64.encode(ciphertext), | ||
| 73 | nonce: base64.encode(nonce), | ||
| 74 | v, | ||
| 75 | }) | ||
| 76 | } | ||
| 77 | |||
| 78 | export function decrypt(privkey: string, pubkey: string, payload: string) { | ||
| 79 | try { | ||
| 80 | payload = JSON.parse(payload) as { | ||
| 81 | ciphertext: string | ||
| 82 | nonce: string | ||
| 83 | v: number | ||
| 84 | } | ||
| 85 | } catch (e) { | ||
| 86 | throw new Error("NIP44: failed to parse payload") | ||
| 87 | } | ||
| 88 | |||
| 89 | if (data.v !== 1) { | ||
| 90 | throw new Error("NIP44: unknown encryption version") | ||
| 91 | } | ||
| 92 | |||
| 93 | const key = getSharedSecret(privkey, pubkey) | ||
| 94 | const nonce = base64.decode(data.nonce) | ||
| 95 | const ciphertext = base64.decode(data.ciphertext) | ||
| 96 | const plaintext = xchacha20(key, nonce, ciphertext) | ||
| 97 | |||
| 98 | return utf8Decoder.decode(plaintext) | ||
| 99 | } | ||
| 100 | ``` | ||
| 101 | |||
| 102 | ### Kotlin | ||
| 103 | |||
| 104 | ```kotlin | ||
| 105 | // implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.10.1' | ||
| 106 | // implementation "com.goterl:lazysodium-android:5.1.0@aar" | ||
| 107 | // implementation "net.java.dev.jna:jna:5.12.1@aar" | ||
| 108 | |||
| 109 | fun getSharedSecretNIP44(privKey: ByteArray, pubKey: ByteArray): ByteArray = | ||
| 110 | MessageDigest.getInstance("SHA-256").digest( | ||
| 111 | Secp256k1.get().pubKeyTweakMul( | ||
| 112 | Hex.decode("02") + pubKey, | ||
| 113 | privKey | ||
| 114 | ).copyOfRange(1, 33) | ||
| 115 | ) | ||
| 116 | |||
| 117 | fun encryptNIP44(msg: String, privKey: ByteArray, pubKey: ByteArray): EncryptedInfo { | ||
| 118 | val nonce = ByteArray(24).apply { | ||
| 119 | SecureRandom.getInstanceStrong().nextBytes(this) | ||
| 120 | } | ||
| 121 | |||
| 122 | val cipher = streamXChaCha20Xor( | ||
| 123 | message = msg.toByteArray(), | ||
| 124 | nonce = nonce, | ||
| 125 | key = getSharedSecretNIP44(privKey, pubKey) | ||
| 126 | ) | ||
| 127 | |||
| 128 | return EncryptedInfo( | ||
| 129 | ciphertext = Base64.getEncoder().encodeToString(cipher), | ||
| 130 | nonce = Base64.getEncoder().encodeToString(nonce), | ||
| 131 | v = Nip24Version.XChaCha20.code | ||
| 132 | ) | ||
| 133 | } | ||
| 134 | |||
| 135 | fun decryptNIP44(encInfo: EncryptedInfo, privKey: ByteArray, pubKey: ByteArray): String? { | ||
| 136 | require(encInfo.v == Nip24Version.XChaCha20.code) { "NIP44: unknown encryption version" } | ||
| 137 | |||
| 138 | return streamXChaCha20Xor( | ||
| 139 | message = Base64.getDecoder().decode(encInfo.ciphertext), | ||
| 140 | nonce = Base64.getDecoder().decode(encInfo.nonce), | ||
| 141 | key = getSharedSecretNIP44(privKey, pubKey) | ||
| 142 | )?.decodeToString() | ||
| 143 | } | ||
| 144 | |||
| 145 | // This method is not exposed in AndroidSodium yet, but it will be in the next version. | ||
| 146 | fun streamXChaCha20Xor(message: ByteArray, nonce: ByteArray, key: ByteArray): ByteArray? { | ||
| 147 | return with (SodiumAndroid()) { | ||
| 148 | val resultCipher = ByteArray(message.size) | ||
| 149 | |||
| 150 | val isSuccessful = crypto_stream_chacha20_xor_ic( | ||
| 151 | resultCipher, | ||
| 152 | message, | ||
| 153 | message.size.toLong(), | ||
| 154 | nonce.drop(16).toByteArray(), // chacha nonce is just the last 8 bytes. | ||
| 155 | 0, | ||
| 156 | ByteArray(32).apply { | ||
| 157 | crypto_core_hchacha20(this, nonce, key, null) | ||
| 158 | } | ||
| 159 | ) == 0 | ||
| 160 | |||
| 161 | if (isSuccessful) resultCipher else null | ||
| 162 | } | ||
| 163 | } | ||
| 164 | |||
| 165 | data class EncryptedInfo(val ciphertext: String, val nonce: String, val v: Int) | ||
| 166 | |||
| 167 | enum class Nip24Version(val code: Int) { | ||
| 168 | Reserved(0), | ||
| 169 | XChaCha20(1) | ||
| 170 | } | ||