carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
22package appconfig
23
24import (
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.
44var 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.
49const 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.
56const (
57 boundPrefix = "CSB2"
58 boundInfo = "carlos-secrets-v2"
59)
60
61const (
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.
73type 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.
80func (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).
96func 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.
107func 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.
121func 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.
141var boundSealing atomic.Bool
142
143// SetBoundSealing switches Seal between v1 (false, the default) and v2.
144func 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.
149func 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.
155func 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.
168func 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).
184func 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
192func 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.
206func 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.
226func 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.
245func 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.
298func 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.
351func 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