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>
111 lines
3.4 KiB
Go
111 lines
3.4 KiB
Go
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")
|
|
}
|
|
}
|