5.2 KiB
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 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
- Reserved0x01
- XChaCha with same keysha256(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:
nonce
: base64-encoded xchacha nonceciphertext
: base64-encoded xchacha ciphertext, created from (key, nonce) againstplaintext
.
Example:
{
"ciphertext": "FvQi1H4atMwU+FzUR/0CJ7kowjs+",
"nonce": "3dBKd83Pg2Q4Tu2A2e8N++c+ZW2IBc2f",
"v": 1
}
Note: By default in the libsecp256k1 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
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
// 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)
}