From 7529656f53972997c1abf684a23d5f56fc3c4038 Mon Sep 17 00:00:00 2001 From: kaldun-tech Date: Tue, 28 Apr 2026 13:54:46 -0600 Subject: [PATCH] loopdb: add Secret type for reading password from file Introduce a Secret type that implements go-flags Unmarshaler to support reading sensitive values from files using @/path/to/file syntax. This allows the postgres password to be stored in a file rather than passed directly on the command line, avoiding exposure in process listings and shell history. Trailing whitespace (spaces, tabs, newlines) is automatically stripped from file contents. Fixes #1088 Signed-off-by: kaldun-tech --- loopdb/postgres.go | 4 +- loopdb/postgres_fixture.go | 2 +- loopdb/secret.go | 44 +++++++++ loopdb/secret_test.go | 197 +++++++++++++++++++++++++++++++++++++ release_notes.md | 5 + 5 files changed, 249 insertions(+), 3 deletions(-) create mode 100644 loopdb/secret.go create mode 100644 loopdb/secret_test.go diff --git a/loopdb/postgres.go b/loopdb/postgres.go index 3058b1a62..a5e4a8c1a 100644 --- a/loopdb/postgres.go +++ b/loopdb/postgres.go @@ -33,7 +33,7 @@ type PostgresConfig struct { Host string `long:"host" description:"Database server hostname."` Port int `long:"port" description:"Database server port."` User string `long:"user" description:"Database user."` - Password string `long:"password" description:"Database user's password."` //nolint:gosec + Password Secret `long:"password" description:"Database user's password. Use @/path/to/file to read from a file."` DBName string `long:"dbname" description:"Database name to use."` MaxOpenConnections int32 `long:"maxconnections" description:"Max open connections to keep alive to the database server."` RequireSSL bool `long:"requiressl" description:"Whether to require using SSL (mode: require) when connecting to the server."` @@ -46,7 +46,7 @@ func (s *PostgresConfig) DSN(hidePassword bool) string { sslMode = "require" } - password := s.Password + password := string(s.Password) if hidePassword { // Placeholder used for logging the DSN safely. password = "****" diff --git a/loopdb/postgres_fixture.go b/loopdb/postgres_fixture.go index 0b4b5a495..5b8ef5f61 100644 --- a/loopdb/postgres_fixture.go +++ b/loopdb/postgres_fixture.go @@ -113,7 +113,7 @@ func (f *TestPgFixture) GetConfig() *PostgresConfig { Host: f.host, Port: f.port, User: testPgUser, - Password: testPgPass, + Password: Secret(testPgPass), DBName: testPgDBName, RequireSSL: false, } diff --git a/loopdb/secret.go b/loopdb/secret.go new file mode 100644 index 000000000..d921d7512 --- /dev/null +++ b/loopdb/secret.go @@ -0,0 +1,44 @@ +package loopdb + +import ( + "fmt" + "os" + "strings" +) + +// Secret is a string type that can unmarshal values from files when prefixed +// with '@'. This allows sensitive values like passwords to be stored in files +// rather than directly in configuration. +type Secret string + +// UnmarshalFlag implements go-flags Unmarshaler. If value starts with '@', +// reads from file at that path. Otherwise uses value directly. +func (s *Secret) UnmarshalFlag(value string) error { + if strings.HasPrefix(value, "@") { + filePath := value[1:] + content, err := os.ReadFile(filePath) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("secret file not found: %s", + filePath) + } + if os.IsPermission(err) { + return fmt.Errorf("unable to read secret "+ + "file (permission denied): %s", + filePath) + } + + return fmt.Errorf("failed to read secret file %s: %w", + filePath, err) + } + // Trim trailing whitespace (spaces, tabs, newlines) to handle + // files created on Windows (CRLF) or Unix (LF), and to avoid + // invisible trailing spaces causing authentication failures. + *s = Secret(strings.TrimRight(string(content), " \t\r\n")) + + return nil + } + *s = Secret(value) + + return nil +} diff --git a/loopdb/secret_test.go b/loopdb/secret_test.go new file mode 100644 index 000000000..889977d58 --- /dev/null +++ b/loopdb/secret_test.go @@ -0,0 +1,197 @@ +package loopdb + +import ( + "os" + "path/filepath" + "testing" + + "github.com/jessevdk/go-flags" + "github.com/stretchr/testify/require" +) + +// TestSecretUnmarshalFlag tests the Secret type's UnmarshalFlag method. +func TestSecretUnmarshalFlag(t *testing.T) { + t.Parallel() + + t.Run("direct value", func(t *testing.T) { + t.Parallel() + + var s Secret + err := s.UnmarshalFlag("mypassword") + require.NoError(t, err) + require.Equal(t, Secret("mypassword"), s) + }) + + t.Run("empty value", func(t *testing.T) { + t.Parallel() + + var s Secret + err := s.UnmarshalFlag("") + require.NoError(t, err) + require.Equal(t, Secret(""), s) + }) + + t.Run("file reference", func(t *testing.T) { + t.Parallel() + + // Create a temp file with a password. + tmpDir := t.TempDir() + passFile := filepath.Join(tmpDir, "password.txt") + err := os.WriteFile(passFile, []byte("secretpassword"), 0600) + require.NoError(t, err) + + var s Secret + err = s.UnmarshalFlag("@" + passFile) + require.NoError(t, err) + require.Equal(t, Secret("secretpassword"), s) + }) + + t.Run("file with trailing newline", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + passFile := filepath.Join(tmpDir, "password.txt") + err := os.WriteFile(passFile, []byte("secretpassword\n"), 0600) + require.NoError(t, err) + + var s Secret + err = s.UnmarshalFlag("@" + passFile) + require.NoError(t, err) + require.Equal(t, Secret("secretpassword"), s) + }) + + t.Run("file with CRLF", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + passFile := filepath.Join(tmpDir, "password.txt") + err := os.WriteFile(passFile, []byte("secretpassword\r\n"), 0600) + require.NoError(t, err) + + var s Secret + err = s.UnmarshalFlag("@" + passFile) + require.NoError(t, err) + require.Equal(t, Secret("secretpassword"), s) + }) + + t.Run("file with trailing whitespace", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + passFile := filepath.Join(tmpDir, "password.txt") + err := os.WriteFile(passFile, []byte("secretpassword \t\n"), 0600) + require.NoError(t, err) + + var s Secret + err = s.UnmarshalFlag("@" + passFile) + require.NoError(t, err) + require.Equal(t, Secret("secretpassword"), s) + }) + + t.Run("empty file", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + passFile := filepath.Join(tmpDir, "password.txt") + err := os.WriteFile(passFile, []byte(""), 0600) + require.NoError(t, err) + + var s Secret + err = s.UnmarshalFlag("@" + passFile) + require.NoError(t, err) + require.Equal(t, Secret(""), s) + }) + + t.Run("file with only newlines", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + passFile := filepath.Join(tmpDir, "password.txt") + err := os.WriteFile(passFile, []byte("\n\n\n"), 0600) + require.NoError(t, err) + + var s Secret + err = s.UnmarshalFlag("@" + passFile) + require.NoError(t, err) + require.Equal(t, Secret(""), s) + }) + + t.Run("file with newline in middle", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + passFile := filepath.Join(tmpDir, "password.txt") + err := os.WriteFile(passFile, []byte("pass\nword\n"), 0600) + require.NoError(t, err) + + var s Secret + err = s.UnmarshalFlag("@" + passFile) + require.NoError(t, err) + require.Equal(t, Secret("pass\nword"), s) + }) + + t.Run("file not found", func(t *testing.T) { + t.Parallel() + + var s Secret + err := s.UnmarshalFlag("@/nonexistent/path/to/file") + require.Error(t, err) + require.Contains(t, err.Error(), "secret file not found") + require.Contains(t, err.Error(), "/nonexistent/path/to/file") + }) + + t.Run("at symbol only", func(t *testing.T) { + t.Parallel() + + // Just "@" means read from empty path, which should fail. + var s Secret + err := s.UnmarshalFlag("@") + require.Error(t, err) + }) + + t.Run("value starting with at but not file ref", func(t *testing.T) { + t.Parallel() + + // A value like "@myemail" would try to read file "myemail". + // This should fail because that file doesn't exist. + var s Secret + err := s.UnmarshalFlag("@myemail") + require.Error(t, err) + require.Contains(t, err.Error(), "secret file not found") + }) +} + +// TestSecretGoFlagsIntegration tests that Secret works correctly with the +// go-flags parser. +func TestSecretGoFlagsIntegration(t *testing.T) { + t.Parallel() + + type Config struct { + Password Secret `long:"password"` + } + + t.Run("direct value via flags", func(t *testing.T) { + t.Parallel() + + var cfg Config + parser := flags.NewParser(&cfg, flags.Default) + _, err := parser.ParseArgs([]string{"--password=directpass"}) + require.NoError(t, err) + require.Equal(t, Secret("directpass"), cfg.Password) + }) + + t.Run("file reference via flags", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + passFile := filepath.Join(tmpDir, "password.txt") + err := os.WriteFile(passFile, []byte("filepass\n"), 0600) + require.NoError(t, err) + + var cfg Config + parser := flags.NewParser(&cfg, flags.Default) + _, err = parser.ParseArgs([]string{"--password=@" + passFile}) + require.NoError(t, err) + require.Equal(t, Secret("filepass"), cfg.Password) + }) +} diff --git a/release_notes.md b/release_notes.md index 38afc603e..b1c824692 100644 --- a/release_notes.md +++ b/release_notes.md @@ -16,6 +16,11 @@ This file tracks release notes for the loop client. #### New Features +* [Support reading database password from file](https://github.com/lightninglabs/loop/issues/1088). + The `--postgres.password` flag now accepts a `@/path/to/file` syntax to read + the password from a file instead of passing it directly. This avoids exposing + secrets in process listings and shell history. + #### Breaking Changes #### Bug Fixes