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

97 lines
3.4 KiB
Go

package credentialstore
import (
"context"
"errors"
"fmt"
"strings"
)
var (
// ErrEnvelopeKeyUnavailable is returned when the key registry cannot be reached or is unconfigured.
ErrEnvelopeKeyUnavailable = errors.New("credentialstore: envelope key unavailable")
// ErrUnknownEnvelopeKey is returned when the referenced key ID or version is not registered.
ErrUnknownEnvelopeKey = errors.New("credentialstore: unknown envelope key")
// ErrInvalidSecretEnvelope is returned when envelope fields fail validation.
ErrInvalidSecretEnvelope = errors.New("credentialstore: invalid secret envelope")
)
// EnvelopeKeyRegistry checks whether an envelope encryption key is registered.
type EnvelopeKeyRegistry interface {
HasEnvelopeKey(ctx context.Context, keyID string, keyVersion uint64) (bool, error)
}
const (
// algorithmAES256GCM is the only authenticated-encryption algorithm the
// store accepts. It is declared locally so credentialstore never depends on
// the higher-layer credentialseal package.
algorithmAES256GCM = "AES-256-GCM"
// minNonceLength is the minimum AES-GCM nonce length (the 96-bit standard).
// Shorter nonces weaken GCM and are rejected at the persistence boundary.
minNonceLength = 12
)
type unavailableKeyRegistry struct{}
func (unavailableKeyRegistry) HasEnvelopeKey(ctx context.Context, keyID string, keyVersion uint64) (bool, error) {
return false, ErrEnvelopeKeyUnavailable
}
// SecretEnvelope holds opaque encrypted secret payload and key metadata.
type SecretEnvelope struct {
Algorithm string `json:"algorithm"`
KeyID string `json:"key_id"`
KeyVersion uint64 `json:"key_version"`
Nonce []byte `json:"nonce"`
Ciphertext []byte `json:"ciphertext"`
AAD []byte `json:"aad,omitempty"`
}
// Validate checks that the envelope carries a supported authenticated-encryption
// algorithm, a registered-shaped key revision, a full-length nonce, and
// ciphertext. It stays secret-blind: it inspects only opaque metadata and never
// the plaintext. AAD is optional metadata and is not required to be present.
func (e SecretEnvelope) Validate() error {
algorithm := strings.TrimSpace(e.Algorithm)
if algorithm == "" {
return fmt.Errorf("%w: algorithm is required", ErrInvalidSecretEnvelope)
}
if algorithm != algorithmAES256GCM {
return fmt.Errorf("%w: unsupported algorithm", ErrInvalidSecretEnvelope)
}
if strings.TrimSpace(e.KeyID) == "" {
return fmt.Errorf("%w: key_id is required", ErrInvalidSecretEnvelope)
}
if e.KeyVersion == 0 {
return fmt.Errorf("%w: key_version must be greater than 0", ErrInvalidSecretEnvelope)
}
if len(e.Nonce) < minNonceLength {
return fmt.Errorf("%w: nonce is too short", ErrInvalidSecretEnvelope)
}
if len(e.Ciphertext) == 0 {
return fmt.Errorf("%w: ciphertext is required", ErrInvalidSecretEnvelope)
}
return nil
}
// validateEnvelopeKey checks that the envelope is valid and its key ID and version are registered.
func (s *Store) validateEnvelopeKey(ctx context.Context, env SecretEnvelope) error {
if err := env.Validate(); err != nil {
return err
}
registry := s.KeyRegistry()
ok, err := registry.HasEnvelopeKey(ctx, env.KeyID, env.KeyVersion)
if err != nil {
if errors.Is(err, ErrEnvelopeKeyUnavailable) {
return err
}
return fmt.Errorf("%w: %v", ErrEnvelopeKeyUnavailable, err)
}
if !ok {
return fmt.Errorf("%w: key_id=%s version=%d", ErrUnknownEnvelopeKey, env.KeyID, env.KeyVersion)
}
return nil
}