Files
minstrel/internal/acquire/store.go
T
bvandeusenandClaude Opus 5.5 a8a2d20307
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
feat: stored credentials are sealed at rest, so a copy of the database alone reveals none of them (#6767)
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>
2026-11-02 13:23:19 -05:00

201 lines
6.3 KiB
Go

package acquire
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/secret"
)
// ErrNotFound is a code with no row.
var ErrNotFound = errors.New("no such service")
// ErrTooMany is a twenty-first row of one protocol.
var ErrTooMany = errors.New("there are already twenty of these")
// InvalidError is a setting an admin has to change, in words for the
// settings card.
type InvalidError struct {
Field string
Message string
}
func (e *InvalidError) Error() string { return e.Message }
func invalid(field, msg string) error { return &InvalidError{Field: field, Message: msg} }
// maxPerProtocol bounds the codes tried for a new row. Nobody runs twenty
// Prowlarrs; the bound keeps a loop over a full table finite.
const maxPerProtocol = 20
// Store reads and writes acquisition_services.
type Store struct {
pool *pgxpool.Pool
}
// NewStore returns a Store over pool.
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
// List returns every row without its secrets.
func (s *Store) List(ctx context.Context) ([]dbq.ListAcquisitionServicesRow, error) {
return dbq.New(s.pool).ListAcquisitionServices(ctx)
}
// Get returns one row, secrets included: for building a client only.
func (s *Store) Get(ctx context.Context, code string) (dbq.AcquisitionService, error) {
row, err := getService(ctx, dbq.New(s.pool), code)
if errors.Is(err, pgx.ErrNoRows) {
return row, ErrNotFound
}
return row, err
}
// Create adds a row of protocol, switched off and with no address. Its code
// is the protocol for the first, then protocol-2, protocol-3.
func (s *Store) Create(ctx context.Context, protocol string) (dbq.AcquisitionService, error) {
a, ok := catalogue[protocol]
if !ok {
return dbq.AcquisitionService{}, invalid("protocol", "One of prowlarr, torznab, qbittorrent, exchange.")
}
q := dbq.New(s.pool)
for n := 1; n <= maxPerProtocol; n++ {
code, label := protocol, a.label
if n > 1 {
code, label = fmt.Sprintf("%s-%d", protocol, n), fmt.Sprintf("%s %d", a.label, n)
}
row, err := q.CreateAcquisitionService(ctx, dbq.CreateAcquisitionServiceParams{
Code: code, Kind: a.kind, Protocol: protocol, Label: label,
Category: a.category, TimeoutMs: a.timeoutMs, SearchesPerHour: a.perHour,
})
if errors.Is(err, pgx.ErrNoRows) {
continue
}
return row, err
}
return dbq.AcquisitionService{}, ErrTooMany
}
// Update is everything on the settings form except the credentials.
type Update struct {
Label string
Enabled bool
BaseURL string
Username string
Category string
TimeoutMs int32
ServesSearch bool
ServesDownloads bool
ServesEnrichment bool
LocalPath string
RemotePath string
SearchesPerHour int32
}
// Save validates u and writes it to code's row.
func (s *Store) Save(ctx context.Context, code string, u Update) (dbq.AcquisitionService, error) {
u.Label = strings.TrimSpace(u.Label)
u.BaseURL = strings.TrimSpace(u.BaseURL)
u.Username = strings.TrimSpace(u.Username)
u.Category = strings.TrimSpace(u.Category)
u.LocalPath = strings.TrimSpace(u.LocalPath)
u.RemotePath = strings.TrimSpace(u.RemotePath)
if err := u.validate(); err != nil {
return dbq.AcquisitionService{}, err
}
row, err := dbq.New(s.pool).UpdateAcquisitionService(ctx, dbq.UpdateAcquisitionServiceParams{
Code: code, Label: u.Label, Enabled: u.Enabled, BaseUrl: u.BaseURL,
Username: u.Username, Category: u.Category, TimeoutMs: u.TimeoutMs,
ServesSearch: u.ServesSearch, ServesDownloads: u.ServesDownloads,
ServesEnrichment: u.ServesEnrichment, LocalPath: u.LocalPath,
RemotePath: u.RemotePath, SearchesPerHour: u.SearchesPerHour,
})
if errors.Is(err, pgx.ErrNoRows) {
return row, ErrNotFound
}
return row, err
}
// validate mirrors the table's CHECKs, so a bad value is named in the card's
// own terms before Postgres refuses it.
func (u Update) validate() error {
if u.Label == "" {
return invalid("label", "Name: give it a name.")
}
if u.Enabled && u.BaseURL == "" {
return invalid("base_url", "Address: give the address before switching this on.")
}
if u.BaseURL != "" {
parsed, err := url.Parse(u.BaseURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
return invalid("base_url", "Address: start with http:// or https://.")
}
}
if u.TimeoutMs < 1000 || u.TimeoutMs > 300000 {
return invalid("timeout_ms", "Give up after: between 1000 and 300000 milliseconds.")
}
if !strings.HasPrefix(u.LocalPath, "/") {
return invalid("local_path", "Downloads folder as Minstrel sees it: a full path, starting with /.")
}
if u.RemotePath != "" && !strings.HasPrefix(u.RemotePath, "/") {
return invalid("remote_path", "Downloads folder as the client sees it: a full path starting with /, or empty when it is the same path.")
}
if u.SearchesPerHour < 0 || u.SearchesPerHour > 3600 {
return invalid("searches_per_hour", "Searches per hour: between 0 and 3600. 0 means no limit.")
}
return nil
}
// Delete removes code's row.
func (s *Store) Delete(ctx context.Context, code string) error {
n, err := dbq.New(s.pool).DeleteAcquisitionService(ctx, code)
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
}
// SetAPIKey stores a key, trimmed and sealed; empty clears it.
func (s *Store) SetAPIKey(ctx context.Context, code, key string) error {
sealed, err := secret.Seal(secret.AcquisitionAPIKey.Purpose(), code, strings.TrimSpace(key))
if err != nil {
return err
}
n, err := dbq.New(s.pool).SetAcquisitionServiceAPIKey(ctx, dbq.SetAcquisitionServiceAPIKeyParams{
Code: code, ApiKey: sealed,
})
return affected(n, err)
}
// SetPassword stores a password as typed (spaces are part of a password),
// sealed; empty clears it.
func (s *Store) SetPassword(ctx context.Context, code, password string) error {
sealed, err := secret.Seal(secret.AcquisitionPassword.Purpose(), code, password)
if err != nil {
return err
}
n, err := dbq.New(s.pool).SetAcquisitionServicePassword(ctx, dbq.SetAcquisitionServicePasswordParams{
Code: code, Password: sealed,
})
return affected(n, err)
}
func affected(n int64, err error) error {
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
}