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}