package config import ( "bufio" "bytes" "encoding/base64" "errors" "fmt" "io" "io/fs" "log/slog" "regexp" "strings" "unicode" "unicode/utf8" "tug/internal/platform/userbasekeys" ) const ( redacted = "[redacted]" maxSecretsSize = 1 << 20 maxSecretsLine = 64 * 1024 sessionKeyLength = 32 nameSessionKey = "SESSION_MASTER_KEY_B64" nameGoogleSecret = "GOOGLE_CLIENT_SECRET" nameAgeIdentity = "AGE_IDENTITY" nameUserbaseSign = "USERBASE_SIGNING_KEY_B64" nameUserbaseKey = "USERBASE_DEPLOY_KEY_B64" nameConfigRepoKey = "CONFIG_DEPLOY_KEY_B64" ) var secretNamePattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`) func newSecret(b []byte) Secret { v := string(b) return Secret{p: &v} } func (s Secret) Bytes() []byte { if s.p == nil { return nil } return []byte(*s.p) } func (s Secret) isZero() bool { return s.p == nil || *s.p == "" } func (Secret) String() string { return redacted } func (Secret) GoString() string { return redacted } func (Secret) Format(f fmt.State, _ rune) { _, _ = io.WriteString(f, redacted) } func (Secret) LogValue() slog.Value { return slog.StringValue(redacted) } func (Secret) MarshalText() ([]byte, error) { return []byte(redacted), nil } func (Secret) MarshalJSON() ([]byte, error) { return []byte(`"` + redacted + `"`), nil } type secretLine struct { value string line int } func clientSecretName(id string) string { return "CLIENT_" + strings.ToUpper(id) + "_SECRET" } func isNoncharacter(r rune) bool { return (r >= 0xFDD0 && r <= 0xFDEF) || r&0xFFFE == 0xFFFE } func valueProblem(value string) string { switch { case value == "": return reasonSecretsEmptyValue case value[0] == '"' || value[0] == '\'': return reasonSecretsQuote case strings.ContainsRune(value, '\\'): return reasonSecretsBackslash case strings.TrimFunc(value, func(r rune) bool { return r == ' ' || r == '\t' }) != value: return reasonSecretsWhitespace case !utf8.ValidString(value): return reasonSecretsUTF8 } for _, r := range value { if r != '\t' && unicode.IsControl(r) { return reasonSecretsControl } if isNoncharacter(r) { return reasonSecretsNoncharacter } } return "" } func parseSecrets(file string, data []byte) (map[string]secretLine, []Problem) { if len(data) > maxSecretsSize { return nil, []Problem{{File: file, Reason: reasonSecretsTooLarge}} } if bytes.HasPrefix(data, []byte("\xEF\xBB\xBF")) { return nil, []Problem{{File: file, Line: 1, Reason: reasonSecretsBOM}} } vars := map[string]secretLine{} var problems []Problem r := bufio.NewReader(bytes.NewReader(data)) for n := 1; ; n++ { line, err := r.ReadString('\n') if line == "" && errors.Is(err, io.EOF) { break } line = strings.TrimSuffix(line, "\n") problem := func(key, reason string) { problems = append(problems, Problem{File: file, Line: n, Key: key, Reason: reason}) } switch { case strings.ContainsAny(line, "\r\x00"): problem("", reasonSecretsCRNUL) case len(line) >= maxSecretsLine: problem("", reasonSecretsLineTooLong) default: trimmed := strings.TrimLeft(line, " \t") if trimmed == "" || trimmed[0] == '#' { break } name, value, ok := strings.Cut(line, "=") if !ok || !secretNamePattern.MatchString(name) { problem("", reasonSecretsNotAssignment) break } if reason := valueProblem(value); reason != "" { problem(name, reason) break } if _, dup := vars[name]; dup { problem(name, reasonSecretsDuplicate) break } vars[name] = secretLine{value: value, line: n} } if err != nil { break } } return vars, problems } func secretsModeReason(m fs.FileMode) string { if m.Perm()&0o077 != 0 { return reasonSecretsMode } return "" } func readSecrets(path string) (map[string]secretLine, []Problem) { data, reason := readRegular(path, maxSecretsSize, reasonSecretsTooLarge, secretsModeReason) if reason != "" { return nil, []Problem{{File: path, Reason: reason}} } return parseSecrets(path, data) } func requiredSecrets(cfg *Config) map[string]bool { req := map[string]bool{nameSessionKey: true} for _, c := range cfg.Clients { if !c.Public { req[clientSecretName(c.ID)] = true } } if cfg.Google != nil { req[nameGoogleSecret] = true } if cfg.Userbase != nil { req[nameAgeIdentity] = true req[nameUserbaseSign] = true if cfg.Userbase.SSH() { req[nameUserbaseKey] = true } } if cfg.Source == sourceGit && cfg.ConfigGit != nil && cfg.ConfigGit.SSH() { req[nameConfigRepoKey] = true } return req } func checkSecrets(file string, vars map[string]secretLine, cfg *Config, exact bool) (Secrets, []Problem) { var problems []Problem req := requiredSecrets(cfg) if exact { problems = exactSet(file, vars, req) } decode := func(name string) Secret { v, ok := vars[name] if !ok || !req[name] { return Secret{} } if !strings.HasSuffix(name, "_B64") { return newSecret([]byte(v.value)) } b, err := base64.StdEncoding.Strict().DecodeString(v.value) if err != nil { problems = append(problems, Problem{File: file, Line: v.line, Key: name, Reason: reasonSecretsBase64}) return Secret{} } return newSecret(b) } s := Secrets{ SessionMasterKey: decode(nameSessionKey), GoogleClientSecret: decode(nameGoogleSecret), AgeIdentity: decode(nameAgeIdentity), UserbaseSigningKey: decode(nameUserbaseSign), UserbaseDeployKey: decode(nameUserbaseKey), ConfigDeployKey: decode(nameConfigRepoKey), ClientSecrets: map[string]Secret{}, } if v, ok := vars[nameSessionKey]; ok && !s.SessionMasterKey.isZero() && len(s.SessionMasterKey.Bytes()) != sessionKeyLength { problems = append(problems, Problem{File: file, Line: v.line, Key: nameSessionKey, Reason: reasonSecretsKeyLength}) } for _, c := range cfg.Clients { if _, ok := vars[clientSecretName(c.ID)]; ok && !c.Public { s.ClientSecrets[c.ID] = decode(clientSecretName(c.ID)) } } if cfg.Userbase != nil { problems = append(problems, checkUserbaseSecrets(file, vars, s, cfg.Userbase)...) } problems = append(problems, checkConfigSecrets(file, vars, s)...) return s, problems } func keyFileReason(err error) string { switch { case errors.Is(err, userbasekeys.ErrEncrypted): return reasonSecretsKeyEncrypted case errors.Is(err, userbasekeys.ErrKeyType): return reasonSecretsKeyType default: return reasonSecretsKeyFile } } func checkUserbaseSecrets(file string, vars map[string]secretLine, s Secrets, u *Userbase) []Problem { var problems []Problem problem := func(name, reason string) { problems = append(problems, Problem{File: file, Line: vars[name].line, Key: name, Reason: reason}) } if !s.AgeIdentity.isZero() { id, err := userbasekeys.ParseIdentity(string(s.AgeIdentity.Bytes())) switch { case err != nil: problem(nameAgeIdentity, reasonSecretsAgeIdentity) case id.Recipient().String() == u.BreakGlassRecipient: problem(nameAgeIdentity, reasonSecretsBreakGlass) } } for name, secret := range map[string]Secret{nameUserbaseSign: s.UserbaseSigningKey, nameUserbaseKey: s.UserbaseDeployKey} { if secret.isZero() { continue } if _, err := userbasekeys.ParseSigner(secret.Bytes()); err != nil { problem(name, keyFileReason(err)) } } return problems }