Raw
  1package config
  2
  3import (
  4	"bufio"
  5	"bytes"
  6	"encoding/base64"
  7	"errors"
  8	"fmt"
  9	"io"
 10	"io/fs"
 11	"log/slog"
 12	"regexp"
 13	"strings"
 14	"unicode"
 15	"unicode/utf8"
 16
 17	"tug/internal/platform/userbasekeys"
 18)
 19
 20const (
 21	redacted          = "[redacted]"
 22	maxSecretsSize    = 1 << 20
 23	maxSecretsLine    = 64 * 1024
 24	sessionKeyLength  = 32
 25	nameSessionKey    = "SESSION_MASTER_KEY_B64"
 26	nameGoogleSecret  = "GOOGLE_CLIENT_SECRET"
 27	nameAgeIdentity   = "AGE_IDENTITY"
 28	nameUserbaseSign  = "USERBASE_SIGNING_KEY_B64"
 29	nameUserbaseKey   = "USERBASE_DEPLOY_KEY_B64"
 30	nameConfigRepoKey = "CONFIG_DEPLOY_KEY_B64"
 31)
 32
 33var secretNamePattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`)
 34
 35func newSecret(b []byte) Secret {
 36	v := string(b)
 37	return Secret{p: &v}
 38}
 39
 40func (s Secret) Bytes() []byte {
 41	if s.p == nil {
 42		return nil
 43	}
 44	return []byte(*s.p)
 45}
 46
 47func (s Secret) isZero() bool {
 48	return s.p == nil || *s.p == ""
 49}
 50
 51func (Secret) String() string {
 52	return redacted
 53}
 54
 55func (Secret) GoString() string {
 56	return redacted
 57}
 58
 59func (Secret) Format(f fmt.State, _ rune) {
 60	_, _ = io.WriteString(f, redacted)
 61}
 62
 63func (Secret) LogValue() slog.Value {
 64	return slog.StringValue(redacted)
 65}
 66
 67func (Secret) MarshalText() ([]byte, error) {
 68	return []byte(redacted), nil
 69}
 70
 71func (Secret) MarshalJSON() ([]byte, error) {
 72	return []byte(`"` + redacted + `"`), nil
 73}
 74
 75type secretLine struct {
 76	value string
 77	line  int
 78}
 79
 80func clientSecretName(id string) string {
 81	return "CLIENT_" + strings.ToUpper(id) + "_SECRET"
 82}
 83
 84func isNoncharacter(r rune) bool {
 85	return (r >= 0xFDD0 && r <= 0xFDEF) || r&0xFFFE == 0xFFFE
 86}
 87
 88func valueProblem(value string) string {
 89	switch {
 90	case value == "":
 91		return reasonSecretsEmptyValue
 92	case value[0] == '"' || value[0] == '\'':
 93		return reasonSecretsQuote
 94	case strings.ContainsRune(value, '\\'):
 95		return reasonSecretsBackslash
 96	case strings.TrimFunc(value, func(r rune) bool { return r == ' ' || r == '\t' }) != value:
 97		return reasonSecretsWhitespace
 98	case !utf8.ValidString(value):
 99		return reasonSecretsUTF8
100	}
101	for _, r := range value {
102		if r != '\t' && unicode.IsControl(r) {
103			return reasonSecretsControl
104		}
105		if isNoncharacter(r) {
106			return reasonSecretsNoncharacter
107		}
108	}
109	return ""
110}
111
112func parseSecrets(file string, data []byte) (map[string]secretLine, []Problem) {
113	if len(data) > maxSecretsSize {
114		return nil, []Problem{{File: file, Reason: reasonSecretsTooLarge}}
115	}
116	if bytes.HasPrefix(data, []byte("\xEF\xBB\xBF")) {
117		return nil, []Problem{{File: file, Line: 1, Reason: reasonSecretsBOM}}
118	}
119	vars := map[string]secretLine{}
120	var problems []Problem
121	r := bufio.NewReader(bytes.NewReader(data))
122	for n := 1; ; n++ {
123		line, err := r.ReadString('\n')
124		if line == "" && errors.Is(err, io.EOF) {
125			break
126		}
127		line = strings.TrimSuffix(line, "\n")
128		problem := func(key, reason string) {
129			problems = append(problems, Problem{File: file, Line: n, Key: key, Reason: reason})
130		}
131		switch {
132		case strings.ContainsAny(line, "\r\x00"):
133			problem("", reasonSecretsCRNUL)
134		case len(line) >= maxSecretsLine:
135			problem("", reasonSecretsLineTooLong)
136		default:
137			trimmed := strings.TrimLeft(line, " \t")
138			if trimmed == "" || trimmed[0] == '#' {
139				break
140			}
141			name, value, ok := strings.Cut(line, "=")
142			if !ok || !secretNamePattern.MatchString(name) {
143				problem("", reasonSecretsNotAssignment)
144				break
145			}
146			if reason := valueProblem(value); reason != "" {
147				problem(name, reason)
148				break
149			}
150			if _, dup := vars[name]; dup {
151				problem(name, reasonSecretsDuplicate)
152				break
153			}
154			vars[name] = secretLine{value: value, line: n}
155		}
156		if err != nil {
157			break
158		}
159	}
160	return vars, problems
161}
162
163func secretsModeReason(m fs.FileMode) string {
164	if m.Perm()&0o077 != 0 {
165		return reasonSecretsMode
166	}
167	return ""
168}
169
170func readSecrets(path string) (map[string]secretLine, []Problem) {
171	data, reason := readRegular(path, maxSecretsSize, reasonSecretsTooLarge, secretsModeReason)
172	if reason != "" {
173		return nil, []Problem{{File: path, Reason: reason}}
174	}
175	return parseSecrets(path, data)
176}
177
178func requiredSecrets(cfg *Config) map[string]bool {
179	req := map[string]bool{nameSessionKey: true}
180	for _, c := range cfg.Clients {
181		if !c.Public {
182			req[clientSecretName(c.ID)] = true
183		}
184	}
185	if cfg.Google != nil {
186		req[nameGoogleSecret] = true
187	}
188	if cfg.Userbase != nil {
189		req[nameAgeIdentity] = true
190		req[nameUserbaseSign] = true
191		if cfg.Userbase.SSH() {
192			req[nameUserbaseKey] = true
193		}
194	}
195	if cfg.Source == sourceGit && cfg.ConfigGit != nil && cfg.ConfigGit.SSH() {
196		req[nameConfigRepoKey] = true
197	}
198	return req
199}
200
201func checkSecrets(file string, vars map[string]secretLine, cfg *Config, exact bool) (Secrets, []Problem) {
202	var problems []Problem
203	req := requiredSecrets(cfg)
204	if exact {
205		problems = exactSet(file, vars, req)
206	}
207	decode := func(name string) Secret {
208		v, ok := vars[name]
209		if !ok || !req[name] {
210			return Secret{}
211		}
212		if !strings.HasSuffix(name, "_B64") {
213			return newSecret([]byte(v.value))
214		}
215		b, err := base64.StdEncoding.Strict().DecodeString(v.value)
216		if err != nil {
217			problems = append(problems, Problem{File: file, Line: v.line, Key: name, Reason: reasonSecretsBase64})
218			return Secret{}
219		}
220		return newSecret(b)
221	}
222	s := Secrets{
223		SessionMasterKey:   decode(nameSessionKey),
224		GoogleClientSecret: decode(nameGoogleSecret),
225		AgeIdentity:        decode(nameAgeIdentity),
226		UserbaseSigningKey: decode(nameUserbaseSign),
227		UserbaseDeployKey:  decode(nameUserbaseKey),
228		ConfigDeployKey:    decode(nameConfigRepoKey),
229		ClientSecrets:      map[string]Secret{},
230	}
231	if v, ok := vars[nameSessionKey]; ok && !s.SessionMasterKey.isZero() && len(s.SessionMasterKey.Bytes()) != sessionKeyLength {
232		problems = append(problems, Problem{File: file, Line: v.line, Key: nameSessionKey, Reason: reasonSecretsKeyLength})
233	}
234	for _, c := range cfg.Clients {
235		if _, ok := vars[clientSecretName(c.ID)]; ok && !c.Public {
236			s.ClientSecrets[c.ID] = decode(clientSecretName(c.ID))
237		}
238	}
239	if cfg.Userbase != nil {
240		problems = append(problems, checkUserbaseSecrets(file, vars, s, cfg.Userbase)...)
241	}
242	problems = append(problems, checkConfigSecrets(file, vars, s)...)
243	return s, problems
244}
245
246func keyFileReason(err error) string {
247	switch {
248	case errors.Is(err, userbasekeys.ErrEncrypted):
249		return reasonSecretsKeyEncrypted
250	case errors.Is(err, userbasekeys.ErrKeyType):
251		return reasonSecretsKeyType
252	default:
253		return reasonSecretsKeyFile
254	}
255}
256
257func checkUserbaseSecrets(file string, vars map[string]secretLine, s Secrets, u *Userbase) []Problem {
258	var problems []Problem
259	problem := func(name, reason string) {
260		problems = append(problems, Problem{File: file, Line: vars[name].line, Key: name, Reason: reason})
261	}
262	if !s.AgeIdentity.isZero() {
263		id, err := userbasekeys.ParseIdentity(string(s.AgeIdentity.Bytes()))
264		switch {
265		case err != nil:
266			problem(nameAgeIdentity, reasonSecretsAgeIdentity)
267		case id.Recipient().String() == u.BreakGlassRecipient:
268			problem(nameAgeIdentity, reasonSecretsBreakGlass)
269		}
270	}
271	for name, secret := range map[string]Secret{nameUserbaseSign: s.UserbaseSigningKey, nameUserbaseKey: s.UserbaseDeployKey} {
272		if secret.isZero() {
273			continue
274		}
275		if _, err := userbasekeys.ParseSigner(secret.Bytes()); err != nil {
276			problem(name, keyFileReason(err))
277		}
278	}
279	return problems
280}