Files
minstrel/internal/subsonic/auth.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

153 lines
5.1 KiB
Go

package subsonic
import (
"context"
"crypto/md5"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"math"
"net/http"
"strconv"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/secret"
)
// Config controls wire-format concessions the server makes for Subsonic
// clients. At-rest policy (which users have a subsonic_password set) is
// driven per-user; this struct only covers the server-wide opt-ins.
type Config struct {
AllowPlaintextPassword bool
}
type ctxKey int
const userCtxKey ctxKey = 1
// UserFromContext returns the user attached by Middleware.
func UserFromContext(ctx context.Context) (dbq.User, bool) {
u, ok := ctx.Value(userCtxKey).(dbq.User)
return u, ok
}
// Middleware authenticates the request against users.api_token_hash (apiKey) or
// users.subsonic_password (t/s or p). On failure it writes a Subsonic failed
// envelope using the request's f= format; downstream handlers never see an
// unauthenticated request.
//
// guard throttles wrong credentials per account and per address, the same
// limits as the native login. Only wrong-credential failures count: Subsonic
// clients authenticate on every request, so counting successes would lock
// out a client for playing an album. hops is the live trusted-proxy depth
// used to find the caller's address. A nil guard disables throttling.
func Middleware(pool *pgxpool.Pool, cfg Config, guard *auth.LoginGuard, hops func() int) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
addr := auth.ClientIP(r, hops())
// apiKey requests have no account to charge; they count against
// the address alone.
account := r.URL.Query().Get("u")
if blocked, wait := guard.Blocked(account, addr); blocked {
secs := int(math.Ceil(wait.Seconds()))
w.Header().Set("Retry-After", strconv.Itoa(max(secs, 1)))
WriteFail(w, r, ErrGeneric, fmt.Sprintf("Too many failed sign-in attempts; try again in %d seconds", max(secs, 1)))
return
}
user, code, msg := authenticate(r, pool, cfg)
if code == ErrWrongCredentials {
guard.Fail(account, addr)
}
if code != 0 {
WriteFail(w, r, code, msg)
return
}
guard.Succeed(account)
ctx := context.WithValue(r.Context(), userCtxKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func authenticate(r *http.Request, pool *pgxpool.Pool, cfg Config) (dbq.User, int, string) {
q := dbq.New(pool)
params := r.URL.Query()
if apiKey := params.Get("apiKey"); apiKey != "" {
user, err := q.GetUserByAPITokenHash(r.Context(), auth.HashAPIToken(apiKey))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return dbq.User{}, ErrWrongCredentials, "Invalid apiKey"
}
return dbq.User{}, ErrGeneric, "Auth lookup failed"
}
return user, 0, ""
}
username := params.Get("u")
if username == "" {
return dbq.User{}, ErrMissingParameter, "Missing required parameter: u or apiKey"
}
user, err := q.GetUserByUsername(r.Context(), username)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return dbq.User{}, ErrWrongCredentials, "Wrong username or password"
}
return dbq.User{}, ErrGeneric, "Auth lookup failed"
}
// The stored Subsonic password is sealed; both t+s and p= compare against
// it opened. One that does not open reads as not configured.
subsonicPassword := secret.RevealPtr(secret.SubsonicPassword.Purpose(), user.ID.String(), user.SubsonicPassword)
if token, salt := params.Get("t"), params.Get("s"); token != "" && salt != "" {
if subsonicPassword == nil {
return dbq.User{}, ErrTokenNotSupported, "Subsonic token auth not configured; use apiKey or set a Subsonic password"
}
sum := md5.Sum([]byte(*subsonicPassword + salt))
expected := hex.EncodeToString(sum[:])
if subtle.ConstantTimeCompare([]byte(expected), []byte(token)) != 1 {
return dbq.User{}, ErrWrongCredentials, "Wrong username or password"
}
return user, 0, ""
}
if password := params.Get("p"); password != "" {
if !cfg.AllowPlaintextPassword {
return dbq.User{}, ErrTokenNotSupported, "Plaintext password auth is disabled; use apiKey or t+s"
}
if subsonicPassword == nil {
return dbq.User{}, ErrTokenNotSupported, "Subsonic password not configured for this user"
}
decoded, err := decodePassword(password)
if err != nil {
return dbq.User{}, ErrWrongCredentials, "Malformed password"
}
if subtle.ConstantTimeCompare([]byte(*subsonicPassword), []byte(decoded)) != 1 {
return dbq.User{}, ErrWrongCredentials, "Wrong username or password"
}
return user, 0, ""
}
return dbq.User{}, ErrMissingParameter, "Missing required parameter: p, t+s, or apiKey"
}
// decodePassword unwraps Subsonic's enc:HEX obfuscation. Non-enc values pass
// through unchanged so the caller can treat the result as the literal secret.
func decodePassword(p string) (string, error) {
if !strings.HasPrefix(p, "enc:") {
return p, nil
}
b, err := hex.DecodeString(p[4:])
if err != nil {
return "", err
}
return string(b), nil
}