package encryption import ( "crypto/hmac" "crypto/rand" "encoding/base64" "encoding/binary" "io" "math" "golang.org/x/crypto/chacha20" "golang.org/x/crypto/hkdf" "lol.mleku.dev/chk" "lol.mleku.dev/errorf" "next.orly.dev/pkg/crypto/p256k" "next.orly.dev/pkg/crypto/sha256" "next.orly.dev/pkg/interfaces/signer" "next.orly.dev/pkg/utils" ) const ( version byte = 2 MinPlaintextSize = 0x0001 // 1b msg => padded to 32b MaxPlaintextSize = 0xffff // 65535 (64kb-1) => padded to 64kb ) type Opts struct { err error nonce []byte } // Deprecated: use WithCustomNonce instead of WithCustomSalt, so the naming is less confusing var WithCustomSalt = WithCustomNonce // WithCustomNonce enables using a custom nonce (salt) instead of using the // system crypto/rand entropy source. func WithCustomNonce(salt []byte) func(opts *Opts) { return func(opts *Opts) { if len(salt) != 32 { opts.err = errorf.E("salt must be 32 bytes, got %d", len(salt)) } opts.nonce = salt } } // Encrypt data using a provided symmetric conversation key using NIP-44 // encryption (chacha20 cipher stream and sha256 HMAC). func Encrypt( plaintext, conversationKey []byte, applyOptions ...func(opts *Opts), ) ( cipherString []byte, err error, ) { var o Opts for _, apply := range applyOptions { apply(&o) } if chk.E(o.err) { err = o.err return } if o.nonce == nil { o.nonce = make([]byte, 32) if _, err = rand.Read(o.nonce); chk.E(err) { return } } var enc, cc20nonce, auth []byte if enc, cc20nonce, auth, err = getKeys( conversationKey, o.nonce, ); chk.E(err) { return } plain := plaintext size := len(plain) if size < MinPlaintextSize || size > MaxPlaintextSize { err = errorf.E("plaintext should be between 1b and 64kB") return } padding := CalcPadding(size) padded := make([]byte, 2+padding) binary.BigEndian.PutUint16(padded, uint16(size)) copy(padded[2:], plain) var cipher []byte if cipher, err = encrypt(enc, cc20nonce, padded); chk.E(err) { return } var mac []byte if mac, err = sha256Hmac(auth, cipher, o.nonce); chk.E(err) { return } ct := make([]byte, 0, 1+32+len(cipher)+32) ct = append(ct, version) ct = append(ct, o.nonce...) ct = append(ct, cipher...) ct = append(ct, mac...) cipherString = make([]byte, base64.StdEncoding.EncodedLen(len(ct))) base64.StdEncoding.Encode(cipherString, ct) return } // Decrypt data that has been encoded using a provided symmetric conversation // key using NIP-44 encryption (chacha20 cipher stream and sha256 HMAC). func Decrypt(b64ciphertextWrapped, conversationKey []byte) ( plaintext []byte, err error, ) { cLen := len(b64ciphertextWrapped) if cLen < 132 || cLen > 87472 { err = errorf.E("invalid payload length: %d", cLen) return } if len(b64ciphertextWrapped) > 0 && b64ciphertextWrapped[0] == '#' { err = errorf.E("unknown version") return } var decoded []byte if decoded, err = base64.StdEncoding.DecodeString(string(b64ciphertextWrapped)); chk.E(err) { return } if decoded[0] != version { err = errorf.E("unknown version %d", decoded[0]) return } dLen := len(decoded) if dLen < 99 || dLen > 65603 { err = errorf.E("invalid data length: %d", dLen) return } nonce, ciphertext, givenMac := decoded[1:33], decoded[33:dLen-32], decoded[dLen-32:] var enc, cc20nonce, auth []byte if enc, cc20nonce, auth, err = getKeys(conversationKey, nonce); chk.E(err) { return } var expectedMac []byte if expectedMac, err = sha256Hmac(auth, ciphertext, nonce); chk.E(err) { return } if !utils.FastEqual(givenMac, expectedMac) { err = errorf.E("invalid hmac") return } var padded []byte if padded, err = encrypt(enc, cc20nonce, ciphertext); chk.E(err) { return } unpaddedLen := binary.BigEndian.Uint16(padded[0:2]) if unpaddedLen < uint16(MinPlaintextSize) || unpaddedLen > uint16(MaxPlaintextSize) || len(padded) != 2+CalcPadding(int(unpaddedLen)) { err = errorf.E("invalid padding") return } unpadded := padded[2:][:unpaddedLen] if len(unpadded) == 0 || len(unpadded) != int(unpaddedLen) { err = errorf.E("invalid padding") return } plaintext = unpadded return } // GenerateConversationKeyFromHex performs an ECDH key generation hashed with the nip-44-v2 using hkdf. func GenerateConversationKeyFromHex(pkh, skh string) (ck []byte, err error) { if skh >= "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141" || skh == "0000000000000000000000000000000000000000000000000000000000000000" { err = errorf.E( "invalid private key: x coordinate %s is not on the secp256k1 curve", skh, ) return } var sign signer.I if sign, err = p256k.NewSecFromHex(skh); chk.E(err) { return } var pk []byte if pk, err = p256k.HexToBin(pkh); chk.E(err) { return } var shared []byte if shared, err = sign.ECDH(pk); chk.E(err) { return } ck = hkdf.Extract(sha256.New, shared, []byte("nip44-v2")) return } func GenerateConversationKeyWithSigner(sign signer.I, pk []byte) ( ck []byte, err error, ) { var shared []byte if shared, err = sign.ECDH(pk); chk.E(err) { return } ck = hkdf.Extract(sha256.New, shared, []byte("nip44-v2")) return } func encrypt(key, nonce, message []byte) (dst []byte, err error) { var cipher *chacha20.Cipher if cipher, err = chacha20.NewUnauthenticatedCipher(key, nonce); chk.E(err) { return } dst = make([]byte, len(message)) cipher.XORKeyStream(dst, message) return } func sha256Hmac(key, ciphertext, nonce []byte) (h []byte, err error) { if len(nonce) != sha256.Size { err = errorf.E("nonce aad must be 32 bytes") return } hm := hmac.New(sha256.New, key) hm.Write(nonce) hm.Write(ciphertext) h = hm.Sum(nil) return } func getKeys(conversationKey, nonce []byte) ( enc, cc20nonce, auth []byte, err error, ) { if len(conversationKey) != 32 { err = errorf.E("conversation key must be 32 bytes") return } if len(nonce) != 32 { err = errorf.E("nonce must be 32 bytes") return } r := hkdf.Expand(sha256.New, conversationKey, nonce) enc = make([]byte, 32) if _, err = io.ReadFull(r, enc); chk.E(err) { return } cc20nonce = make([]byte, 12) if _, err = io.ReadFull(r, cc20nonce); chk.E(err) { return } auth = make([]byte, 32) if _, err = io.ReadFull(r, auth); chk.E(err) { return } return } // CalcPadding creates padding for the message payload that is precisely a power // of two in order to reduce the chances of plaintext attack. This is plainly // retarded because it could blow out the message size a lot when just a random few // dozen bytes and a length prefix would achieve the same result. func CalcPadding(sLen int) (l int) { if sLen <= 32 { return 32 } nextPower := 1 << int(math.Floor(math.Log2(float64(sLen-1)))+1) chunk := int(math.Max(32, float64(nextPower/8))) l = chunk * int(math.Floor(float64((sLen-1)/chunk))+1) return }