nip44.go raw

   1  // Package nip44 implements version 2 of NIP-44, the versioned encrypted
   2  // payload format for Nostr.
   3  //
   4  // A payload is base64(version || nonce || ciphertext || mac), where the
   5  // plaintext has been padded with a length prefix as described in the NIP.
   6  // Conversation keys are derived from a secp256k1 x-only ECDH shared secret
   7  // and all message keys come from HKDF-SHA256.
   8  package nip44
   9  
  10  import (
  11  	"crypto/hmac"
  12  	"crypto/rand"
  13  	"crypto/sha256"
  14  	"crypto/subtle"
  15  	"encoding/base64"
  16  	"encoding/binary"
  17  	"errors"
  18  	"math/bits"
  19  
  20  	"git.smesh.lol/nostrino/pkg/chacha20"
  21  	"git.smesh.lol/nostrino/pkg/crypto/secp256k1"
  22  )
  23  
  24  const (
  25  	// Version is the NIP-44 payload version implemented by this package.
  26  	Version = 2
  27  
  28  	nonceSize     = 32
  29  	hmacKeySize   = 32
  30  	chachaKeySize = 32
  31  
  32  	// minPayloadChars and minPayloadBytes are the DoS guards required by
  33  	// the NIP: a version byte, a 32-byte nonce, a 32-byte MAC and at least
  34  	// one padded (>= 34 byte) ciphertext block.
  35  	minPayloadChars = 132
  36  	minPayloadBytes = 99
  37  
  38  	// minPlaintextSize is 1: empty plaintexts are invalid.
  39  	minPlaintextSize = 1
  40  	// maxPlaintextSize is 2^32 - 1, the largest length the wire format can
  41  	// express.
  42  	maxPlaintextSize = 1<<32 - 1
  43  	// prefixThreshold is the plaintext length at which the padding prefix
  44  	// switches from 2-byte u16 to 6-byte (0x0000 || u32) form.
  45  	prefixThreshold = 1 << 16
  46  
  47  	minPaddedSize = 32
  48  )
  49  
  50  var (
  51  	errInvalidKey           = errors.New("nip44: invalid secret key or public key")
  52  	errInvalidPlaintextSize = errors.New("nip44: invalid plaintext length")
  53  	errInvalidPayload       = errors.New("nip44: invalid payload")
  54  	errUnknownVersion       = errors.New("nip44: unknown encryption version")
  55  	errInvalidMAC           = errors.New("nip44: invalid MAC")
  56  	errInvalidPadding       = errors.New("nip44: invalid padding")
  57  )
  58  
  59  // ConversationKey derives the long-term conversation key shared by two users.
  60  // secret is the local 32-byte secp256k1 private key and peerPub is the peer's
  61  // 32-byte x-only public key. The ECDH result is used unhashed, as required by
  62  // NIP-44 (shared x coordinate -> HKDF-Extract with salt "nip44-v2").
  63  func ConversationKey(secret [32]byte, peerPub [32]byte) ([32]byte, error) {
  64  	var key [32]byte
  65  	shared, ok := secp256k1.ECDH(secret, peerPub)
  66  	if !ok {
  67  		return key, errInvalidKey
  68  	}
  69  	return hkdfExtract([]byte("nip44-v2"), shared[:]), nil
  70  }
  71  
  72  // Encrypt encrypts plaintext under conversationKey using a fresh random
  73  // 32-byte nonce and returns the base64 payload.
  74  func Encrypt(conversationKey [32]byte, plaintext []byte) (string, error) {
  75  	var nonce [nonceSize]byte
  76  	if _, err := rand.Read(nonce[:]); err != nil {
  77  		return "", err
  78  	}
  79  	return EncryptWithNonce(conversationKey, nonce, plaintext)
  80  }
  81  
  82  // EncryptWithNonce encrypts plaintext under conversationKey using the given
  83  // 32-byte nonce. The nonce must never be reused for the same conversation
  84  // key; callers should normally use Encrypt.
  85  func EncryptWithNonce(conversationKey [32]byte, nonce [32]byte, plaintext []byte) (string, error) {
  86  	padded, err := pad(plaintext)
  87  	if err != nil {
  88  		return "", err
  89  	}
  90  	chachaKey, chachaNonce, hmacKey := messageKeys(conversationKey, nonce)
  91  
  92  	ciphertext := make([]byte, len(padded))
  93  	chacha20.XORKeyStream(&chachaKey, &chachaNonce, ciphertext, padded)
  94  	mac := aadMAC(&hmacKey, &nonce, ciphertext)
  95  
  96  	raw := make([]byte, 0, 1+nonceSize+len(ciphertext)+32)
  97  	raw = append(raw, Version)
  98  	raw = append(raw, nonce[:]...)
  99  	raw = append(raw, ciphertext...)
 100  	raw = append(raw, mac[:]...)
 101  	return base64.StdEncoding.EncodeToString(raw), nil
 102  }
 103  
 104  // Decrypt decrypts a NIP-44 payload using the conversation key. It returns an
 105  // error for any malformed, unauthenticated or wrongly padded payload.
 106  func Decrypt(conversationKey [32]byte, payload string) ([]byte, error) {
 107  	// A leading '#' signals a future, non-base64 encoding.
 108  	if len(payload) == 0 || payload[0] == '#' {
 109  		return nil, errUnknownVersion
 110  	}
 111  	if len(payload) < minPayloadChars {
 112  		return nil, errInvalidPayload
 113  	}
 114  	data, err := base64.StdEncoding.DecodeString(payload)
 115  	if err != nil {
 116  		return nil, errInvalidPayload
 117  	}
 118  	if len(data) < minPayloadBytes {
 119  		return nil, errInvalidPayload
 120  	}
 121  	if data[0] != Version {
 122  		return nil, errUnknownVersion
 123  	}
 124  
 125  	var nonce [nonceSize]byte
 126  	copy(nonce[:], data[1:1+nonceSize])
 127  	ciphertextEnd := len(data) - 32
 128  	ciphertext := data[1+nonceSize : ciphertextEnd]
 129  	var mac [32]byte
 130  	copy(mac[:], data[ciphertextEnd:])
 131  
 132  	chachaKey, chachaNonce, hmacKey := messageKeys(conversationKey, nonce)
 133  	want := aadMAC(&hmacKey, &nonce, ciphertext)
 134  	if subtle.ConstantTimeCompare(want[:], mac[:]) != 1 {
 135  		return nil, errInvalidMAC
 136  	}
 137  
 138  	padded := make([]byte, len(ciphertext))
 139  	chacha20.XORKeyStream(&chachaKey, &chachaNonce, padded, ciphertext)
 140  	return unpad(padded)
 141  }
 142  
 143  // hkdfExtract implements HKDF-Extract (RFC 5869) with SHA-256.
 144  func hkdfExtract(salt, ikm []byte) [32]byte {
 145  	mac := hmac.New(sha256.New, salt)
 146  	mac.Write(ikm)
 147  	var prk [32]byte
 148  	mac.Sum(prk[:0])
 149  	return prk
 150  }
 151  
 152  // hkdfExpand implements HKDF-Expand (RFC 5869) with SHA-256 for a 32-byte
 153  // PRK. It panics if length exceeds the 255-block limit (8160 bytes); NIP-44
 154  // only ever asks for 76 bytes.
 155  func hkdfExpand(prk [32]byte, info []byte, length int) []byte {
 156  	if length > 255*sha256.Size {
 157  		panic("nip44: hkdf length too large")
 158  	}
 159  	out := make([]byte, 0, length)
 160  	var block []byte // T(0) is empty; each iteration hashes the previous T.
 161  	for counter := 1; len(out) < length; counter++ {
 162  		mac := hmac.New(sha256.New, prk[:])
 163  		mac.Write(block)
 164  		mac.Write(info)
 165  		mac.Write([]byte{byte(counter)})
 166  		block = mac.Sum(block[:0])
 167  		out = append(out, block...)
 168  	}
 169  	return out[:length]
 170  }
 171  
 172  // messageKeys derives the ChaCha20 key and nonce and the HMAC key for one
 173  // message from the conversation key and 32-byte nonce.
 174  func messageKeys(conversationKey [32]byte, nonce [32]byte) (key [chachaKeySize]byte, chachaNonce [chacha20.NonceSize]byte, hmacKey [hmacKeySize]byte) {
 175  	okm := hkdfExpand(conversationKey, nonce[:], chachaKeySize+chacha20.NonceSize+hmacKeySize)
 176  	copy(key[:], okm[0:chachaKeySize])
 177  	copy(chachaNonce[:], okm[chachaKeySize:chachaKeySize+chacha20.NonceSize])
 178  	copy(hmacKey[:], okm[chachaKeySize+chacha20.NonceSize:])
 179  	return key, chachaNonce, hmacKey
 180  }
 181  
 182  // calcPaddedLen returns the length of the padded plaintext (excluding the
 183  // length prefix) for an unpadded length, as defined by NIP-44.
 184  //
 185  // The calculation deliberately uses 64-bit arithmetic because the result can
 186  // reach 2^32, which overflows a 32-bit integer.
 187  func calcPaddedLen(unpaddedLen uint64) uint64 {
 188  	if unpaddedLen <= minPaddedSize {
 189  		return minPaddedSize
 190  	}
 191  	nextPower := uint64(1) << bits.Len64(unpaddedLen-1)
 192  	var chunk uint64
 193  	if nextPower <= 256 {
 194  		chunk = 32
 195  	} else {
 196  		chunk = nextPower / 8
 197  	}
 198  	return chunk * ((unpaddedLen-1)/chunk + 1)
 199  }
 200  
 201  // pad prepends the length prefix and appends zero padding.
 202  func pad(plaintext []byte) ([]byte, error) {
 203  	n := uint64(len(plaintext))
 204  	if n < minPlaintextSize || n > maxPlaintextSize {
 205  		return nil, errInvalidPlaintextSize
 206  	}
 207  	prefixLen := 2
 208  	if n >= prefixThreshold {
 209  		prefixLen = 6
 210  	}
 211  	total := uint64(prefixLen) + calcPaddedLen(n)
 212  	if total > uint64(maxInt()) {
 213  		return nil, errInvalidPlaintextSize
 214  	}
 215  	out := make([]byte, int(total))
 216  	if prefixLen == 6 {
 217  		// The zero u16 signals the extended prefix.
 218  		binary.BigEndian.PutUint32(out[2:6], uint32(n))
 219  	} else {
 220  		binary.BigEndian.PutUint16(out[0:2], uint16(n))
 221  	}
 222  	copy(out[prefixLen:], plaintext)
 223  	return out, nil
 224  }
 225  
 226  // unpad validates the length prefix, the padded length and the zero padding,
 227  // then returns the plaintext slice.
 228  func unpad(padded []byte) ([]byte, error) {
 229  	if len(padded) < 2 {
 230  		return nil, errInvalidPadding
 231  	}
 232  	prefixLen := 2
 233  	n := uint64(binary.BigEndian.Uint16(padded[0:2]))
 234  	if n == 0 {
 235  		if len(padded) < 6 {
 236  			return nil, errInvalidPadding
 237  		}
 238  		n = uint64(binary.BigEndian.Uint32(padded[2:6]))
 239  		// A short length in extended form is invalid.
 240  		if n < prefixThreshold {
 241  			return nil, errInvalidPadding
 242  		}
 243  		prefixLen = 6
 244  	}
 245  	if n < minPlaintextSize || n > maxPlaintextSize {
 246  		return nil, errInvalidPadding
 247  	}
 248  	if uint64(len(padded)) != uint64(prefixLen)+calcPaddedLen(n) {
 249  		return nil, errInvalidPadding
 250  	}
 251  	end := prefixLen + int(n)
 252  	for _, b := range padded[end:] {
 253  		if b != 0 {
 254  			return nil, errInvalidPadding
 255  		}
 256  	}
 257  	return padded[prefixLen:end], nil
 258  }
 259  
 260  // aadMAC computes HMAC-SHA256 over nonce || ciphertext, as required by
 261  // NIP-44.
 262  func aadMAC(key *[hmacKeySize]byte, nonce *[nonceSize]byte, ciphertext []byte) [32]byte {
 263  	mac := hmac.New(sha256.New, key[:])
 264  	mac.Write(nonce[:])
 265  	mac.Write(ciphertext)
 266  	var out [32]byte
 267  	mac.Sum(out[:0])
 268  	return out
 269  }
 270  
 271  // maxInt returns the largest value an int can hold.
 272  func maxInt() int {
 273  	return int(^uint(0) >> 1)
 274  }
 275