feat: stored credentials are sealed at rest, so a copy of the database alone reveals none of them (#6767)
release / govulncheck (push) Successful in 28s
release / web (push) Successful in 1m18s
release / go (push) Successful in 1m35s
release / android (push) Successful in 4m45s
release / Build signed APK (push) Successful in 4m57s
release / integration (push) Successful in 5m3s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m14s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 28s
release / web (push) Successful in 1m18s
release / go (push) Successful in 1m35s
release / android (push) Successful in 4m45s
release / Build signed APK (push) Successful in 4m57s
release / integration (push) Successful in 5m3s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m14s
release / Verify release artifacts (tag releases only) (push) Skipped
Minstrel keeps credentials it has to read back: indexer, Prowlarr and download-client keys and passwords, the AcoustID key, cover-art and tag provider keys, the SMTP password, and each user's ListenBrainz token and Subsonic password. They were stored in the clear; they are now sealed with AES-256-GCM under a per-column HKDF key derived from the stream secret, each value bound to its row. - internal/secret: Seal/Open/Reveal, and Columns, the one list of sealed columns (a backup will leave them out). Install takes the master key at startup; SealStored seals any value still in the clear, so an existing database is brought over in place on the first boot. - Every write seals and every read opens: acquire (one read path, getService / usableServices), acoustid settings, SMTP, ListenBrainz, Subsonic auth and password generation, cover-art and tag provider settings. - A value that does not open (the stream secret changed) is logged and reads as unset, to be entered again in Settings. docs/security.md says so. - Tests: secret unit tests (wrong owner/purpose/key, malformed, no key), a SealStored integration test over all eight columns plus a schema check that each listed column exists, and the existing tests now assert sealed storage. dbtest installs a test key for integration tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
package secret_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/secret"
|
||||
)
|
||||
|
||||
func newPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in -short mode")
|
||||
}
|
||||
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
pool, err := pgxpool.New(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
dbtest.ResetDB(t, pool)
|
||||
return pool
|
||||
}
|
||||
|
||||
// Every listed column exists, so a renamed or dropped column fails here
|
||||
// rather than leaving a credential unsealed.
|
||||
func TestColumnsExistInTheSchema_Integration(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
for _, c := range secret.Columns {
|
||||
var n int
|
||||
err := pool.QueryRow(context.Background(),
|
||||
`SELECT count(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = $1 AND column_name IN ($2, $3)`,
|
||||
c.Table, c.Column, c.KeyColumn).Scan(&n)
|
||||
if err != nil || n != 2 {
|
||||
t.Errorf("%s (key %s): found %d of 2 columns, err %v", c.Purpose(), c.KeyColumn, n, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A database written before sealing existed is sealed in place at startup,
|
||||
// every value opens to what it held, and a second pass has nothing to do.
|
||||
func TestSealStoredSealsWhatIsInTheClear_Integration(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
ctx := context.Background()
|
||||
q := dbq.New(pool)
|
||||
|
||||
u, err := q.CreateUser(ctx, dbq.CreateUserParams{
|
||||
Username: dbtest.TestUserPrefix + "sealer", PasswordHash: "x", ApiTokenHash: "sealer", IsAdmin: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("user: %v", err)
|
||||
}
|
||||
if _, err := q.CreateAcquisitionService(ctx, dbq.CreateAcquisitionServiceParams{
|
||||
Code: "seal-test", Kind: "search", Protocol: "prowlarr", Label: "Seal test",
|
||||
Category: "", TimeoutMs: 1000, SearchesPerHour: 0,
|
||||
}); err != nil {
|
||||
t.Fatalf("acquisition row: %v", err)
|
||||
}
|
||||
seed := []string{
|
||||
`UPDATE acquisition_services SET api_key = 'acq-key', password = 'acq-pw' WHERE code = 'seal-test'`,
|
||||
`UPDATE acoustid_settings SET api_key = 'acoustid-key' WHERE id = true`,
|
||||
`UPDATE smtp_config SET password = 'smtp-pw' WHERE id = true`,
|
||||
fmt.Sprintf(`UPDATE users SET listenbrainz_token = 'lb-token', subsonic_password = 'sub-pw' WHERE id = '%s'`, u.ID.String()),
|
||||
`INSERT INTO cover_art_provider_settings (provider_id, enabled, display_order, api_key)
|
||||
VALUES ('seal-test', false, 99, 'cover-key')
|
||||
ON CONFLICT (provider_id) DO UPDATE SET api_key = EXCLUDED.api_key`,
|
||||
`INSERT INTO tag_provider_settings (provider_id, enabled, display_order, api_key)
|
||||
VALUES ('seal-test', false, 99, 'tag-key')
|
||||
ON CONFLICT (provider_id) DO UPDATE SET api_key = EXCLUDED.api_key`,
|
||||
}
|
||||
for _, s := range seed {
|
||||
if _, err := pool.Exec(ctx, s); err != nil {
|
||||
t.Fatalf("seed %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
for _, s := range []string{
|
||||
`DELETE FROM cover_art_provider_settings WHERE provider_id = 'seal-test'`,
|
||||
`DELETE FROM tag_provider_settings WHERE provider_id = 'seal-test'`,
|
||||
`DELETE FROM acquisition_services WHERE code = 'seal-test'`,
|
||||
`UPDATE acoustid_settings SET api_key = NULL WHERE id = true`,
|
||||
`UPDATE smtp_config SET password = '' WHERE id = true`,
|
||||
} {
|
||||
_, _ = pool.Exec(context.Background(), s)
|
||||
}
|
||||
})
|
||||
|
||||
want := []struct {
|
||||
col secret.Column
|
||||
key string
|
||||
plain string
|
||||
}{
|
||||
{secret.AcquisitionAPIKey, "seal-test", "acq-key"},
|
||||
{secret.AcquisitionPassword, "seal-test", "acq-pw"},
|
||||
{secret.AcoustIDAPIKey, "true", "acoustid-key"},
|
||||
{secret.SMTPPassword, "true", "smtp-pw"},
|
||||
{secret.ListenBrainzToken, u.ID.String(), "lb-token"},
|
||||
{secret.SubsonicPassword, u.ID.String(), "sub-pw"},
|
||||
{secret.CoverArtAPIKey, "seal-test", "cover-key"},
|
||||
{secret.TagProviderAPIKey, "seal-test", "tag-key"},
|
||||
}
|
||||
if len(want) != len(secret.Columns) {
|
||||
t.Fatalf("the test covers %d columns, secret.Columns lists %d", len(want), len(secret.Columns))
|
||||
}
|
||||
|
||||
n, err := secret.SealStored(ctx, pool)
|
||||
if err != nil {
|
||||
t.Fatalf("SealStored: %v", err)
|
||||
}
|
||||
if n < len(want) {
|
||||
t.Errorf("sealed %d values, want at least %d", n, len(want))
|
||||
}
|
||||
|
||||
for _, w := range want {
|
||||
var stored string
|
||||
query := fmt.Sprintf(`SELECT %s FROM %s WHERE %s::text = $1`,
|
||||
pgx.Identifier{w.col.Column}.Sanitize(), pgx.Identifier{w.col.Table}.Sanitize(),
|
||||
pgx.Identifier{w.col.KeyColumn}.Sanitize())
|
||||
if err := pool.QueryRow(ctx, query, w.key).Scan(&stored); err != nil {
|
||||
t.Errorf("%s: read: %v", w.col.Purpose(), err)
|
||||
continue
|
||||
}
|
||||
if !secret.IsSealed(stored) {
|
||||
t.Errorf("%s = %q, want it sealed", w.col.Purpose(), stored)
|
||||
continue
|
||||
}
|
||||
got, err := secret.Open(w.col.Purpose(), w.col.Owner(w.key), stored)
|
||||
if err != nil || got != w.plain {
|
||||
t.Errorf("%s opens to %q (err %v), want %q", w.col.Purpose(), got, err, w.plain)
|
||||
}
|
||||
}
|
||||
|
||||
again, err := secret.SealStored(ctx, pool)
|
||||
if err != nil || again != 0 {
|
||||
t.Errorf("second pass sealed %d (err %v), want 0", again, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user