iop/apps/control-plane/internal/credentialseal/aead.go
toki 4c8441e6c9 feat(credential): Provider Credential Slot 라우팅을 구현한다
사용자별 credential 저장, lease, projection, runtime 전달과 OpenAI-compatible 계약 및 검증 근거를 함께 반영한다.
2026-08-02 09:10:11 +09:00

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
}
}