사용자별 credential 저장, lease, projection, runtime 전달과 OpenAI-compatible 계약 및 검증 근거를 함께 반영한다.
367 lines
12 KiB
Go
367 lines
12 KiB
Go
// Package credentialseal implements a versioned AES-256-GCM keyring that seals
|
|
// provider secrets into opaque envelopes and opens them again.
|
|
//
|
|
// It is a lower-layer cryptographic primitive. It depends only on the
|
|
// data-only credentialstore.SecretEnvelope value and never imports the
|
|
// credentialops service package, so no import cycle or layer inversion is
|
|
// introduced: credentialops -> credentialseal -> credentialstore.
|
|
//
|
|
// Key material is supplied out of band by a deployment secret manager that
|
|
// mounts a strict YAML manifest as a `0600` file (optionally through a
|
|
// secret-manager atomic symlink). The manifest, key bytes, and decrypted
|
|
// plaintext are never written to logs, errors, config, or the database.
|
|
package credentialseal
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"iop/apps/control-plane/internal/credentialstore"
|
|
)
|
|
|
|
// AlgorithmAES256GCM is the only authenticated-encryption algorithm this
|
|
// package produces. It is recorded verbatim in every sealed envelope.
|
|
const AlgorithmAES256GCM = "AES-256-GCM"
|
|
|
|
// aeadKeyLength is the decoded key length required for AES-256 (32 bytes).
|
|
const aeadKeyLength = 32
|
|
|
|
// aadDomain is a fixed domain-separation prefix mixed into every AAD so a
|
|
// blob sealed by this package can never be confused with any other AEAD use.
|
|
const aadDomain = "iop.credentialseal.v1"
|
|
|
|
var (
|
|
// ErrSealFailed is returned when sealing cannot complete. It is deliberately
|
|
// constant so no plaintext, key metadata, or context leaks to the caller.
|
|
ErrSealFailed = errors.New("credentialseal: seal failed")
|
|
|
|
// ErrOpenFailed is returned when opening cannot complete, including unknown
|
|
// key revision, wrong context, or any ciphertext/nonce/AAD tamper. It is
|
|
// constant so authentication failures reveal nothing.
|
|
ErrOpenFailed = errors.New("credentialseal: open failed")
|
|
|
|
// ErrConfigIncomplete is returned when the encryption config supplies some
|
|
// but not all of key_file, active_key_id, active_key_version.
|
|
ErrConfigIncomplete = errors.New("credentialseal: incomplete credential encryption config")
|
|
|
|
// ErrKeyFileAccess is returned when the key file cannot be resolved, opened,
|
|
// or read. It never includes the file contents.
|
|
ErrKeyFileAccess = errors.New("credentialseal: key file is not accessible")
|
|
|
|
// ErrKeyFileInsecure is returned when the resolved key file is not a regular
|
|
// file, is not owned by the current user or root, or is group/world
|
|
// accessible.
|
|
ErrKeyFileInsecure = errors.New("credentialseal: key file has insecure ownership or permissions")
|
|
|
|
// ErrManifest is returned when the key manifest is malformed, empty, has a
|
|
// duplicate id/version, or a non-positive version.
|
|
ErrManifest = errors.New("credentialseal: invalid key manifest")
|
|
|
|
// ErrKeyMaterial is returned when a key's material does not decode to exactly
|
|
// 32 bytes. It never includes the decoded bytes.
|
|
ErrKeyMaterial = errors.New("credentialseal: invalid key material")
|
|
|
|
// ErrUnknownActiveKey is returned when the configured active key id/version
|
|
// is absent from the manifest.
|
|
ErrUnknownActiveKey = errors.New("credentialseal: active key not present in manifest")
|
|
)
|
|
|
|
// Context binds ciphertext to its intended resource. It must never carry raw
|
|
// secret material; it holds only the principal, slot, and credential kind.
|
|
type Context struct {
|
|
PrincipalID string
|
|
SlotID string
|
|
Kind string
|
|
}
|
|
|
|
// Sealer seals plaintext into an opaque authenticated envelope bound to a
|
|
// Context. The raw plaintext must never leave the boundary.
|
|
type Sealer interface {
|
|
Seal(ctx context.Context, plaintext []byte, sealCtx Context) (credentialstore.SecretEnvelope, error)
|
|
}
|
|
|
|
// FileConfig selects the external key manifest and the active sealing key. All
|
|
// three fields are omitted to disable at-rest encryption; any present field
|
|
// requires the full triple.
|
|
type FileConfig struct {
|
|
KeyFile string `yaml:"key_file"`
|
|
ActiveKeyID string `yaml:"active_key_id"`
|
|
ActiveKeyVersion uint64 `yaml:"active_key_version"`
|
|
}
|
|
|
|
type keyRef struct {
|
|
id string
|
|
version uint64
|
|
}
|
|
|
|
// Keyring is a versioned AES-256-GCM keyring. It seals with the configured
|
|
// active key and can open any revision it holds. It is safe for concurrent use
|
|
// because every cipher.AEAD is immutable after construction.
|
|
type Keyring struct {
|
|
aeads map[keyRef]cipher.AEAD
|
|
active keyRef
|
|
randomSrc io.Reader
|
|
}
|
|
|
|
var (
|
|
_ Sealer = (*Keyring)(nil)
|
|
_ credentialstore.EnvelopeKeyRegistry = (*Keyring)(nil)
|
|
)
|
|
|
|
// manifest is the strict on-disk key manifest schema.
|
|
type manifest struct {
|
|
Keys []manifestKey `yaml:"keys"`
|
|
}
|
|
|
|
type manifestKey struct {
|
|
ID string `yaml:"id"`
|
|
Version uint64 `yaml:"version"`
|
|
Material string `yaml:"material"`
|
|
}
|
|
|
|
// LoadFile builds a Keyring from an operator-supplied FileConfig.
|
|
//
|
|
// When key_file, active_key_id, and active_key_version are all omitted it
|
|
// returns (nil, nil) so the caller can run with at-rest encryption disabled and
|
|
// every provider-secret mutation fails closed. Any present field requires the
|
|
// complete triple; a partial config returns ErrConfigIncomplete.
|
|
func LoadFile(cfg FileConfig) (*Keyring, error) {
|
|
keyFile := strings.TrimSpace(cfg.KeyFile)
|
|
activeID := strings.TrimSpace(cfg.ActiveKeyID)
|
|
activeVersion := cfg.ActiveKeyVersion
|
|
|
|
if keyFile == "" && activeID == "" && activeVersion == 0 {
|
|
return nil, nil
|
|
}
|
|
if keyFile == "" || activeID == "" || activeVersion == 0 {
|
|
return nil, ErrConfigIncomplete
|
|
}
|
|
|
|
raw, err := readSecureManifestFile(keyFile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer zero(raw)
|
|
|
|
return newKeyring(raw, keyRef{id: activeID, version: activeVersion}, nil)
|
|
}
|
|
|
|
// newKeyring parses a manifest byte slice and assembles the AEAD map. It is
|
|
// separated from LoadFile so tests can supply an alternate random source.
|
|
func newKeyring(rawManifest []byte, active keyRef, randomSrc io.Reader) (*Keyring, error) {
|
|
var m manifest
|
|
dec := yaml.NewDecoder(bytes.NewReader(rawManifest))
|
|
dec.KnownFields(true)
|
|
if err := dec.Decode(&m); err != nil {
|
|
return nil, fmt.Errorf("%w: parse", ErrManifest)
|
|
}
|
|
if len(m.Keys) == 0 {
|
|
return nil, fmt.Errorf("%w: no keys", ErrManifest)
|
|
}
|
|
|
|
aeads := make(map[keyRef]cipher.AEAD, len(m.Keys))
|
|
for _, mk := range m.Keys {
|
|
id := strings.TrimSpace(mk.ID)
|
|
if id == "" {
|
|
return nil, fmt.Errorf("%w: empty key id", ErrManifest)
|
|
}
|
|
if mk.Version == 0 {
|
|
return nil, fmt.Errorf("%w: non-positive key version", ErrManifest)
|
|
}
|
|
ref := keyRef{id: id, version: mk.Version}
|
|
if _, dup := aeads[ref]; dup {
|
|
return nil, fmt.Errorf("%w: duplicate key id/version", ErrManifest)
|
|
}
|
|
aead, err := newAEAD(mk.Material)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
aeads[ref] = aead
|
|
}
|
|
|
|
if _, ok := aeads[active]; !ok {
|
|
return nil, ErrUnknownActiveKey
|
|
}
|
|
|
|
src := randomSrc
|
|
if src == nil {
|
|
src = rand.Reader
|
|
}
|
|
return &Keyring{aeads: aeads, active: active, randomSrc: src}, nil
|
|
}
|
|
|
|
// newAEAD decodes base64 key material, verifies its length, and builds an
|
|
// AES-256-GCM AEAD. The decoded material is zeroed once the cipher has copied
|
|
// it into its own key schedule.
|
|
func newAEAD(material string) (cipher.AEAD, error) {
|
|
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(material))
|
|
if err != nil {
|
|
return nil, ErrKeyMaterial
|
|
}
|
|
defer zero(decoded)
|
|
if len(decoded) != aeadKeyLength {
|
|
return nil, ErrKeyMaterial
|
|
}
|
|
block, err := aes.NewCipher(decoded)
|
|
if err != nil {
|
|
return nil, ErrKeyMaterial
|
|
}
|
|
aead, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return nil, ErrKeyMaterial
|
|
}
|
|
return aead, nil
|
|
}
|
|
|
|
// Seal encrypts plaintext with the active key and returns an opaque envelope.
|
|
// The nonce is freshly random, the AAD is canonically bound to sealCtx, and all
|
|
// returned buffers are fresh copies.
|
|
func (k *Keyring) Seal(_ context.Context, plaintext []byte, sealCtx Context) (credentialstore.SecretEnvelope, error) {
|
|
if k == nil {
|
|
return credentialstore.SecretEnvelope{}, ErrSealFailed
|
|
}
|
|
aead, ok := k.aeads[k.active]
|
|
if !ok {
|
|
return credentialstore.SecretEnvelope{}, ErrSealFailed
|
|
}
|
|
nonce := make([]byte, aead.NonceSize())
|
|
if _, err := io.ReadFull(k.randomSrc, nonce); err != nil {
|
|
return credentialstore.SecretEnvelope{}, ErrSealFailed
|
|
}
|
|
aad := canonicalAAD(sealCtx)
|
|
ciphertext := aead.Seal(nil, nonce, plaintext, aad)
|
|
|
|
return credentialstore.SecretEnvelope{
|
|
Algorithm: AlgorithmAES256GCM,
|
|
KeyID: k.active.id,
|
|
KeyVersion: k.active.version,
|
|
Nonce: nonce,
|
|
Ciphertext: ciphertext,
|
|
AAD: aad,
|
|
}, nil
|
|
}
|
|
|
|
// Open decrypts an envelope for the exact sealCtx it was sealed under. It
|
|
// rejects an unknown key revision, a mismatched context, or any tampered
|
|
// nonce/ciphertext/AAD, always with the constant ErrOpenFailed.
|
|
func (k *Keyring) Open(_ context.Context, env credentialstore.SecretEnvelope, sealCtx Context) ([]byte, error) {
|
|
if k == nil {
|
|
return nil, ErrOpenFailed
|
|
}
|
|
if env.Algorithm != AlgorithmAES256GCM {
|
|
return nil, ErrOpenFailed
|
|
}
|
|
aead, ok := k.aeads[keyRef{id: env.KeyID, version: env.KeyVersion}]
|
|
if !ok {
|
|
return nil, ErrOpenFailed
|
|
}
|
|
if len(env.Nonce) != aead.NonceSize() {
|
|
return nil, ErrOpenFailed
|
|
}
|
|
expectedAAD := canonicalAAD(sealCtx)
|
|
if !bytes.Equal(env.AAD, expectedAAD) {
|
|
return nil, ErrOpenFailed
|
|
}
|
|
plaintext, err := aead.Open(nil, env.Nonce, env.Ciphertext, expectedAAD)
|
|
if err != nil {
|
|
return nil, ErrOpenFailed
|
|
}
|
|
return plaintext, nil
|
|
}
|
|
|
|
// HasEnvelopeKey reports whether the keyring holds the given key revision. It
|
|
// lets a Keyring double as a credentialstore.EnvelopeKeyRegistry.
|
|
func (k *Keyring) HasEnvelopeKey(_ context.Context, keyID string, keyVersion uint64) (bool, error) {
|
|
if k == nil {
|
|
return false, nil
|
|
}
|
|
_, ok := k.aeads[keyRef{id: keyID, version: keyVersion}]
|
|
return ok, nil
|
|
}
|
|
|
|
// canonicalAAD builds a deterministic, length-delimited AAD over the domain
|
|
// prefix and the principal/slot/kind context so no two distinct contexts can
|
|
// ever collide (e.g. "ab"+"c" differs from "a"+"bc").
|
|
func canonicalAAD(c Context) []byte {
|
|
var b bytes.Buffer
|
|
writeField(&b, aadDomain)
|
|
writeField(&b, c.PrincipalID)
|
|
writeField(&b, c.SlotID)
|
|
writeField(&b, c.Kind)
|
|
return b.Bytes()
|
|
}
|
|
|
|
func writeField(b *bytes.Buffer, s string) {
|
|
var lenBuf [8]byte
|
|
binary.BigEndian.PutUint64(lenBuf[:], uint64(len(s)))
|
|
b.Write(lenBuf[:])
|
|
b.WriteString(s)
|
|
}
|
|
|
|
// readSecureManifestFile resolves any secret-manager atomic symlink once, then
|
|
// validates and reads the resolved target through the same file descriptor it
|
|
// stat'd, so the material cannot be swapped underneath the check.
|
|
func readSecureManifestFile(path string) ([]byte, error) {
|
|
resolved, err := filepath.EvalSymlinks(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: resolve", ErrKeyFileAccess)
|
|
}
|
|
f, err := os.Open(resolved)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: open", ErrKeyFileAccess)
|
|
}
|
|
defer f.Close()
|
|
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: stat", ErrKeyFileAccess)
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return nil, fmt.Errorf("%w: not a regular file", ErrKeyFileInsecure)
|
|
}
|
|
if info.Mode().Perm()&0o077 != 0 {
|
|
return nil, fmt.Errorf("%w: group or world accessible", ErrKeyFileInsecure)
|
|
}
|
|
if err := verifyOwner(info); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
data, err := io.ReadAll(f)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: read", ErrKeyFileAccess)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// verifyOwner requires the file to be owned by the current user or root.
|
|
func verifyOwner(info os.FileInfo) error {
|
|
st, ok := info.Sys().(*syscall.Stat_t)
|
|
if !ok {
|
|
return fmt.Errorf("%w: owner unverifiable", ErrKeyFileInsecure)
|
|
}
|
|
if st.Uid != uint32(os.Getuid()) && st.Uid != 0 {
|
|
return fmt.Errorf("%w: unexpected owner", ErrKeyFileInsecure)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// zero best-effort wipes a byte buffer of sensitive material.
|
|
func zero(b []byte) {
|
|
for i := range b {
|
|
b[i] = 0
|
|
}
|
|
}
|