Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
137 lines
4.1 KiB
Go
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)
|
|
}
|