nips/44.md

5.8 KiB

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:

AZKyMIHbfVYFlAAK7Ci5wuM5GFOLaeI7LQKDzWJY

Other Notes

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

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 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 payload = new Uint8Array(1 + 24 + ciphertext.length)
  payload.set([version], 0)
  payload.set(nonce, 1)
  payload.set(ciphertext, 1 + 24)

  return base64.encode(payload)
}

export function decrypt(privkey: string, pubkey: string, payload: string) {
  const payload = base64.decode(blob)
  if (payload[0] !== 1) {
    throw new Error('NIP44: unknown encryption version')
  }

  const nonce = payload.subarray(1, 25)
  const ciphertext = payload.subarray(25)

  const key = getSharedSecret(privkey, pubkey)
  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)
}