240 lines
7.2 KiB
Go
240 lines
7.2 KiB
Go
// Copyright (c) 2022 Tulir Asokan
|
|
//
|
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
|
|
package attachment
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"errors"
|
|
"hash"
|
|
"io"
|
|
|
|
"maunium.net/go/mautrix/crypto/utils"
|
|
)
|
|
|
|
var (
|
|
HashMismatch = errors.New("mismatching SHA-256 digest")
|
|
UnsupportedVersion = errors.New("unsupported Matrix file encryption version")
|
|
UnsupportedAlgorithm = errors.New("unsupported JWK encryption algorithm")
|
|
InvalidKey = errors.New("failed to decode key")
|
|
InvalidInitVector = errors.New("failed to decode initialization vector")
|
|
InvalidHash = errors.New("failed to decode SHA-256 hash")
|
|
ReaderClosed = errors.New("encrypting reader was already closed")
|
|
)
|
|
|
|
var (
|
|
keyBase64Length = base64.RawURLEncoding.EncodedLen(utils.AESCTRKeyLength)
|
|
ivBase64Length = base64.RawStdEncoding.EncodedLen(utils.AESCTRIVLength)
|
|
hashBase64Length = base64.RawStdEncoding.EncodedLen(utils.SHAHashLength)
|
|
)
|
|
|
|
type JSONWebKey struct {
|
|
Key string `json:"k"`
|
|
Algorithm string `json:"alg"`
|
|
Extractable bool `json:"ext"`
|
|
KeyType string `json:"kty"`
|
|
KeyOps []string `json:"key_ops"`
|
|
}
|
|
|
|
type EncryptedFileHashes struct {
|
|
SHA256 string `json:"sha256"`
|
|
}
|
|
|
|
type decodedKeys struct {
|
|
key [utils.AESCTRKeyLength]byte
|
|
iv [utils.AESCTRIVLength]byte
|
|
|
|
sha256 [utils.SHAHashLength]byte
|
|
}
|
|
|
|
type EncryptedFile struct {
|
|
Key JSONWebKey `json:"key"`
|
|
InitVector string `json:"iv"`
|
|
Hashes EncryptedFileHashes `json:"hashes"`
|
|
Version string `json:"v"`
|
|
|
|
decoded *decodedKeys
|
|
}
|
|
|
|
func NewEncryptedFile() *EncryptedFile {
|
|
key, iv := utils.GenAttachmentA256CTR()
|
|
return &EncryptedFile{
|
|
Key: JSONWebKey{
|
|
Key: base64.RawURLEncoding.EncodeToString(key[:]),
|
|
Algorithm: "A256CTR",
|
|
Extractable: true,
|
|
KeyType: "oct",
|
|
KeyOps: []string{"encrypt", "decrypt"},
|
|
},
|
|
InitVector: base64.RawStdEncoding.EncodeToString(iv[:]),
|
|
Version: "v2",
|
|
|
|
decoded: &decodedKeys{key: key, iv: iv},
|
|
}
|
|
}
|
|
|
|
func (ef *EncryptedFile) decodeKeys(includeHash bool) error {
|
|
if ef.decoded != nil {
|
|
return nil
|
|
} else if len(ef.Key.Key) != keyBase64Length {
|
|
return InvalidKey
|
|
} else if len(ef.InitVector) != ivBase64Length {
|
|
return InvalidInitVector
|
|
} else if includeHash && len(ef.Hashes.SHA256) != hashBase64Length {
|
|
return InvalidHash
|
|
}
|
|
ef.decoded = &decodedKeys{}
|
|
_, err := base64.RawURLEncoding.Decode(ef.decoded.key[:], []byte(ef.Key.Key))
|
|
if err != nil {
|
|
return InvalidKey
|
|
}
|
|
_, err = base64.RawStdEncoding.Decode(ef.decoded.iv[:], []byte(ef.InitVector))
|
|
if err != nil {
|
|
return InvalidInitVector
|
|
}
|
|
if includeHash {
|
|
_, err = base64.RawStdEncoding.Decode(ef.decoded.sha256[:], []byte(ef.Hashes.SHA256))
|
|
if err != nil {
|
|
return InvalidHash
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Encrypt encrypts the given data, updates the SHA256 hash in the EncryptedFile struct and returns the ciphertext.
|
|
//
|
|
// Deprecated: this makes a copy for the ciphertext, which means 2x memory usage. EncryptInPlace is recommended.
|
|
func (ef *EncryptedFile) Encrypt(plaintext []byte) []byte {
|
|
ciphertext := make([]byte, len(plaintext))
|
|
copy(ciphertext, plaintext)
|
|
ef.EncryptInPlace(ciphertext)
|
|
return ciphertext
|
|
}
|
|
|
|
// EncryptInPlace encrypts the given data in-place (i.e. the provided data is overridden with the ciphertext)
|
|
// and updates the SHA256 hash in the EncryptedFile struct.
|
|
func (ef *EncryptedFile) EncryptInPlace(data []byte) {
|
|
ef.decodeKeys(false)
|
|
utils.XorA256CTR(data, ef.decoded.key, ef.decoded.iv)
|
|
checksum := sha256.Sum256(data)
|
|
ef.Hashes.SHA256 = base64.RawStdEncoding.EncodeToString(checksum[:])
|
|
}
|
|
|
|
type encryptingReader struct {
|
|
stream cipher.Stream
|
|
hash hash.Hash
|
|
source io.Reader
|
|
file *EncryptedFile
|
|
closed bool
|
|
|
|
isDecrypting bool
|
|
}
|
|
|
|
func (r *encryptingReader) Read(dst []byte) (n int, err error) {
|
|
if r.closed {
|
|
return 0, ReaderClosed
|
|
} else if r.isDecrypting && r.file.decoded == nil {
|
|
if err = r.file.PrepareForDecryption(); err != nil {
|
|
return
|
|
}
|
|
}
|
|
n, err = r.source.Read(dst)
|
|
r.stream.XORKeyStream(dst[:n], dst[:n])
|
|
r.hash.Write(dst[:n])
|
|
return
|
|
}
|
|
|
|
func (r *encryptingReader) Close() (err error) {
|
|
closer, ok := r.source.(io.ReadCloser)
|
|
if ok {
|
|
err = closer.Close()
|
|
}
|
|
if r.isDecrypting {
|
|
var downloadedChecksum [utils.SHAHashLength]byte
|
|
r.hash.Sum(downloadedChecksum[:])
|
|
if downloadedChecksum != r.file.decoded.sha256 {
|
|
return HashMismatch
|
|
}
|
|
} else {
|
|
r.file.Hashes.SHA256 = base64.RawStdEncoding.EncodeToString(r.hash.Sum(nil))
|
|
}
|
|
r.closed = true
|
|
return
|
|
}
|
|
|
|
// EncryptStream wraps the given io.Reader in order to encrypt the data.
|
|
//
|
|
// The Close() method of the returned io.ReadCloser must be called for the SHA256 hash
|
|
// in the EncryptedFile struct to be updated. The metadata is not valid before the hash
|
|
// is filled.
|
|
func (ef *EncryptedFile) EncryptStream(reader io.Reader) io.ReadCloser {
|
|
ef.decodeKeys(false)
|
|
block, _ := aes.NewCipher(ef.decoded.key[:])
|
|
return &encryptingReader{
|
|
stream: cipher.NewCTR(block, ef.decoded.iv[:]),
|
|
hash: sha256.New(),
|
|
source: reader,
|
|
file: ef,
|
|
}
|
|
}
|
|
|
|
// Decrypt decrypts the given data and returns the plaintext.
|
|
//
|
|
// Deprecated: this makes a copy for the plaintext data, which means 2x memory usage. DecryptInPlace is recommended.
|
|
func (ef *EncryptedFile) Decrypt(ciphertext []byte) ([]byte, error) {
|
|
plaintext := make([]byte, len(ciphertext))
|
|
copy(plaintext, ciphertext)
|
|
return plaintext, ef.DecryptInPlace(plaintext)
|
|
}
|
|
|
|
// PrepareForDecryption checks that the version and algorithm are supported and decodes the base64 keys
|
|
//
|
|
// DecryptStream will call this with the first Read() call if this hasn't been called manually.
|
|
//
|
|
// DecryptInPlace will always call this automatically, so calling this manually is not necessary when using that function.
|
|
func (ef *EncryptedFile) PrepareForDecryption() error {
|
|
if ef.Version != "v2" {
|
|
return UnsupportedVersion
|
|
} else if ef.Key.Algorithm != "A256CTR" {
|
|
return UnsupportedAlgorithm
|
|
} else if err := ef.decodeKeys(true); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DecryptInPlace decrypts the given data in-place (i.e. the provided data is overridden with the plaintext).
|
|
func (ef *EncryptedFile) DecryptInPlace(data []byte) error {
|
|
if err := ef.PrepareForDecryption(); err != nil {
|
|
return err
|
|
} else if ef.decoded.sha256 != sha256.Sum256(data) {
|
|
return HashMismatch
|
|
} else {
|
|
utils.XorA256CTR(data, ef.decoded.key, ef.decoded.iv)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// DecryptStream wraps the given io.Reader in order to decrypt the data.
|
|
//
|
|
// The first Read call will check the algorithm and decode keys, so it might return an error before actually reading anything.
|
|
// If you want to validate the file before opening the stream, call PrepareForDecryption manually and check for errors.
|
|
//
|
|
// The Close call will validate the hash and return an error if it doesn't match.
|
|
// In this case, the written data should be considered compromised and should not be used further.
|
|
func (ef *EncryptedFile) DecryptStream(reader io.Reader) io.ReadCloser {
|
|
block, _ := aes.NewCipher(ef.decoded.key[:])
|
|
return &encryptingReader{
|
|
stream: cipher.NewCTR(block, ef.decoded.iv[:]),
|
|
hash: sha256.New(),
|
|
source: reader,
|
|
file: ef,
|
|
}
|
|
}
|