Files
Felis/internal/offsite/crypt.go

196 lines
5.2 KiB
Go

package offsite
import (
"bufio"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"io"
"strings"
)
// An object in the bucket is its file encrypted with AES-256-GCM in fixed
// segments, so a multi-gigabyte world streams through in constant memory:
//
// magic "FELISOS1" | 7-byte random nonce prefix | segment 0 | segment 1 | ...
//
// Segment i seals up to segmentSize bytes under the nonce
// prefix || uint32(i) || last, where last is 1 on the final segment only. The
// counter stops segments being reordered or dropped, and the last flag stops
// the object being cut short at a segment boundary: either change fails to
// authenticate.
const (
magic = "FELISOS1"
prefixSize = 7
segmentSize = 64 << 10
headerSize = len(magic) + prefixSize
// KeySize is the key length: AES-256.
KeySize = 32
)
// ErrAuth is a segment that does not authenticate: the wrong key, or an object
// damaged in the bucket or on the way.
var ErrAuth = errors.New("offsite: object does not decrypt with this key (wrong key, or the object is damaged)")
// NewKey returns a fresh random key in the text form ParseKey reads.
func NewKey() (string, error) {
k := make([]byte, KeySize)
if _, err := rand.Read(k); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(k), nil
}
// ParseKey decodes a key written by NewKey (standard base64 of 32 bytes).
func ParseKey(s string) ([]byte, error) {
k, err := base64.StdEncoding.DecodeString(strings.TrimSpace(s))
if err != nil || len(k) != KeySize {
return nil, fmt.Errorf("offsite: the key must be %d random bytes in base64 (generate one with `felis offsite keygen`)", KeySize)
}
return k, nil
}
// KeyID names a key without revealing it, so status output and logs can say
// which key a bucket's objects were written with.
func KeyID(key []byte) string {
h := sha256.Sum256(append([]byte("felis-offsite-key-id\x00"), key...))
return hex.EncodeToString(h[:8])
}
// SealedSize is the size of the object Encrypt makes from n plaintext bytes.
// Uploads need it up front: an S3 upload of unknown length buffers far more.
func SealedSize(n int64) int64 {
segs := (n + segmentSize - 1) / segmentSize
if segs == 0 {
segs = 1
}
return int64(headerSize) + n + segs*16
}
func newAEAD(key []byte) (cipher.AEAD, error) {
if len(key) != KeySize {
return nil, fmt.Errorf("offsite: key is %d bytes, want %d", len(key), KeySize)
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
func segmentNonce(prefix []byte, i uint32, last bool) []byte {
n := make([]byte, 12)
copy(n, prefix)
binary.BigEndian.PutUint32(n[prefixSize:], i)
if last {
n[11] = 1
}
return n
}
// Encrypt writes src to dst in the segmented format.
func Encrypt(dst io.Writer, src io.Reader, key []byte) error {
aead, err := newAEAD(key)
if err != nil {
return err
}
prefix := make([]byte, prefixSize)
if _, err := rand.Read(prefix); err != nil {
return err
}
if _, err := io.WriteString(dst, magic); err != nil {
return err
}
if _, err := dst.Write(prefix); err != nil {
return err
}
in := bufio.NewReaderSize(src, segmentSize+1)
buf := make([]byte, segmentSize)
out := make([]byte, 0, segmentSize+aead.Overhead())
for i := uint32(0); ; i++ {
n, err := io.ReadFull(in, buf)
switch {
case err == io.EOF || err == io.ErrUnexpectedEOF:
err = nil
case err != nil:
return err
}
last := n < segmentSize
if !last {
if _, perr := in.Peek(1); perr == io.EOF {
last = true
} else if perr != nil {
return perr
}
}
out = aead.Seal(out[:0], segmentNonce(prefix, i, last), buf[:n], []byte(magic))
if _, err := dst.Write(out); err != nil {
return err
}
if last {
return nil
}
if i == ^uint32(0) {
return errors.New("offsite: file too large to encrypt")
}
}
}
// Decrypt reverses Encrypt. Nothing unauthenticated is written: each segment
// is checked before its plaintext reaches dst, and a missing tail is an error.
// A caller writing to a file still has to discard it on error, since earlier
// segments were already written.
func Decrypt(dst io.Writer, src io.Reader, key []byte) error {
aead, err := newAEAD(key)
if err != nil {
return err
}
hdr := make([]byte, headerSize)
if _, err := io.ReadFull(src, hdr); err != nil {
return fmt.Errorf("offsite: object too short for its header: %w", err)
}
if string(hdr[:len(magic)]) != magic {
return errors.New("offsite: not a Felis off-site object (bad magic)")
}
prefix := hdr[len(magic):]
sealed := segmentSize + aead.Overhead()
in := bufio.NewReaderSize(src, sealed+1)
buf := make([]byte, sealed)
var plain []byte
for i := uint32(0); ; i++ {
n, err := io.ReadFull(in, buf)
switch {
case err == io.EOF:
return fmt.Errorf("offsite: object is cut short after %d segments", i)
case err == io.ErrUnexpectedEOF:
err = nil
case err != nil:
return err
}
last := n < sealed
if !last {
if _, perr := in.Peek(1); perr == io.EOF {
last = true
} else if perr != nil {
return perr
}
}
plain, err = aead.Open(plain[:0], segmentNonce(prefix, i, last), buf[:n], []byte(magic))
if err != nil {
return ErrAuth
}
if _, err := dst.Write(plain); err != nil {
return err
}
if last {
return nil
}
}
}