Files
llmgw/internal/secrets/seal.go
T

137 lines
4.1 KiB
Go

package secrets
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"strings"
"golang.org/x/crypto/hkdf"
)
// Sealer encrypts the store's own secrets — what engine keys are derived
// from — under a key the store does not hold, so a copy of the store or of
// its backup does not hold them either.
type Sealer interface {
Seal(ctx context.Context, plain []byte) (string, error)
Open(ctx context.Context, sealed string) ([]byte, error)
}
// Sealed values say how they were sealed, so a plain value from before is
// told apart, and read.
const (
localPrefix = "llmgw:local:v1:"
transitPrefix = "llmgw:transit:v1:"
)
// IsSealed says whether a stored value is sealed.
func IsSealed(v string) bool {
return strings.HasPrefix(v, localPrefix) || strings.HasPrefix(v, transitPrefix)
}
// NewSealer is the sealer secrets.master_key names: vault-transit:KEY seals
// with Vault's transit engine, which never lets the key out; anything else
// is a reference to 32 bytes (hex, base64 or raw) used here.
func NewSealer(ctx context.Context, ref string) (Sealer, error) {
if key, ok := strings.CutPrefix(ref, "vault-transit:"); ok {
mount, name := "transit", key
if m, n, ok := strings.Cut(key, "/"); ok {
mount, name = m, n
}
return transit{mount: mount, key: name}, nil
}
raw, err := Read(ctx, ref)
if err != nil {
return nil, fmt.Errorf("secrets.master_key: %w", err)
}
key := decodeKey(raw)
if len(key) < 32 {
return nil, errors.New("secrets.master_key is shorter than 32 bytes: make one with `openssl rand -hex 32`")
}
k := make([]byte, 32)
if _, err := io.ReadFull(hkdf.New(sha256.New, key, nil, []byte("llmgw cluster secrets v1")), k); err != nil {
return nil, err
}
block, err := aes.NewCipher(k)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
return local{gcm: gcm}, nil
}
func decodeKey(b []byte) []byte {
s := strings.TrimSpace(string(b))
if k, err := hex.DecodeString(s); err == nil {
return k
}
if k, err := base64.StdEncoding.DecodeString(s); err == nil {
return k
}
return []byte(s)
}
type local struct{ gcm cipher.AEAD }
func (l local) Seal(_ context.Context, plain []byte) (string, error) {
nonce := make([]byte, l.gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", err
}
return localPrefix + base64.StdEncoding.EncodeToString(l.gcm.Seal(nonce, nonce, plain, nil)), nil
}
func (l local) Open(_ context.Context, sealed string) ([]byte, error) {
b, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(sealed, localPrefix))
if err != nil || !strings.HasPrefix(sealed, localPrefix) || len(b) < l.gcm.NonceSize() {
return nil, errors.New("not a value this key sealed")
}
out, err := l.gcm.Open(nil, b[:l.gcm.NonceSize()], b[l.gcm.NonceSize():], nil)
if err != nil {
return nil, errors.New("the master key does not open this value: it was sealed under another")
}
return out, nil
}
type transit struct{ mount, key string }
func (t transit) Seal(ctx context.Context, plain []byte) (string, error) {
var out struct {
Data struct {
Ciphertext string `json:"ciphertext"`
} `json:"data"`
}
if err := call(ctx, http.MethodPost, "/v1/"+t.mount+"/encrypt/"+t.key,
map[string]string{"plaintext": base64.StdEncoding.EncodeToString(plain)}, &out); err != nil {
return "", fmt.Errorf("sealing with Vault's transit key %s: %w", t.key, err)
}
return transitPrefix + out.Data.Ciphertext, nil
}
func (t transit) Open(ctx context.Context, sealed string) ([]byte, error) {
if !strings.HasPrefix(sealed, transitPrefix) {
return nil, errors.New("not a value Vault's transit engine sealed")
}
var out struct {
Data struct {
Plaintext string `json:"plaintext"`
} `json:"data"`
}
if err := call(ctx, http.MethodPost, "/v1/"+t.mount+"/decrypt/"+t.key,
map[string]string{"ciphertext": strings.TrimPrefix(sealed, transitPrefix)}, &out); err != nil {
return nil, fmt.Errorf("opening with Vault's transit key %s: %w", t.key, err)
}
return base64.StdEncoding.DecodeString(out.Data.Plaintext)
}