a73x

internal/server/seal/seal.go

Ref:   Size: 3.5 KiB   History

// Package seal encrypts the key material eitri holds, so that what rests on
// disk is not what signs. One key-encryption key — the server's
// key_encryption_key, which lives in its config and nowhere else — protects
// every piece: the fleet's host CA and gate host key, in the data directory. A
// copied backup or a lifted PVC yields ciphertext and nothing signable.
//
// The package is pure: bytes in, bytes out, no files and no store. Where each
// sealed thing lives is its own package's business.
package seal

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"encoding/base64"
	"errors"
	"fmt"
	"strings"
)

// KEKSize is the key-encryption key's length in bytes: AES-256.
const KEKSize = 32

// Version prefixes every sealed value. It is a version, not decoration: a
// second scheme can be added beside this one and told apart by the value
// itself, and anything unrecognized is refused rather than guessed at. It is
// also what lets a reader tell a sealed file from the plaintext PEM that used
// to sit there — no PEM starts with it.
const Version = "v1:"

// IsSealed reports whether s carries the sealed form. Callers reading a file
// that may predate sealing use it to tell the two apart.
func IsSealed(s string) bool { return strings.HasPrefix(s, Version) }

// Seal encrypts plaintext for storage. The sealed form is
// Version + base64(nonce || AES-256-GCM ciphertext), one fresh nonce per call.
//
// Errors never echo the plaintext or the KEK.
func Seal(kek []byte, plaintext string) (string, error) {
	gcm, err := aead(kek)
	if err != nil {
		return "", err
	}
	nonce := make([]byte, gcm.NonceSize())
	if _, err := rand.Read(nonce); err != nil {
		return "", fmt.Errorf("seal: nonce: %w", err)
	}
	blob := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
	return Version + base64.StdEncoding.EncodeToString(blob), nil
}

// Open decrypts a value written by Seal. Every failure — a form it does not
// recognize, a KEK that is not the one it was sealed with, a byte changed
// anywhere in the value — is the same refusal: the caller gets nothing. GCM's
// authentication is what makes tampering a failure rather than a subtly wrong
// key.
//
// Errors never echo the plaintext, the KEK, or the sealed value.
func Open(kek []byte, blob string) (string, error) {
	body, ok := strings.CutPrefix(blob, Version)
	if !ok {
		return "", errors.New("open: unrecognized sealed format")
	}
	raw, err := base64.StdEncoding.DecodeString(body)
	if err != nil {
		return "", errors.New("open: sealed value is not valid base64")
	}
	gcm, err := aead(kek)
	if err != nil {
		return "", err
	}
	if len(raw) < gcm.NonceSize() {
		return "", errors.New("open: sealed value is truncated")
	}
	plaintext, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil)
	if err != nil {
		return "", errors.New("open: sealed value failed authentication — " +
			"key_encryption_key is not the key this was sealed with, or the stored value was altered")
	}
	return string(plaintext), nil
}

// aead builds the cipher both directions share, and is the one place the KEK's
// length is enforced.
func aead(kek []byte) (cipher.AEAD, error) {
	if len(kek) != KEKSize {
		return nil, fmt.Errorf("key encryption key must be %d bytes, got %d", KEKSize, len(kek))
	}
	block, err := aes.NewCipher(kek)
	if err != nil {
		return nil, errors.New("key encryption key is unusable as an AES key")
	}
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return nil, errors.New("cipher is unusable")
	}
	return gcm, nil
}