From d6e18d39016158fdd4f220ce71a38e6068c03d84 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 17:26:44 -0500 Subject: [PATCH] feat(natsstore): parse NATS connection settings from nats CLI variables Credentials must be supplied the same way as for the other NATS tooling, and secrets must never reach logs or errors, so settings are read from the nats CLI variables into redacted types. Signed-off-by: Yordis Prieto --- go.mod | 1 + go.sum | 2 + internal/store/natsstore/secret.go | 63 ++++ internal/store/natsstore/settings.go | 441 ++++++++++++++++++++++ internal/store/natsstore/settings_test.go | 254 +++++++++++++ 5 files changed, 761 insertions(+) create mode 100644 internal/store/natsstore/secret.go create mode 100644 internal/store/natsstore/settings.go create mode 100644 internal/store/natsstore/settings_test.go diff --git a/go.mod b/go.mod index 3b4b50d7ed6..5330c35b5f2 100644 --- a/go.mod +++ b/go.mod @@ -46,6 +46,7 @@ replace github.com/MoeForge/moego v0.1.0-alpha.1 => github.com/router-for-me/moe require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dlclark/regexp2/v2 v2.5.1 // indirect + github.com/nats-io/nkeys v0.4.17 // indirect github.com/pion/datachannel v1.6.2 // indirect github.com/pion/dtls/v3 v3.1.5 // indirect github.com/pion/logging v0.2.4 // indirect diff --git a/go.sum b/go.sum index 8873b5cdf17..b5d9c6b697c 100644 --- a/go.sum +++ b/go.sum @@ -155,6 +155,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/nats-io/nkeys v0.4.17 h1:JAJrlDhOyQqOt0a1eg4cbIZ1Kc7+vmkCigAltpwQpVA= +github.com/nats-io/nkeys v0.4.17/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= github.com/pelletier/go-toml/v2 v2.4.3-0.20260624044523-35f78d5908f1 h1:gIZQQ2+wbsdQGWLPDSdJFptR25tBCjSC0KbSIQZ1rgE= github.com/pelletier/go-toml/v2 v2.4.3-0.20260624044523-35f78d5908f1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pierrec/xxHash v0.1.5 h1:n/jBpwTHiER4xYvK3/CdPVnLDPchj8eTJFFLUb4QHBo= diff --git a/internal/store/natsstore/secret.go b/internal/store/natsstore/secret.go new file mode 100644 index 00000000000..e7380ef3e36 --- /dev/null +++ b/internal/store/natsstore/secret.go @@ -0,0 +1,63 @@ +// Package natsstore holds the NATS JetStream connection, settings and bucket +// bootstrap that back the NATS token store. +package natsstore + +import ( + "fmt" + "strings" +) + +const redacted = "[redacted]" + +// Secret wraps a credential so it never reaches logs, errors or serialized +// output by accident. Only Reveal returns the value. +type Secret struct { + value string +} + +// NewSecret wraps value, reporting false for an empty one because empty counts as unset. +func NewSecret(value string) (Secret, bool) { + if value == "" { + return Secret{}, false + } + return Secret{value: value}, true +} + +// Reveal returns the wrapped value for the one place that hands it to the NATS client. +func (s Secret) Reveal() string { return s.value } + +func (s Secret) String() string { return redacted } + +func (s Secret) GoString() string { return redacted } + +// Format redacts the secret under every fmt verb, including %v and %+v on a struct holding it. +func (s Secret) Format(f fmt.State, _ rune) { _, _ = f.Write([]byte(redacted)) } + +func (s Secret) MarshalText() ([]byte, error) { return []byte(redacted), nil } + +func (s Secret) MarshalJSON() ([]byte, error) { return []byte(`"` + redacted + `"`), nil } + +// redactServer strips URL userinfo so a server address is safe to log. +func redactServer(server string) string { + rest := server + scheme := "" + if i := strings.Index(rest, "://"); i >= 0 { + scheme, rest = rest[:i+3], rest[i+3:] + } + host := rest + if i := strings.IndexAny(rest, "/?#"); i >= 0 { + host = rest[:i] + } + if at := strings.LastIndex(host, "@"); at >= 0 { + return scheme + redacted + "@" + rest[at+1:] + } + return server +} + +func redactServers(servers []string) []string { + out := make([]string, len(servers)) + for i, server := range servers { + out[i] = redactServer(server) + } + return out +} diff --git a/internal/store/natsstore/settings.go b/internal/store/natsstore/settings.go new file mode 100644 index 00000000000..7a30ab6cc6c --- /dev/null +++ b/internal/store/natsstore/settings.go @@ -0,0 +1,441 @@ +package natsstore + +import ( + "errors" + "fmt" + "io/fs" + "os" + "strings" + + "github.com/nats-io/nkeys" +) + +// Connection environment variables, named and read the way the nats CLI does. +const ( + EnvURL = "NATS_URL" + EnvCreds = "NATS_CREDS" + EnvNKey = "NATS_NKEY" + EnvUser = "NATS_USER" + EnvPassword = "NATS_PASSWORD" + EnvToken = "NATS_TOKEN" + EnvJWT = "NATS_JWT" + EnvSeed = "NATS_SEED" + EnvCert = "NATS_CERT" + EnvKey = "NATS_KEY" + EnvCA = "NATS_CA" + + // DefaultURL is used when NATS_URL is unset. + DefaultURL = "localhost:4222" +) + +// seedPrefixes are the prefixes of operator, account and user NKey seeds. +var seedPrefixes = []string{"SO", "SA", "SU"} + +// Lookup reads one environment variable, reporting whether it is set. +type Lookup func(key string) (string, bool) + +// OSLookup reads the process environment. +func OSLookup(key string) (string, bool) { return os.LookupEnv(key) } + +// SettingError names the setting a problem belongs to without ever carrying a secret value. +type SettingError struct { + Setting string + Err error +} + +func (e *SettingError) Error() string { return fmt.Sprintf("invalid %s: %v", e.Setting, e.Err) } + +func (e *SettingError) Unwrap() error { return e.Err } + +// ErrUnsupportedScheme marks a reference scheme this package does not resolve. +var ErrUnsupportedScheme = errors.New("unsupported scheme") + +func unsupportedScheme(scheme string) error { + return fmt.Errorf("%w `%s`", ErrUnsupportedScheme, scheme) +} + +// ioReason drops the path from a filesystem error, keeping only the cause. +func ioReason(err error) error { + var pathErr *fs.PathError + if errors.As(err, &pathErr) { + return pathErr.Err + } + return err +} + +// splitScheme splits `scheme:rest`, dropping a leading `//` from rest. It returns +// false when the prefix is not a URI scheme, so `C:\x` and bare values stay literal. +func splitScheme(raw string) (scheme, rest string, ok bool) { + end := strings.IndexByte(raw, ':') + if end < 0 { + return "", "", false + } + scheme = raw[:end] + if len(scheme) < 2 || !isAlpha(scheme[0]) { + return "", "", false + } + for i := 1; i < len(scheme); i++ { + c := scheme[i] + if !isAlpha(c) && !(c >= '0' && c <= '9') && c != '+' && c != '-' && c != '.' { + return "", "", false + } + } + rest = raw[end+1:] + return scheme, strings.TrimPrefix(rest, "//"), true +} + +func isAlpha(c byte) bool { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') } + +// Location is a filesystem location written as a bare path or as file://path. +type Location struct { + path string + scheme string +} + +// ParseLocation parses raw, reporting false for an empty value. +func ParseLocation(raw string) (Location, bool) { + if raw == "" { + return Location{}, false + } + scheme, rest, ok := splitScheme(raw) + switch { + case !ok: + return Location{path: raw}, true + case strings.EqualFold(scheme, "file"): + return Location{path: rest}, true + default: + return Location{scheme: strings.ToLower(scheme)}, true + } +} + +// Path returns the filesystem path, or an error when the scheme is not file. +func (l Location) Path() (string, error) { + if l.scheme != "" { + return "", unsupportedScheme(l.scheme) + } + return l.path, nil +} + +// readSeed reads an NKey seed from a plain or decorated seed file (including a +// .creds file), taking the first line with a seed prefix. Errors never echo the +// path, because a mistyped inline seed is read as a path. +func (l Location) readSeed() (string, error) { + path, err := l.Path() + if err != nil { + return "", err + } + contents, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("cannot read the NKey seed file: %w", ioReason(err)) + } + for _, line := range strings.Split(string(contents), "\n") { + line = strings.TrimSpace(line) + if !hasSeedPrefix(line) { + continue + } + if _, err := nkeys.FromSeed([]byte(line)); err != nil { + return "", fmt.Errorf("invalid NKey seed: %w", err) + } + return line, nil + } + return "", errors.New("no NKey seed found in the NKey seed file") +} + +func hasSeedPrefix(line string) bool { + for _, prefix := range seedPrefixes { + if strings.HasPrefix(line, prefix) { + return true + } + } + return false +} + +// NKeySeed is an NKey seed given inline or as a seed file location. A value that +// parses as a seed is used inline; anything else is a bare or file:// path, which +// is how the nats CLI reads NATS_NKEY. +type NKeySeed struct { + inline Secret + file Location + isFile bool +} + +// ParseNKeySeed parses raw, reporting false for an empty value. +func ParseNKeySeed(raw string) (NKeySeed, bool) { + trimmed := strings.TrimSpace(raw) + if _, err := nkeys.FromSeed([]byte(trimmed)); err == nil { + inline, ok := NewSecret(trimmed) + return NKeySeed{inline: inline}, ok + } + location, ok := ParseLocation(trimmed) + return NKeySeed{file: location, isFile: true}, ok +} + +func (s NKeySeed) resolve() (string, error) { + if s.isFile { + return s.file.readSeed() + } + return s.inline.Reveal(), nil +} + +func (s NKeySeed) String() string { return "NKeySeed(" + redacted + ")" } + +func (s NKeySeed) GoString() string { return s.String() } + +func (s NKeySeed) Format(f fmt.State, _ rune) { _, _ = f.Write([]byte(s.String())) } + +type credentialKind int + +const ( + credentialLiteral credentialKind = iota + credentialFile + credentialEnv + credentialUnsupported +) + +// CredentialValue is a configured value that is a literal or a reference to where +// it lives (file://path, env://NAME). +type CredentialValue struct { + literal Secret + file string + env string + scheme string + kind credentialKind +} + +// ParseCredentialValue parses raw, reporting false for an empty value. +func ParseCredentialValue(raw string) (CredentialValue, bool) { + if scheme, rest, ok := splitScheme(raw); ok { + switch { + case strings.EqualFold(scheme, "file"): + return CredentialValue{kind: credentialFile, file: rest}, true + case strings.EqualFold(scheme, "env"): + return CredentialValue{kind: credentialEnv, env: rest}, true + case strings.EqualFold(scheme, "op"), strings.EqualFold(scheme, "nsc"), strings.EqualFold(scheme, "data"): + return CredentialValue{kind: credentialUnsupported, scheme: strings.ToLower(scheme)}, true + } + } + literal, ok := NewSecret(raw) + return CredentialValue{literal: literal}, ok +} + +func (v CredentialValue) resolve(env Lookup) (string, error) { + switch v.kind { + case credentialFile: + contents, err := os.ReadFile(v.file) + if err != nil { + return "", fmt.Errorf("cannot read %s: %w", v.file, ioReason(err)) + } + return strings.TrimRight(string(contents), "\r\n"), nil + case credentialEnv: + value, ok := env(v.env) + if !ok { + return "", fmt.Errorf("environment variable `%s` is not set", v.env) + } + return value, nil + case credentialUnsupported: + return "", unsupportedScheme(v.scheme) + default: + return v.literal.Reveal(), nil + } +} + +func (v CredentialValue) String() string { + switch v.kind { + case credentialFile: + return "file://" + v.file + case credentialEnv: + return "env://" + v.env + case credentialUnsupported: + return v.scheme + "://" + default: + return redacted + } +} + +func (v CredentialValue) GoString() string { return v.String() } + +func (v CredentialValue) Format(f fmt.State, _ rune) { _, _ = f.Write([]byte(v.String())) } + +// Auth is the authentication method, chosen exclusively by Settings.Resolve. +type Auth interface { + // Description names the method for logs, never carrying a value. + Description() string + isAuth() +} + +type ( + // CredentialsAuth authenticates with a user credentials (.creds) file. + CredentialsAuth struct{ File Location } + // NKeyAuth authenticates with an NKey seed. + NKeyAuth struct{ Seed NKeySeed } + // UserPasswordAuth authenticates with a user and password. + UserPasswordAuth struct { + User string + Password CredentialValue + } + // JWTAuth authenticates with a user JWT, signing the nonce with Seed when present. + JWTAuth struct { + JWT CredentialValue + Seed *CredentialValue + } + // TokenAuth authenticates with a token. + TokenAuth struct{ Token CredentialValue } + // NoAuth connects without credentials. + NoAuth struct{} +) + +func (CredentialsAuth) Description() string { return "credentials file" } +func (NKeyAuth) Description() string { return "NKey" } +func (UserPasswordAuth) Description() string { return "user/password" } +func (a JWTAuth) Description() string { + if a.Seed == nil { + return "JWT without signature" + } + return "JWT" +} +func (TokenAuth) Description() string { return "token" } +func (NoAuth) Description() string { return "none" } + +func (CredentialsAuth) isAuth() {} +func (NKeyAuth) isAuth() {} +func (UserPasswordAuth) isAuth() {} +func (JWTAuth) isAuth() {} +func (TokenAuth) isAuth() {} +func (NoAuth) isAuth() {} + +// TLS holds client TLS material. A client certificate needs both its certificate +// and key, and setting either alone is reported instead of ignored. +type TLS struct { + Cert *Location + Key *Location + CA *Location +} + +// Configured reports whether any TLS setting is present. +func (t TLS) Configured() bool { return t.Cert != nil || t.Key != nil || t.CA != nil } + +// clientCertificate returns the cert and key pair, nil when neither is set. +func (t TLS) clientCertificate() (*[2]Location, error) { + switch { + case t.Cert != nil && t.Key != nil: + return &[2]Location{*t.Cert, *t.Key}, nil + case t.Cert != nil: + return nil, &SettingError{Setting: EnvCert, Err: fmt.Errorf("must be set together with %s", EnvKey)} + case t.Key != nil: + return nil, &SettingError{Setting: EnvKey, Err: fmt.Errorf("must be set together with %s", EnvCert)} + } + return nil, nil +} + +// Config is the resolved connection configuration. +type Config struct { + Servers []string + Auth Auth + TLS TLS +} + +// Settings holds the raw, unparsed connection inputs. Resolve is the single place +// that decides which authentication method wins, with this precedence: +// 1. Creds +// 2. NKey +// 3. User + Password +// 4. Token, or User when it is set without a Password +// 5. JWT (signed with Seed when present) +// +// Empty values count as unset. +type Settings struct { + URL string + User string + Password Secret + Token Secret + Creds string + NKey Secret + JWT Secret + Seed Secret + Cert string + Key string + CA string +} + +// SettingsFromEnv reads the connection settings from the environment variables the nats CLI reads. +func SettingsFromEnv(env Lookup) Settings { + read := func(key string) string { + value, _ := env(key) + return value + } + secret := func(key string) Secret { + value, _ := NewSecret(read(key)) + return value + } + return Settings{ + URL: read(EnvURL), + User: read(EnvUser), + Password: secret(EnvPassword), + Token: secret(EnvToken), + Creds: read(EnvCreds), + NKey: secret(EnvNKey), + JWT: secret(EnvJWT), + Seed: secret(EnvSeed), + Cert: read(EnvCert), + Key: read(EnvKey), + CA: read(EnvCA), + } +} + +// Resolve applies the precedence and returns the connection configuration. +func (s Settings) Resolve() Config { + return Config{Servers: s.servers(), Auth: s.auth(), TLS: s.tls()} +} + +func (s Settings) auth() Auth { + if location, ok := ParseLocation(s.Creds); ok { + return CredentialsAuth{File: location} + } + if seed, ok := ParseNKeySeed(s.NKey.Reveal()); ok { + return NKeyAuth{Seed: seed} + } + if s.User != "" && s.Password.Reveal() != "" { + if password, ok := ParseCredentialValue(s.Password.Reveal()); ok { + return UserPasswordAuth{User: s.User, Password: password} + } + } + token := s.Token.Reveal() + if token == "" && s.Password.Reveal() == "" { + token = s.User + } + if value, ok := ParseCredentialValue(token); ok { + return TokenAuth{Token: value} + } + if jwt, ok := ParseCredentialValue(s.JWT.Reveal()); ok { + auth := JWTAuth{JWT: jwt} + if seed, ok := ParseCredentialValue(s.Seed.Reveal()); ok { + auth.Seed = &seed + } + return auth + } + return NoAuth{} +} + +func (s Settings) tls() TLS { + location := func(raw string) *Location { + if l, ok := ParseLocation(raw); ok { + return &l + } + return nil + } + return TLS{Cert: location(s.Cert), Key: location(s.Key), CA: location(s.CA)} +} + +func (s Settings) servers() []string { + url := s.URL + if url == "" { + url = DefaultURL + } + var servers []string + for _, server := range strings.Split(url, ",") { + if server = strings.TrimSpace(server); server != "" { + servers = append(servers, server) + } + } + return servers +} diff --git a/internal/store/natsstore/settings_test.go b/internal/store/natsstore/settings_test.go new file mode 100644 index 00000000000..22c77b4d73e --- /dev/null +++ b/internal/store/natsstore/settings_test.go @@ -0,0 +1,254 @@ +package natsstore + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/nats-io/nkeys" +) + +func mapEnv(values map[string]string) Lookup { + return func(key string) (string, bool) { + value, ok := values[key] + return value, ok + } +} + +func newSeed(t *testing.T) string { + t.Helper() + pair, err := nkeys.CreateUser() + if err != nil { + t.Fatal(err) + } + seed, err := pair.Seed() + if err != nil { + t.Fatal(err) + } + return string(seed) +} + +func TestSettingsServers(t *testing.T) { + tests := []struct { + name string + url string + want []string + }{ + {"default", "", []string{DefaultURL}}, + {"single", "nats://a:4222", []string{"nats://a:4222"}}, + {"list trimmed", " nats://a:4222 , nats://b:4222,, ", []string{"nats://a:4222", "nats://b:4222"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SettingsFromEnv(mapEnv(map[string]string{EnvURL: tt.url})).Resolve().Servers + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("servers = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSettingsAuthPrecedence(t *testing.T) { + seed := newSeed(t) + all := map[string]string{ + EnvCreds: "/run/creds/user.creds", + EnvNKey: seed, + EnvUser: "alice", + EnvPassword: "pw", + EnvToken: "tok", + EnvJWT: "jwt", + EnvSeed: seed, + } + steps := []struct { + drop string + want string + }{ + {"", "credentials file"}, + {EnvCreds, "NKey"}, + {EnvNKey, "user/password"}, + {EnvPassword, "token"}, + {EnvToken, "token"}, // user without a password is used as a token + {EnvUser, "JWT"}, + {EnvSeed, "JWT without signature"}, + {EnvJWT, "none"}, + } + env := map[string]string{} + for k, v := range all { + env[k] = v + } + for _, step := range steps { + delete(env, step.drop) + got := SettingsFromEnv(mapEnv(env)).Resolve().Auth.Description() + if got != step.want { + t.Fatalf("after dropping %q: auth = %q, want %q", step.drop, got, step.want) + } + } +} + +func TestSettingsEmptyValuesCountAsUnset(t *testing.T) { + env := map[string]string{EnvCreds: "", EnvNKey: "", EnvUser: "", EnvPassword: "", EnvToken: "", EnvJWT: ""} + cfg := SettingsFromEnv(mapEnv(env)).Resolve() + if _, ok := cfg.Auth.(NoAuth); !ok { + t.Fatalf("auth = %T, want NoAuth", cfg.Auth) + } + if cfg.TLS.Configured() { + t.Fatal("TLS configured from empty values") + } +} + +func TestSettingsNKeyAcceptsInlineSeedOrFile(t *testing.T) { + seed := newSeed(t) + path := filepath.Join(t.TempDir(), "user.creds") + creds := "-----BEGIN NATS USER JWT-----\neyJ.fake.jwt\n------END NATS USER JWT------\n\n-----BEGIN USER NKEY SEED-----\n" + seed + "\n------END USER NKEY SEED------\n" + if err := os.WriteFile(path, []byte(creds), 0o600); err != nil { + t.Fatal(err) + } + for name, raw := range map[string]string{"inline": seed, "path": path, "file url": "file://" + path} { + t.Run(name, func(t *testing.T) { + parsed, ok := ParseNKeySeed(raw) + if !ok { + t.Fatal("not parsed") + } + got, err := parsed.resolve() + if err != nil { + t.Fatal(err) + } + if got != seed { + t.Fatal("resolved seed differs") + } + }) + } +} + +func TestNKeySeedFileErrorsDoNotEchoThePath(t *testing.T) { + const mistyped = "SUAMISTYPEDINLINESEEDTHATISNOTVALID" + parsed, _ := ParseNKeySeed(mistyped) + _, err := parsed.resolve() + if err == nil { + t.Fatal("expected an error") + } + if strings.Contains(err.Error(), mistyped) { + t.Fatalf("error echoes the value: %v", err) + } +} + +func TestCredentialValueReferences(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "token") + if err := os.WriteFile(path, []byte("from-file\n"), 0o600); err != nil { + t.Fatal(err) + } + env := mapEnv(map[string]string{"TOKEN_VAR": "from-env"}) + tests := []struct { + raw string + want string + wantErr error + }{ + {raw: "literal", want: "literal"}, + {raw: "file://" + path, want: "from-file"}, + {raw: "env://TOKEN_VAR", want: "from-env"}, + {raw: "op://vault/item", wantErr: ErrUnsupportedScheme}, + {raw: "nsc://op/acct/user", wantErr: ErrUnsupportedScheme}, + {raw: "data:abc", wantErr: ErrUnsupportedScheme}, + } + for _, tt := range tests { + value, ok := ParseCredentialValue(tt.raw) + if !ok { + t.Fatalf("%q not parsed", tt.raw) + } + got, err := value.resolve(env) + if tt.wantErr != nil { + if !errors.Is(err, tt.wantErr) { + t.Fatalf("%q: err = %v, want %v", tt.raw, err, tt.wantErr) + } + continue + } + if err != nil || got != tt.want { + t.Fatalf("%q: got %q, %v; want %q", tt.raw, got, err, tt.want) + } + } + if _, err := mustCredential(t, "env://UNSET_VAR").resolve(env); err == nil { + t.Fatal("unset environment reference resolved") + } + if _, ok := ParseCredentialValue(""); ok { + t.Fatal("empty value parsed") + } +} + +func mustCredential(t *testing.T, raw string) CredentialValue { + t.Helper() + value, ok := ParseCredentialValue(raw) + if !ok { + t.Fatalf("%q not parsed", raw) + } + return value +} + +func TestSecretsAreRedactedEverywhere(t *testing.T) { + const ( + password = "hunter2-password-value" + token = "tok-secret-value" + jwt = "jwt-secret-value" + ) + seed := newSeed(t) + settings := SettingsFromEnv(mapEnv(map[string]string{ + EnvURL: "nats://svc:urlpass@host:4222", + EnvUser: "alice", + EnvPassword: password, + EnvToken: token, + EnvJWT: jwt, + EnvNKey: seed, + EnvSeed: seed, + })) + cfg := settings.Resolve() + auths := []Auth{ + cfg.Auth, + UserPasswordAuth{User: "alice", Password: mustCredential(t, password)}, + TokenAuth{Token: mustCredential(t, token)}, + JWTAuth{JWT: mustCredential(t, jwt), Seed: ptr(mustCredential(t, seed))}, + NKeyAuth{Seed: func() NKeySeed { s, _ := ParseNKeySeed(seed); return s }()}, + } + secrets := []string{password, token, jwt, seed} + for _, subject := range []any{settings, cfg, auths} { + for _, format := range []string{"%v", "%+v", "%#v", "%s"} { + out := fmt.Sprintf(format, subject) + for _, secret := range secrets { + if strings.Contains(out, secret) { + t.Fatalf("%s of %T leaks a secret", format, subject) + } + } + } + } + encoded, err := json.Marshal(settings) + if err != nil { + t.Fatal(err) + } + for _, secret := range secrets { + if strings.Contains(string(encoded), secret) { + t.Fatal("JSON of settings leaks a secret") + } + } +} + +func ptr[T any](v T) *T { return &v } + +func TestRedactServer(t *testing.T) { + tests := map[string]string{ + "nats://host:4222": "nats://host:4222", + "host:4222": "host:4222", + "nats://user:pass@host:4222": "nats://[redacted]@host:4222", + "nats://tok@host:4222": "nats://[redacted]@host:4222", + "user:pass@host:4222": "[redacted]@host:4222", + "tls://u:p@host:4222/path?x=y": "tls://[redacted]@host:4222/path?x=y", + } + for in, want := range tests { + if got := redactServer(in); got != want { + t.Errorf("redactServer(%q) = %q, want %q", in, got, want) + } + } +}