| 1 | // Envelope format and key handling for per-app secrets: ECDH-P256 ephemeral |
| 2 | // -> HKDF-SHA256 -> AES-256-GCM, base64-encoded for storage in a Manifest |
| 3 | // Var's Envelope field. Two wire shapes, told apart by their first bytes: |
| 4 | // |
| 5 | // v1 (unbound): ephPub(65) || nonce(12) || ciphertext |
| 6 | // v2 (bound): "CSB2" || ephPub(65) || nonce(12) || ciphertext |
| 7 | // |
| 8 | // v2 authenticates the (account, app) it was sealed for as GCM associated |
| 9 | // data, so a box opens it only while rendering that app's config — see |
| 10 | // Binding. v1 is what every envelope written before v2 existed looks like, |
| 11 | // and still opens anywhere; an uncompressed point always starts 0x04, so the |
| 12 | // two can never be mistaken for each other. Mirrors the shape the framework's crypto core plans for its own |
| 13 | // Seal/Open (docs/superpowers/specs/2026-08-01-carlos-framework-design.md |
| 14 | // §6), reimplemented natively here since platform cannot import the |
| 15 | // framework repo (platform is the substrate the framework builds on). |
| 16 | // |
| 17 | // Key sensitivity is the MIRROR IMAGE of CARLOS_RELEASE_KEY's: there, the |
| 18 | // private half stays off every host and only the public half is |
| 19 | // fleet-wide; here, the private half (CARLOS_SECRETS_KEY) must reach every |
| 20 | // host that decrypts, and the public half (CARLOS_SECRETS_PUBKEY) is the |
| 21 | // one safe to hold widely — it can only encrypt, never decrypt. |
| 22 | package appconfig |
| 23 | |
| 24 | import ( |
| 25 | "crypto/aes" |
| 26 | "crypto/cipher" |
| 27 | "crypto/ecdh" |
| 28 | "crypto/rand" |
| 29 | "crypto/sha256" |
| 30 | "encoding/base64" |
| 31 | "encoding/binary" |
| 32 | "errors" |
| 33 | "fmt" |
| 34 | "io" |
| 35 | "strings" |
| 36 | "sync/atomic" |
| 37 | |
| 38 | "golang.org/x/crypto/hkdf" |
| 39 | |
| 40 | "github.com/carlosframework/platform/internal/release" |
| 41 | ) |
| 42 | |
| 43 | // randReader is a var so tests can inject determinism for golden vectors. |
| 44 | var randReader io.Reader = rand.Reader |
| 45 | |
| 46 | // hkdfInfo domain-separates this derivation from any other use of the same |
| 47 | // shared secret, the same convention the framework crypto core's Sign |
| 48 | // context strings use for its own purposes. |
| 49 | const hkdfInfo = "carlos-secrets-v1" |
| 50 | |
| 51 | // boundPrefix marks a v2 (bound) envelope and boundInfo is its HKDF info. |
| 52 | // The info string differs from v1's on purpose: a v2 envelope with its |
| 53 | // prefix cut off would otherwise be a well-formed v1 envelope, and opening |
| 54 | // it as one would skip the binding. With a different key derivation (and |
| 55 | // the associated data missing) that downgrade fails the GCM tag instead. |
| 56 | const ( |
| 57 | boundPrefix = "CSB2" |
| 58 | boundInfo = "carlos-secrets-v2" |
| 59 | ) |
| 60 | |
| 61 | const ( |
| 62 | ephLen = 65 // uncompressed P-256 point |
| 63 | nonceLen = 12 |
| 64 | ) |
| 65 | |
| 66 | // Binding names the config a secret belongs to: the account sqid and the app |
| 67 | // whose bundle it is sealed into (tenant-isolation audit, discussion #220). |
| 68 | // One CARLOS_SECRETS_KEY opens every envelope in a deployment, so without |
| 69 | // this a v1 envelope copied from one tenant's config into another's would be |
| 70 | // decrypted into the second tenant's process environment. The environment |
| 71 | // bundle is not part of it: moving a secret between one app's own bundles |
| 72 | // crosses no tenant boundary. |
| 73 | type Binding struct { |
| 74 | Account string |
| 75 | App string |
| 76 | } |
| 77 | |
| 78 | // aad is the associated data a v2 envelope authenticates: length-prefixed |
| 79 | // fields, so no choice of names can make two bindings encode the same. |
| 80 | func (b Binding) aad() ([]byte, error) { |
| 81 | if !release.ValidAccount(b.Account) || !release.ValidApp(b.App) { |
| 82 | return nil, fmt.Errorf("envelope binding: invalid account/app %q/%q", b.Account, b.App) |
| 83 | } |
| 84 | var out []byte |
| 85 | for _, f := range []string{boundPrefix, b.Account, b.App} { |
| 86 | out = binary.BigEndian.AppendUint32(out, uint32(len(f))) |
| 87 | out = append(out, f...) |
| 88 | } |
| 89 | return out, nil |
| 90 | } |
| 91 | |
| 92 | // GenerateKey mints a fresh P-256 ECDH pair: the base64 private key (the |
| 93 | // operator's secret — must reach every host's host.env as |
| 94 | // CARLOS_SECRETS_KEY) and the base64 public key (CARLOS_SECRETS_PUBKEY, |
| 95 | // safe to hold widely). |
| 96 | func GenerateKey() (privB64, pubB64 string, err error) { |
| 97 | priv, err := ecdh.P256().GenerateKey(randReader) |
| 98 | if err != nil { |
| 99 | return "", "", err |
| 100 | } |
| 101 | return base64.StdEncoding.EncodeToString(priv.Bytes()), |
| 102 | base64.StdEncoding.EncodeToString(priv.PublicKey().Bytes()), nil |
| 103 | } |
| 104 | |
| 105 | // ParsePrivateKey turns a base64 P-256 scalar — from CARLOS_SECRETS_KEY — |
| 106 | // into the decryption key. src only names the origin in the error. |
| 107 | func ParsePrivateKey(b64, src string) (*ecdh.PrivateKey, error) { |
| 108 | raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64)) |
| 109 | if err != nil { |
| 110 | return nil, fmt.Errorf("%s: invalid base64: %w", src, err) |
| 111 | } |
| 112 | k, err := ecdh.P256().NewPrivateKey(raw) |
| 113 | if err != nil { |
| 114 | return nil, fmt.Errorf("%s: %w", src, err) |
| 115 | } |
| 116 | return k, nil |
| 117 | } |
| 118 | |
| 119 | // ParsePublicKey turns a base64 P-256 point — from CARLOS_SECRETS_PUBKEY — |
| 120 | // into the encryption key. |
| 121 | func ParsePublicKey(b64, src string) (*ecdh.PublicKey, error) { |
| 122 | raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64)) |
| 123 | if err != nil { |
| 124 | return nil, fmt.Errorf("%s: invalid base64: %w", src, err) |
| 125 | } |
| 126 | k, err := ecdh.P256().NewPublicKey(raw) |
| 127 | if err != nil { |
| 128 | return nil, fmt.Errorf("%s: %w", src, err) |
| 129 | } |
| 130 | return k, nil |
| 131 | } |
| 132 | |
| 133 | // boundSealing is whether Seal writes v2. Off by default, and that default is |
| 134 | // the rollout guard: a box on a binary from before v2 cannot open one, and a |
| 135 | // single unopenable secret stalls the app's whole config render on that box. |
| 136 | // So reading v2 ships first and writing it is a separate, deliberate step — |
| 137 | // the console turns it on from CONSOLE_SECRETS_ENVELOPE_V2 once every box |
| 138 | // runs a build that reads v2 (cmd/console). Deployment-wide by nature, which |
| 139 | // is why it is process state rather than a parameter threaded through every |
| 140 | // door that seals. |
| 141 | var boundSealing atomic.Bool |
| 142 | |
| 143 | // SetBoundSealing switches Seal between v1 (false, the default) and v2. |
| 144 | func SetBoundSealing(on bool) { boundSealing.Store(on) } |
| 145 | |
| 146 | // BoundSealing reports whether Seal writes v2 in this process. The console |
| 147 | // advertises it to the CLI (handleAPIConfigGet's secrets_account), so a |
| 148 | // client-side seal follows the same switch. |
| 149 | func BoundSealing() bool { return boundSealing.Load() } |
| 150 | |
| 151 | // Seal encrypts plaintext for pub as the config of b: a v2 envelope bound to |
| 152 | // b when bound sealing is on (SetBoundSealing), else a v1 envelope. b is |
| 153 | // validated either way, so a caller passing the wrong binding fails now and |
| 154 | // not on the day the switch flips. |
| 155 | func Seal(pub *ecdh.PublicKey, b Binding, plaintext []byte) (string, error) { |
| 156 | if _, err := b.aad(); err != nil { |
| 157 | return "", err |
| 158 | } |
| 159 | if !BoundSealing() { |
| 160 | return SealUnbound(pub, plaintext) |
| 161 | } |
| 162 | return SealBound(pub, b, plaintext) |
| 163 | } |
| 164 | |
| 165 | // SealBound writes a v2 envelope bound to b regardless of the switch: only a |
| 166 | // render of b's own config opens it. For callers that already know every |
| 167 | // reader takes v2 — the CLI once the console has said so. |
| 168 | func SealBound(pub *ecdh.PublicKey, b Binding, plaintext []byte) (string, error) { |
| 169 | aad, err := b.aad() |
| 170 | if err != nil { |
| 171 | return "", err |
| 172 | } |
| 173 | eph, nonce, err := freshEphemeral() |
| 174 | if err != nil { |
| 175 | return "", err |
| 176 | } |
| 177 | return sealWith(eph, nonce, pub, boundPrefix, boundInfo, aad, plaintext) |
| 178 | } |
| 179 | |
| 180 | // SealUnbound writes the v1 envelope: no binding, opens under any account's |
| 181 | // config. What Seal writes until bound sealing is on, and what the CLI writes |
| 182 | // for a console that has not said it takes v2 (an older console's |
| 183 | // CheckEnvelope reads the v2 prefix as a bad point and refuses it). |
| 184 | func SealUnbound(pub *ecdh.PublicKey, plaintext []byte) (string, error) { |
| 185 | eph, nonce, err := freshEphemeral() |
| 186 | if err != nil { |
| 187 | return "", err |
| 188 | } |
| 189 | return sealWith(eph, nonce, pub, "", hkdfInfo, nil, plaintext) |
| 190 | } |
| 191 | |
| 192 | func freshEphemeral() (*ecdh.PrivateKey, []byte, error) { |
| 193 | eph, err := ecdh.P256().GenerateKey(randReader) |
| 194 | if err != nil { |
| 195 | return nil, nil, err |
| 196 | } |
| 197 | nonce := make([]byte, nonceLen) |
| 198 | if _, err := io.ReadFull(randReader, nonce); err != nil { |
| 199 | return nil, nil, err |
| 200 | } |
| 201 | return eph, nonce, nil |
| 202 | } |
| 203 | |
| 204 | // sealWith is the sealing core with the ephemeral key and nonce supplied — |
| 205 | // the seam the golden-vector tests use for a deterministic envelope. |
| 206 | func sealWith(eph *ecdh.PrivateKey, nonce []byte, pub *ecdh.PublicKey, prefix, info string, aad, plaintext []byte) (string, error) { |
| 207 | shared, err := eph.ECDH(pub) |
| 208 | if err != nil { |
| 209 | return "", err |
| 210 | } |
| 211 | gcm, err := gcmFor(shared, info) |
| 212 | if err != nil { |
| 213 | return "", err |
| 214 | } |
| 215 | ciphertext := gcm.Seal(nil, nonce, plaintext, aad) |
| 216 | out := make([]byte, 0, len(prefix)+ephLen+nonceLen+len(ciphertext)) |
| 217 | out = append(out, prefix...) |
| 218 | out = append(out, eph.PublicKey().Bytes()...) |
| 219 | out = append(out, nonce...) |
| 220 | out = append(out, ciphertext...) |
| 221 | return base64.StdEncoding.EncodeToString(out), nil |
| 222 | } |
| 223 | |
| 224 | // splitVersion reports whether raw is a v2 envelope and returns it with the |
| 225 | // prefix removed. |
| 226 | func splitVersion(raw []byte) (bool, []byte) { |
| 227 | if len(raw) >= len(boundPrefix) && string(raw[:len(boundPrefix)]) == boundPrefix { |
| 228 | return true, raw[len(boundPrefix):] |
| 229 | } |
| 230 | return false, raw |
| 231 | } |
| 232 | |
| 233 | // Open decrypts an envelope with priv, for the config named by b. A v2 |
| 234 | // envelope opens only if it was sealed for b. A v1 envelope opens regardless |
| 235 | // of b: those were written before the binding existed and are most of every |
| 236 | // production bucket today. |
| 237 | // |
| 238 | // That v1 path is the residual the binding cannot close by itself. Anyone |
| 239 | // who can write one tenant's config can copy another tenant's v1 envelope |
| 240 | // into it and have a box decrypt it there. Stripping a v2 envelope down to |
| 241 | // v1 does not get there (see boundInfo), so the exposure is the fixed set of |
| 242 | // v1 envelopes already stored plus every one written while bound sealing is |
| 243 | // off. Once it is on, each write re-seals as v2. Sunset: once no stored |
| 244 | // envelope is v1, refuse v1 here. |
| 245 | func Open(priv *ecdh.PrivateKey, b Binding, envelopeB64 string) ([]byte, error) { |
| 246 | raw, err := base64.StdEncoding.DecodeString(envelopeB64) |
| 247 | if err != nil { |
| 248 | return nil, fmt.Errorf("envelope: invalid base64: %w", err) |
| 249 | } |
| 250 | bound, raw := splitVersion(raw) |
| 251 | info, aad := hkdfInfo, []byte(nil) |
| 252 | if bound { |
| 253 | info = boundInfo |
| 254 | if aad, err = b.aad(); err != nil { |
| 255 | return nil, err |
| 256 | } |
| 257 | } |
| 258 | if len(raw) < ephLen+nonceLen { |
| 259 | return nil, errors.New("envelope: too short") |
| 260 | } |
| 261 | ephPub, err := ecdh.P256().NewPublicKey(raw[:ephLen]) |
| 262 | if err != nil { |
| 263 | return nil, fmt.Errorf("envelope: %w", err) |
| 264 | } |
| 265 | nonce := raw[ephLen : ephLen+nonceLen] |
| 266 | ciphertext := raw[ephLen+nonceLen:] |
| 267 | shared, err := priv.ECDH(ephPub) |
| 268 | if err != nil { |
| 269 | return nil, fmt.Errorf("envelope: %w", err) |
| 270 | } |
| 271 | gcm, err := gcmFor(shared, info) |
| 272 | if err != nil { |
| 273 | return nil, err |
| 274 | } |
| 275 | plaintext, err := gcm.Open(nil, nonce, ciphertext, aad) |
| 276 | if err != nil { |
| 277 | if bound { |
| 278 | return nil, fmt.Errorf("envelope: decryption failed (sealed for another app or account, or corrupt): %w", err) |
| 279 | } |
| 280 | return nil, fmt.Errorf("envelope: decryption failed: %w", err) |
| 281 | } |
| 282 | return plaintext, nil |
| 283 | } |
| 284 | |
| 285 | // CheckEnvelope structurally validates a sealed envelope WITHOUT any key |
| 286 | // material: it base64 decodes, the ephemeral point parses, and there is room |
| 287 | // for a nonce and a GCM tag. The console door runs this at SET time for the |
| 288 | // same reason applyAt runs ValidVarValue then — a garbage envelope accepted |
| 289 | // now would stall the app's WHOLE config at render time, hours later, on |
| 290 | // every host, and out of sight of whoever pasted it. |
| 291 | // |
| 292 | // It cannot prove decryptability: only the box holding CARLOS_SECRETS_KEY |
| 293 | // can do that, and by design the console never holds it. What it proves is |
| 294 | // the shape Open would not reject outright — the cheap half of Open's own |
| 295 | // preamble, minus the ECDH. Errors name the defect and NEVER echo the |
| 296 | // envelope: it is someone's ciphertext, and this function's callers put its |
| 297 | // error text into HTTP responses and logs. |
| 298 | func CheckEnvelope(envelopeB64 string) error { |
| 299 | raw, err := base64.StdEncoding.DecodeString(envelopeB64) |
| 300 | if err != nil { |
| 301 | return errors.New("envelope is not valid base64") |
| 302 | } |
| 303 | // Both versions carry the same body after v2's prefix, so the checks |
| 304 | // below read the body; the binding itself only a box can verify. |
| 305 | _, raw = splitVersion(raw) |
| 306 | // 16: the GCM tag, which is present even for empty plaintext — so this |
| 307 | // is the length of the shortest envelope Seal can produce, not an |
| 308 | // arbitrary floor. |
| 309 | const minLen = ephLen + nonceLen + 16 |
| 310 | if len(raw) < minLen { |
| 311 | return fmt.Errorf("envelope too short (%d bytes, want at least %d)", len(raw), minLen) |
| 312 | } |
| 313 | if _, err := ecdh.P256().NewPublicKey(raw[:ephLen]); err != nil { |
| 314 | return errors.New("envelope has no valid ephemeral key") |
| 315 | } |
| 316 | // LAST, and deliberately after the structural checks: this one is about |
| 317 | // the VALUE inside a well-formed envelope, where every check above is |
| 318 | // about whether there is a well-formed envelope at all. Run earlier it |
| 319 | // would answer "seals an empty value" for a 93-byte run of garbage that |
| 320 | // seals nothing — naming a defect the caller does not have, which is |
| 321 | // the one thing every refusal here must not do. |
| 322 | // |
| 323 | // EXACTLY the floor means the plaintext was empty: AES-GCM is |
| 324 | // length-preserving, so an envelope carries len(plaintext) bytes beyond |
| 325 | // the ephemeral point, the nonce and the tag. That makes emptiness the |
| 326 | // one property of a secret's VALUE this side of the seal can read |
| 327 | // without a key — and reading it is worth doing, because an empty |
| 328 | // secret is almost always a substitution that produced nothing rather |
| 329 | // than a value someone meant. Discussion #16: values arriving as |
| 330 | // "$(op read ...)" where a mistyped field name returns empty, sealed |
| 331 | // and stored and reported as set, surfacing hours later as an OAuth |
| 332 | // provider silently not offered. |
| 333 | // |
| 334 | // Enforced HERE as well as in `carlos secrets set` for the reason the |
| 335 | // environment-name check above it is: a door must not depend on its |
| 336 | // client's manners. The CLI's check is on the plaintext, before Seal |
| 337 | // and before any network; this one is the same rule expressed on the |
| 338 | // far side of the seal, and it also covers an older CLI that has not |
| 339 | // got the first one. |
| 340 | // |
| 341 | // A caller who genuinely wants an empty value wants an env var, not a |
| 342 | // secret: `carlos env set KEY=` stores one, and it is not sealed |
| 343 | // because there is nothing in it to protect. |
| 344 | if len(raw) == minLen { |
| 345 | 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`") |
| 346 | } |
| 347 | return nil |
| 348 | } |
| 349 | |
| 350 | // gcmFor derives the AES-256-GCM cipher an ECDH shared secret drives. |
| 351 | func gcmFor(shared []byte, info string) (cipher.AEAD, error) { |
| 352 | r := hkdf.New(sha256.New, shared, nil, []byte(info)) |
| 353 | key := make([]byte, 32) |
| 354 | if _, err := io.ReadFull(r, key); err != nil { |
| 355 | return nil, err |
| 356 | } |
| 357 | block, err := aes.NewCipher(key) |
| 358 | if err != nil { |
| 359 | return nil, err |
| 360 | } |
| 361 | return cipher.NewGCM(block) |
| 362 | } |
| 363 | |