98 lines
3.4 KiB
Go
98 lines
3.4 KiB
Go
// Package vault seals the reusable secrets the system stores in Postgres -
|
|
// DKIM private keys and Cloudflare tokens (PLAN.md section 9). These are
|
|
// encrypted, never hashed: they have to be recoverable to be used at all.
|
|
package vault
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
|
|
"golang.org/x/crypto/hkdf"
|
|
)
|
|
|
|
// CurrentKeyVersion labels secrets sealed with the KEK derived below. Bumping
|
|
// it alongside a new derivation lets old rows stay readable during a rotation
|
|
// (PLAN.md section 9, *_key_version columns).
|
|
const CurrentKeyVersion = 1
|
|
|
|
// hkdfInfo binds the derived key to this purpose, so the same master key used
|
|
// elsewhere cannot produce the same KEK.
|
|
const hkdfInfo = "lambdamail/secret-vault/v1"
|
|
|
|
var (
|
|
// ErrMasterKeyMissing is returned when the process has no master key. It
|
|
// is fatal by design: continuing would mean writing secrets nobody can
|
|
// decrypt later, or silently storing them in the clear.
|
|
ErrMasterKeyMissing = errors.New("vault: LAMBDAMAIL_MASTER_KEY is not set")
|
|
|
|
ErrUnknownKeyVersion = errors.New("vault: secret was sealed with an unknown key version")
|
|
)
|
|
|
|
// SecretVault seals and opens secrets with AES-256-GCM under a key derived
|
|
// from the operator's master key.
|
|
type SecretVault struct {
|
|
aead cipher.AEAD
|
|
}
|
|
|
|
// New derives the key-encryption key from masterKey. The master key is passed
|
|
// through HKDF rather than used directly so that a short or low-entropy
|
|
// operator value still yields a full-length AES-256 key.
|
|
func New(masterKey string) (*SecretVault, error) {
|
|
if masterKey == "" {
|
|
return nil, ErrMasterKeyMissing
|
|
}
|
|
|
|
kek := make([]byte, 32)
|
|
// A fixed salt keeps derivation deterministic across restarts, which is
|
|
// required: a random salt would make every previously sealed secret
|
|
// unreadable after a restart.
|
|
salt := sha256.Sum256([]byte(hkdfInfo))
|
|
if _, err := io.ReadFull(hkdf.New(sha256.New, []byte(masterKey), salt[:], []byte(hkdfInfo)), kek); err != nil {
|
|
return nil, fmt.Errorf("derive kek: %w", err)
|
|
}
|
|
|
|
block, err := aes.NewCipher(kek)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create cipher: %w", err)
|
|
}
|
|
aead, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create gcm: %w", err)
|
|
}
|
|
|
|
return &SecretVault{aead: aead}, nil
|
|
}
|
|
|
|
// Seal encrypts plaintext, returning the ciphertext and the nonce to store
|
|
// beside it. The nonce is stored separately because the schema models it as
|
|
// its own column (PLAN.md section 9).
|
|
func (v *SecretVault) Seal(plaintext []byte) (ciphertext, nonce []byte, err error) {
|
|
nonce = make([]byte, v.aead.NonceSize())
|
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
|
return nil, nil, fmt.Errorf("generate nonce: %w", err)
|
|
}
|
|
return v.aead.Seal(nil, nonce, plaintext, nil), nonce, nil
|
|
}
|
|
|
|
// Open reverses Seal. A wrong master key surfaces here as an authentication
|
|
// failure rather than as silently corrupt plaintext, because GCM is
|
|
// authenticated.
|
|
func (v *SecretVault) Open(ciphertext, nonce []byte, keyVersion int) ([]byte, error) {
|
|
if keyVersion != CurrentKeyVersion {
|
|
return nil, fmt.Errorf("%w: %d", ErrUnknownKeyVersion, keyVersion)
|
|
}
|
|
if len(nonce) != v.aead.NonceSize() {
|
|
return nil, fmt.Errorf("vault: nonce is %d bytes, want %d", len(nonce), v.aead.NonceSize())
|
|
}
|
|
plaintext, err := v.aead.Open(nil, nonce, ciphertext, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("vault: cannot open secret (wrong master key or corrupt data): %w", err)
|
|
}
|
|
return plaintext, nil
|
|
}
|