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

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:
2026-11-02 13:23:19 -05:00
co-authored by Claude Opus 5.5
parent be642cd53f
commit a8a2d20307
22 changed files with 748 additions and 41 deletions
+110
View File
@@ -0,0 +1,110 @@
package secret
import (
"errors"
"strings"
"testing"
)
func withKey(t *testing.T, key string) {
t.Helper()
mu.RLock()
prev := master
mu.RUnlock()
Install([]byte(key))
t.Cleanup(func() { Install(prev) })
}
func TestSealOpensForTheSamePurposeAndOwner(t *testing.T) {
withKey(t, "test master key")
sealed, err := Seal("t.col", "row-1", "hunter2")
if err != nil {
t.Fatal(err)
}
if !IsSealed(sealed) || strings.Contains(sealed, "hunter2") {
t.Fatalf("sealed = %q, want an opaque v1. value", sealed)
}
plain, err := Open("t.col", "row-1", sealed)
if err != nil || plain != "hunter2" {
t.Fatalf("Open = %q, %v; want hunter2", plain, err)
}
again, _ := Seal("t.col", "row-1", "hunter2")
if again == sealed {
t.Error("two seals of one value are identical; the nonce is not random")
}
}
func TestOpenRefusesAnythingElse(t *testing.T) {
withKey(t, "test master key")
sealed, _ := Seal("t.col", "row-1", "hunter2")
cases := map[string]func() (string, error){
"another owner": func() (string, error) { return Open("t.col", "row-2", sealed) },
"another purpose": func() (string, error) { return Open("t.other", "row-1", sealed) },
"plain text": func() (string, error) { return Open("t.col", "row-1", "hunter2") },
"truncated": func() (string, error) { return Open("t.col", "row-1", sealed[:len(sealed)-4]) },
"not base64": func() (string, error) { return Open("t.col", "row-1", "v1.!!!") },
"too short": func() (string, error) { return Open("t.col", "row-1", "v1.AAAA") },
}
for name, open := range cases {
if _, err := open(); !errors.Is(err, ErrCorrupt) {
t.Errorf("%s: err = %v, want ErrCorrupt", name, err)
}
}
Install([]byte("a different key"))
if _, err := Open("t.col", "row-1", sealed); !errors.Is(err, ErrCorrupt) {
t.Errorf("another master key: err = %v, want ErrCorrupt", err)
}
}
func TestEmptyStaysEmpty(t *testing.T) {
withKey(t, "test master key")
if s, err := Seal("t.col", "o", ""); s != "" || err != nil {
t.Errorf("Seal(\"\") = %q, %v; want the empty string", s, err)
}
if s, err := Open("t.col", "o", ""); s != "" || err != nil {
t.Errorf("Open(\"\") = %q, %v; want the empty string", s, err)
}
empty := ""
if p, err := SealPtr("t.col", "o", &empty); p != nil || err != nil {
t.Errorf("SealPtr(\"\") = %v, %v; want nil", p, err)
}
if p := RevealPtr("t.col", "o", nil); p != nil {
t.Errorf("RevealPtr(nil) = %v, want nil", p)
}
}
func TestNoKeyIsAnErrorNotAPanic(t *testing.T) {
withKey(t, "")
if _, err := Seal("t.col", "o", "x"); !errors.Is(err, ErrNoKey) {
t.Errorf("Seal: err = %v, want ErrNoKey", err)
}
if _, err := Open("t.col", "o", "v1.AAAA"); !errors.Is(err, ErrNoKey) {
t.Errorf("Open: err = %v, want ErrNoKey", err)
}
}
func TestRevealReadsAnUnopenableValueAsUnset(t *testing.T) {
withKey(t, "test master key")
if got := Reveal("t.col", "o", "not sealed"); got != "" {
t.Errorf("Reveal = %q, want the empty string", got)
}
bad := "not sealed"
if got := RevealPtr("t.col", "o", &bad); got != nil {
t.Errorf("RevealPtr = %q, want nil", *got)
}
}
func TestEveryColumnHasADistinctPurpose(t *testing.T) {
seen := map[string]bool{}
for _, c := range Columns {
if seen[c.Purpose()] {
t.Errorf("%s is listed twice", c.Purpose())
}
seen[c.Purpose()] = true
}
if !seen[AcquisitionAPIKey.Purpose()] || !seen[SubsonicPassword.Purpose()] {
t.Error("Columns is missing a declared column")
}
}