nips/44.md
2023-08-11 08:34:18 -07:00

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 - 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 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)
}