Files
Felis/internal/offsite/keymark.go
T

165 lines
5.0 KiB
Go

package offsite
import (
"context"
"errors"
"fmt"
"io"
"regexp"
"sort"
"strings"
"time"
)
// keyMark is the one object the bucket holds in the clear: the KeyID of the
// key every other object is sealed with. The id names the key without
// revealing it, so a host holding another key (a reinstall that generated a
// fresh one, an offsite.env from elsewhere) is refused before it writes an
// object or prunes one the right key still opens.
const keyMark = "felis-key-id"
// ErrKeyMismatch is a bucket whose objects are sealed with another key.
var ErrKeyMismatch = errors.New("offsite: the bucket's objects are sealed with another key")
const keyMismatchFix = "set FELIS_OFFSITE_KEY in /etc/felis/offsite.env to the key they were sealed with, or point [offsite] at an empty bucket or prefix (docs/troubleshooting.md §16)"
var keyIDPattern = regexp.MustCompile(`^[0-9a-f]{16}$`)
// KeyFit is how CheckKey matched a key to a bucket. The zero value is no match:
// what CheckKey returns with an error.
type KeyFit int
const (
// KeyRecorded: the bucket records this key's id.
KeyRecorded KeyFit = iota + 1
// KeyOpens: the bucket records no id, and the key opens its newest objects.
KeyOpens
// KeyUnused: the bucket holds no sealed object yet.
KeyUnused
)
// A bucket without a recorded id is judged by its newest sealed objects: the
// key must open the first segment of one of them. keyRefusals objects that
// refuse the key decide a mismatch; at most keyTries are read, so a run of
// damaged objects cannot stall the check.
const (
keyRefusals = 3
keyTries = 10
)
// BucketKeyID reads the key id the bucket records, "" when it records none.
func BucketKeyID(ctx context.Context, b Bucket) (string, error) {
rc, err := b.Get(ctx, keyMark)
if errors.Is(err, ErrNotFound) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("read %s: %w", keyMark, err)
}
defer rc.Close()
raw, err := io.ReadAll(io.LimitReader(rc, 256))
if err != nil {
return "", fmt.Errorf("read %s: %w", keyMark, err)
}
id := strings.TrimSpace(string(raw))
if !keyIDPattern.MatchString(id) {
return "", fmt.Errorf("offsite: %s in the bucket is not a key id Felis wrote", keyMark)
}
return id, nil
}
// errOpened stops Decrypt once the first segment has authenticated.
var errOpened = errors.New("offsite: first segment opened")
type firstSegment struct{}
func (firstSegment) Write([]byte) (int, error) { return 0, errOpened }
// CheckKey tells whether key is the one the bucket's objects are sealed with.
// It writes nothing; a mismatch is ErrKeyMismatch.
func CheckKey(ctx context.Context, b Bucket, key []byte) (KeyFit, error) {
mine := KeyID(key)
id, err := BucketKeyID(ctx, b)
if err != nil {
return 0, err
}
if id != "" {
if id != mine {
return 0, fmt.Errorf("%w: the bucket records key id %s, this key is %s; %s", ErrKeyMismatch, id, mine, keyMismatchFix)
}
return KeyRecorded, nil
}
var sealed []Object
for _, dir := range []string{dbDir, worldsDir, registryDir, uploadsDir} {
objs, err := b.List(ctx, dir)
if err != nil {
return 0, fmt.Errorf("list %s: %w", dir, err)
}
for _, o := range objs {
if strings.HasSuffix(o.Key, objExt) {
sealed = append(sealed, o)
}
}
}
if len(sealed) == 0 {
return KeyUnused, nil
}
sort.Slice(sealed, func(i, j int) bool { return sealed[i].Modified.After(sealed[j].Modified) })
var refused []string
var unjudged error
for i, o := range sealed {
if i == keyTries || len(refused) == keyRefusals {
break
}
rc, err := b.Get(ctx, o.Key)
if errors.Is(err, ErrNotFound) {
continue // pruned since the listing
}
if err != nil {
return 0, fmt.Errorf("%s: %w", o.Key, err)
}
err = Decrypt(firstSegment{}, rc, key)
rc.Close()
switch {
case err == nil || errors.Is(err, errOpened):
return KeyOpens, nil
case errors.Is(err, ErrAuth):
refused = append(refused, o.Key)
case unjudged == nil:
unjudged = fmt.Errorf("%s: %w", o.Key, err)
}
}
if len(refused) > 0 {
return 0, fmt.Errorf("%w: this key (key id %s) opens none of %s; %s", ErrKeyMismatch, mine, strings.Join(refused, ", "), keyMismatchFix)
}
if unjudged != nil {
return 0, fmt.Errorf("offsite: cannot tell which key sealed the bucket's objects: %w", unjudged)
}
return KeyUnused, nil
}
// claim is the check before a run writes anything: CheckKey, then the lease
// (nil checks none), then key's id recorded in a bucket that has none, so
// that every later check reads the id. The key goes first, so a host with the
// wrong key never records itself as the writer, and the lease before the key
// id, so a standby host writes nothing at all.
func claim(ctx context.Context, b Bucket, key []byte, lease *Lease, now time.Time) error {
fit, err := CheckKey(ctx, b, key)
if err != nil {
return err
}
if lease != nil {
if err := lease.Acquire(ctx, b, fit == KeyUnused, now); err != nil {
return err
}
}
if fit == KeyRecorded {
return nil
}
id := KeyID(key) + "\n"
if err := b.Put(ctx, keyMark, strings.NewReader(id), int64(len(id))); err != nil {
return fmt.Errorf("record the key id in %s: %w", keyMark, err)
}
return nil
}