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
}