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