NIP-44 ====== Encrypted Payloads (Versioned) ------------------------------ `optional` `author:paulmillr` `author:staab` The NIP introduces a new data format for keypair-based encryption. This NIP is versioned to allow multiple algorithm choices to exist simultaneously. 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 1 Params: 1. `nonce`: base64-encoded xchacha nonce 2. `ciphertext`: base64-encoded xchacha ciphertext, created from (key, nonce) against `plaintext`. Example: - Alice's private key: `5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a` - Bob's private key: `4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d` Encrypting the message `hello` from Alice to Bob results in the base-64 encoded tlv payload: ``` AAEBARgeI8gcP/4mnw3mKgtMvD8aGYUnGBlhopoCBd94Ev9i ``` # Other Notes 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 NIP-94, unhashed shared point was used. This encryption scheme replaces the one described in NIP-04, which is not secure. It used bad cryptographic building blocks and must not be 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, concatBytes} from "@noble/hashes/utils" import {base64} from "@scure/base" export const utf8Decoder = new TextDecoder() export const utf8Encoder = new TextEncoder() export type TLV = {[t: number]: Uint8Array[]} export function parseTLV(data: Uint8Array): TLV { let result: TLV = {} let rest = data while (rest.length > 0) { let t = rest[0] let l = rest[1] if (!l) throw new Error(`malformed TLV ${t}`) let v = rest.slice(2, 2 + l) rest = rest.slice(2 + l) if (v.length < l) throw new Error(`not enough data to read on TLV ${t}`) result[t] = result[t] || [] result[t].push(v) } return result } export function encodeTLV(tlv: TLV): Uint8Array { let entries: Uint8Array[] = [] Object.entries(tlv).forEach(([t, vs]) => { vs.forEach(v => { let entry = new Uint8Array(v.length + 2) entry.set([parseInt(t)], 0) entry.set([v.length], 1) entry.set(v, 2) entries.push(entry) }) }) return concatBytes(...entries) } 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) const tlv = encodeTLV({ 0: [new Uint8Array([1])], 1: [nonce], 2: [ciphertext] }) return base64.encode(tlv) } export function decrypt(privkey: string, pubkey: string, payload: string) { let byteArray try { byteArray = base64.decode(payload) } catch (e) { throw new Error(`NIP44: failed to base64 decode payload: ${e}`) } let tlv try { tlv = parseTLV(byteArray) } catch (e) { throw new Error(`NIP44: failed to decode tlv: ${e}`) } if (tlv[0]?.[0]?.[0] !== 1) { throw new Error(`NIP44: invalid version: ${tlv[0]?.[0]?.[0]}`) } if (tlv[1]?.[0]?.length !== 24) { throw new Error(`NIP44: invalid nonce: ${tlv[1]?.[0]}`) } if (!tlv[2]?.[0]) { throw new Error(`NIP44: missing ciphertext`) } const nonce = tlv[1][0] const ciphertext = tlv[2][0] const key = getSharedSecret(privkey, pubkey) 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) }