// Envelope format and key handling for per-app secrets: ECDH-P256 ephemeral // -> HKDF-SHA256 -> AES-256-GCM, base64-encoded for storage in a Manifest // Var's Envelope field. Two wire shapes, told apart by their first bytes: // // v1 (unbound): ephPub(65) || nonce(12) || ciphertext // v2 (bound): "CSB2" || ephPub(65) || nonce(12) || ciphertext // // v2 authenticates the (account, app) it was sealed for as GCM associated // data, so a box opens it only while rendering that app's config — see // Binding. v1 is what every envelope written before v2 existed looks like, // and still opens anywhere; an uncompressed point always starts 0x04, so the // two can never be mistaken for each other. Mirrors the shape the framework's crypto core plans for its own // Seal/Open (docs/superpowers/specs/2026-08-01-carlos-framework-design.md // §6), reimplemented natively here since platform cannot import the // framework repo (platform is the substrate the framework builds on). // // Key sensitivity is the MIRROR IMAGE of CARLOS_RELEASE_KEY's: there, the // private half stays off every host and only the public half is // fleet-wide; here, the private half (CARLOS_SECRETS_KEY) must reach every // host that decrypts, and the public half (CARLOS_SECRETS_PUBKEY) is the // one safe to hold widely — it can only encrypt, never decrypt. package appconfig import ( "crypto/aes" "crypto/cipher" "crypto/ecdh" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/binary" "errors" "fmt" "io" "strings" "sync/atomic" "golang.org/x/crypto/hkdf" "github.com/carlosframework/platform/internal/release" ) // randReader is a var so tests can inject determinism for golden vectors. var randReader io.Reader = rand.Reader // hkdfInfo domain-separates this derivation from any other use of the same // shared secret, the same convention the framework crypto core's Sign // context strings use for its own purposes. const hkdfInfo = "carlos-secrets-v1" // boundPrefix marks a v2 (bound) envelope and boundInfo is its HKDF info. // The info string differs from v1's on purpose: a v2 envelope with its // prefix cut off would otherwise be a well-formed v1 envelope, and opening // it as one would skip the binding. With a different key derivation (and // the associated data missing) that downgrade fails the GCM tag instead. const ( boundPrefix = "CSB2" boundInfo = "carlos-secrets-v2" ) const ( ephLen = 65 // uncompressed P-256 point nonceLen = 12 ) // Binding names the config a secret belongs to: the account sqid and the app // whose bundle it is sealed into (tenant-isolation audit, discussion #220). // One CARLOS_SECRETS_KEY opens every envelope in a deployment, so without // this a v1 envelope copied from one tenant's config into another's would be // decrypted into the second tenant's process environment. The environment // bundle is not part of it: moving a secret between one app's own bundles // crosses no tenant boundary. type Binding struct { Account string App string } // aad is the associated data a v2 envelope authenticates: length-prefixed // fields, so no choice of names can make two bindings encode the same. func (b Binding) aad() ([]byte, error) { if !release.ValidAccount(b.Account) || !release.ValidApp(b.App) { return nil, fmt.Errorf("envelope binding: invalid account/app %q/%q", b.Account, b.App) } var out []byte for _, f := range []string{boundPrefix, b.Account, b.App} { out = binary.BigEndian.AppendUint32(out, uint32(len(f))) out = append(out, f...) } return out, nil } // GenerateKey mints a fresh P-256 ECDH pair: the base64 private key (the // operator's secret — must reach every host's host.env as // CARLOS_SECRETS_KEY) and the base64 public key (CARLOS_SECRETS_PUBKEY, // safe to hold widely). func GenerateKey() (privB64, pubB64 string, err error) { priv, err := ecdh.P256().GenerateKey(randReader) if err != nil { return "", "", err } return base64.StdEncoding.EncodeToString(priv.Bytes()), base64.StdEncoding.EncodeToString(priv.PublicKey().Bytes()), nil } // ParsePrivateKey turns a base64 P-256 scalar — from CARLOS_SECRETS_KEY — // into the decryption key. src only names the origin in the error. func ParsePrivateKey(b64, src string) (*ecdh.PrivateKey, error) { raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64)) if err != nil { return nil, fmt.Errorf("%s: invalid base64: %w", src, err) } k, err := ecdh.P256().NewPrivateKey(raw) if err != nil { return nil, fmt.Errorf("%s: %w", src, err) } return k, nil } // ParsePublicKey turns a base64 P-256 point — from CARLOS_SECRETS_PUBKEY — // into the encryption key. func ParsePublicKey(b64, src string) (*ecdh.PublicKey, error) { raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64)) if err != nil { return nil, fmt.Errorf("%s: invalid base64: %w", src, err) } k, err := ecdh.P256().NewPublicKey(raw) if err != nil { return nil, fmt.Errorf("%s: %w", src, err) } return k, nil } // boundSealing is whether Seal writes v2. Off by default, and that default is // the rollout guard: a box on a binary from before v2 cannot open one, and a // single unopenable secret stalls the app's whole config render on that box. // So reading v2 ships first and writing it is a separate, deliberate step — // the console turns it on from CONSOLE_SECRETS_ENVELOPE_V2 once every box // runs a build that reads v2 (cmd/console). Deployment-wide by nature, which // is why it is process state rather than a parameter threaded through every // door that seals. var boundSealing atomic.Bool // SetBoundSealing switches Seal between v1 (false, the default) and v2. func SetBoundSealing(on bool) { boundSealing.Store(on) } // BoundSealing reports whether Seal writes v2 in this process. The console // advertises it to the CLI (handleAPIConfigGet's secrets_account), so a // client-side seal follows the same switch. func BoundSealing() bool { return boundSealing.Load() } // Seal encrypts plaintext for pub as the config of b: a v2 envelope bound to // b when bound sealing is on (SetBoundSealing), else a v1 envelope. b is // validated either way, so a caller passing the wrong binding fails now and // not on the day the switch flips. func Seal(pub *ecdh.PublicKey, b Binding, plaintext []byte) (string, error) { if _, err := b.aad(); err != nil { return "", err } if !BoundSealing() { return SealUnbound(pub, plaintext) } return SealBound(pub, b, plaintext) } // SealBound writes a v2 envelope bound to b regardless of the switch: only a // render of b's own config opens it. For callers that already know every // reader takes v2 — the CLI once the console has said so. func SealBound(pub *ecdh.PublicKey, b Binding, plaintext []byte) (string, error) { aad, err := b.aad() if err != nil { return "", err } eph, nonce, err := freshEphemeral() if err != nil { return "", err } return sealWith(eph, nonce, pub, boundPrefix, boundInfo, aad, plaintext) } // SealUnbound writes the v1 envelope: no binding, opens under any account's // config. What Seal writes until bound sealing is on, and what the CLI writes // for a console that has not said it takes v2 (an older console's // CheckEnvelope reads the v2 prefix as a bad point and refuses it). func SealUnbound(pub *ecdh.PublicKey, plaintext []byte) (string, error) { eph, nonce, err := freshEphemeral() if err != nil { return "", err } return sealWith(eph, nonce, pub, "", hkdfInfo, nil, plaintext) } func freshEphemeral() (*ecdh.PrivateKey, []byte, error) { eph, err := ecdh.P256().GenerateKey(randReader) if err != nil { return nil, nil, err } nonce := make([]byte, nonceLen) if _, err := io.ReadFull(randReader, nonce); err != nil { return nil, nil, err } return eph, nonce, nil } // sealWith is the sealing core with the ephemeral key and nonce supplied — // the seam the golden-vector tests use for a deterministic envelope. func sealWith(eph *ecdh.PrivateKey, nonce []byte, pub *ecdh.PublicKey, prefix, info string, aad, plaintext []byte) (string, error) { shared, err := eph.ECDH(pub) if err != nil { return "", err } gcm, err := gcmFor(shared, info) if err != nil { return "", err } ciphertext := gcm.Seal(nil, nonce, plaintext, aad) out := make([]byte, 0, len(prefix)+ephLen+nonceLen+len(ciphertext)) out = append(out, prefix...) out = append(out, eph.PublicKey().Bytes()...) out = append(out, nonce...) out = append(out, ciphertext...) return base64.StdEncoding.EncodeToString(out), nil } // splitVersion reports whether raw is a v2 envelope and returns it with the // prefix removed. func splitVersion(raw []byte) (bool, []byte) { if len(raw) >= len(boundPrefix) && string(raw[:len(boundPrefix)]) == boundPrefix { return true, raw[len(boundPrefix):] } return false, raw } // Open decrypts an envelope with priv, for the config named by b. A v2 // envelope opens only if it was sealed for b. A v1 envelope opens regardless // of b: those were written before the binding existed and are most of every // production bucket today. // // That v1 path is the residual the binding cannot close by itself. Anyone // who can write one tenant's config can copy another tenant's v1 envelope // into it and have a box decrypt it there. Stripping a v2 envelope down to // v1 does not get there (see boundInfo), so the exposure is the fixed set of // v1 envelopes already stored plus every one written while bound sealing is // off. Once it is on, each write re-seals as v2. Sunset: once no stored // envelope is v1, refuse v1 here. func Open(priv *ecdh.PrivateKey, b Binding, envelopeB64 string) ([]byte, error) { raw, err := base64.StdEncoding.DecodeString(envelopeB64) if err != nil { return nil, fmt.Errorf("envelope: invalid base64: %w", err) } bound, raw := splitVersion(raw) info, aad := hkdfInfo, []byte(nil) if bound { info = boundInfo if aad, err = b.aad(); err != nil { return nil, err } } if len(raw) < ephLen+nonceLen { return nil, errors.New("envelope: too short") } ephPub, err := ecdh.P256().NewPublicKey(raw[:ephLen]) if err != nil { return nil, fmt.Errorf("envelope: %w", err) } nonce := raw[ephLen : ephLen+nonceLen] ciphertext := raw[ephLen+nonceLen:] shared, err := priv.ECDH(ephPub) if err != nil { return nil, fmt.Errorf("envelope: %w", err) } gcm, err := gcmFor(shared, info) if err != nil { return nil, err } plaintext, err := gcm.Open(nil, nonce, ciphertext, aad) if err != nil { if bound { return nil, fmt.Errorf("envelope: decryption failed (sealed for another app or account, or corrupt): %w", err) } return nil, fmt.Errorf("envelope: decryption failed: %w", err) } return plaintext, nil } // CheckEnvelope structurally validates a sealed envelope WITHOUT any key // material: it base64 decodes, the ephemeral point parses, and there is room // for a nonce and a GCM tag. The console door runs this at SET time for the // same reason applyAt runs ValidVarValue then — a garbage envelope accepted // now would stall the app's WHOLE config at render time, hours later, on // every host, and out of sight of whoever pasted it. // // It cannot prove decryptability: only the box holding CARLOS_SECRETS_KEY // can do that, and by design the console never holds it. What it proves is // the shape Open would not reject outright — the cheap half of Open's own // preamble, minus the ECDH. Errors name the defect and NEVER echo the // envelope: it is someone's ciphertext, and this function's callers put its // error text into HTTP responses and logs. func CheckEnvelope(envelopeB64 string) error { raw, err := base64.StdEncoding.DecodeString(envelopeB64) if err != nil { return errors.New("envelope is not valid base64") } // Both versions carry the same body after v2's prefix, so the checks // below read the body; the binding itself only a box can verify. _, raw = splitVersion(raw) // 16: the GCM tag, which is present even for empty plaintext — so this // is the length of the shortest envelope Seal can produce, not an // arbitrary floor. const minLen = ephLen + nonceLen + 16 if len(raw) < minLen { return fmt.Errorf("envelope too short (%d bytes, want at least %d)", len(raw), minLen) } if _, err := ecdh.P256().NewPublicKey(raw[:ephLen]); err != nil { return errors.New("envelope has no valid ephemeral key") } // LAST, and deliberately after the structural checks: this one is about // the VALUE inside a well-formed envelope, where every check above is // about whether there is a well-formed envelope at all. Run earlier it // would answer "seals an empty value" for a 93-byte run of garbage that // seals nothing — naming a defect the caller does not have, which is // the one thing every refusal here must not do. // // EXACTLY the floor means the plaintext was empty: AES-GCM is // length-preserving, so an envelope carries len(plaintext) bytes beyond // the ephemeral point, the nonce and the tag. That makes emptiness the // one property of a secret's VALUE this side of the seal can read // without a key — and reading it is worth doing, because an empty // secret is almost always a substitution that produced nothing rather // than a value someone meant. Discussion #16: values arriving as // "$(op read ...)" where a mistyped field name returns empty, sealed // and stored and reported as set, surfacing hours later as an OAuth // provider silently not offered. // // Enforced HERE as well as in `carlos secrets set` for the reason the // environment-name check above it is: a door must not depend on its // client's manners. The CLI's check is on the plaintext, before Seal // and before any network; this one is the same rule expressed on the // far side of the seal, and it also covers an older CLI that has not // got the first one. // // A caller who genuinely wants an empty value wants an env var, not a // secret: `carlos env set KEY=` stores one, and it is not sealed // because there is nothing in it to protect. if len(raw) == minLen { return errors.New("envelope seals an empty value. To remove a secret use `carlos secrets unset`. For a deliberately empty value use `carlos env set`") } return nil } // gcmFor derives the AES-256-GCM cipher an ECDH shared secret drives. func gcmFor(shared []byte, info string) (cipher.AEAD, error) { r := hkdf.New(sha256.New, shared, nil, []byte(info)) key := make([]byte, 32) if _, err := io.ReadFull(r, key); err != nil { return nil, err } block, err := aes.NewCipher(key) if err != nil { return nil, err } return cipher.NewGCM(block) }