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,179 @@
|
||||
// Package secret seals the credentials Minstrel keeps for outside systems
|
||||
// (indexer and download-client keys, the SMTP password, provider API keys,
|
||||
// ListenBrainz tokens, Subsonic passwords) so the database holds ciphertext,
|
||||
// not the credential.
|
||||
//
|
||||
// Each column has its own key, derived from the install's master key with
|
||||
// HKDF-SHA256 and the column as the info string, so a value sealed for one
|
||||
// column cannot be opened as another, and the master key never touches a
|
||||
// cipher. Each value is also bound to the row it belongs to (its owner), so a
|
||||
// sealed value copied onto another row does not open there.
|
||||
//
|
||||
// The master key is the stream secret (MINSTREL_STREAM_SECRET, or the
|
||||
// stream_secret file in the data directory). It lives outside the database,
|
||||
// so a copy of the database alone opens nothing. Changing it makes every
|
||||
// sealed value unreadable: each one reads as unset and has to be entered
|
||||
// again in Settings.
|
||||
package secret
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hkdf"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ErrNoKey is a seal or open before Install has given the package a key.
|
||||
var ErrNoKey = errors.New("secret: no master key installed")
|
||||
|
||||
// ErrCorrupt is a stored value that does not open: the master key changed,
|
||||
// the value was edited by hand, or it belongs to a different row.
|
||||
var ErrCorrupt = errors.New("secret: the stored value does not open")
|
||||
|
||||
// sealedPrefix versions the format, so a future change of cipher can still
|
||||
// read what this one wrote. Nothing a person types starts with it by chance
|
||||
// often enough to matter, and Open rejects anything without it.
|
||||
const sealedPrefix = "v1."
|
||||
|
||||
var b64 = base64.RawURLEncoding
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
master []byte
|
||||
boxes map[string]cipher.AEAD
|
||||
)
|
||||
|
||||
// Install sets the master key every Seal and Open derives from. The server
|
||||
// calls it once at startup, before anything reads a credential.
|
||||
func Install(key []byte) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
master = append([]byte(nil), key...)
|
||||
boxes = map[string]cipher.AEAD{}
|
||||
}
|
||||
|
||||
// aead returns purpose's cipher, deriving it on first use.
|
||||
func aead(purpose string) (cipher.AEAD, error) {
|
||||
mu.RLock()
|
||||
a, ok := boxes[purpose]
|
||||
key := master
|
||||
mu.RUnlock()
|
||||
if ok {
|
||||
return a, nil
|
||||
}
|
||||
if len(key) == 0 {
|
||||
return nil, ErrNoKey
|
||||
}
|
||||
derived, err := hkdf.Key(sha256.New, key, nil, "minstrel/"+purpose, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("secret: derive: %w", err)
|
||||
}
|
||||
block, err := aes.NewCipher(derived)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("secret: %w", err)
|
||||
}
|
||||
a, err = cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("secret: %w", err)
|
||||
}
|
||||
mu.Lock()
|
||||
if boxes != nil {
|
||||
boxes[purpose] = a
|
||||
}
|
||||
mu.Unlock()
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// IsSealed reports whether stored is in the sealed format.
|
||||
func IsSealed(stored string) bool { return strings.HasPrefix(stored, sealedPrefix) }
|
||||
|
||||
// Seal encrypts plain for purpose (the column it is stored in) and owner (the
|
||||
// row it belongs to). The empty string stays empty, so "is one set?" stays a
|
||||
// test for the empty string.
|
||||
func Seal(purpose, owner, plain string) (string, error) {
|
||||
if plain == "" {
|
||||
return "", nil
|
||||
}
|
||||
a, err := aead(purpose)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, a.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", fmt.Errorf("secret: nonce: %w", err)
|
||||
}
|
||||
out := a.Seal(nonce, nonce, []byte(plain), []byte(owner))
|
||||
return sealedPrefix + b64.EncodeToString(out), nil
|
||||
}
|
||||
|
||||
// Open decrypts what Seal stored for the same purpose and owner. The empty
|
||||
// string opens to itself.
|
||||
func Open(purpose, owner, stored string) (string, error) {
|
||||
if stored == "" {
|
||||
return "", nil
|
||||
}
|
||||
a, err := aead(purpose)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
raw, ok := strings.CutPrefix(stored, sealedPrefix)
|
||||
if !ok {
|
||||
return "", ErrCorrupt
|
||||
}
|
||||
data, err := b64.DecodeString(raw)
|
||||
n := a.NonceSize()
|
||||
if err != nil || len(data) < n {
|
||||
return "", ErrCorrupt
|
||||
}
|
||||
plain, err := a.Open(nil, data[:n], data[n:], []byte(owner))
|
||||
if err != nil {
|
||||
return "", ErrCorrupt
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
// Reveal is Open for a caller about to use the credential: a value that does
|
||||
// not open is logged and read as unset, which is what it has become. The
|
||||
// admin sees "not set" in Settings and enters it again.
|
||||
func Reveal(purpose, owner, stored string) string {
|
||||
plain, err := Open(purpose, owner, stored)
|
||||
if err != nil {
|
||||
slog.Warn("secret: a stored credential does not open; it reads as unset until it is entered again",
|
||||
"column", purpose, "err", err)
|
||||
return ""
|
||||
}
|
||||
return plain
|
||||
}
|
||||
|
||||
// RevealPtr is Reveal for a nullable column: nil stays nil, and a value that
|
||||
// does not open reads as nil.
|
||||
func RevealPtr(purpose, owner string, stored *string) *string {
|
||||
if stored == nil {
|
||||
return nil
|
||||
}
|
||||
plain := Reveal(purpose, owner, *stored)
|
||||
if plain == "" {
|
||||
return nil
|
||||
}
|
||||
return &plain
|
||||
}
|
||||
|
||||
// SealPtr is Seal for a nullable column: nil and the empty string both store
|
||||
// NULL.
|
||||
func SealPtr(purpose, owner string, plain *string) (*string, error) {
|
||||
if plain == nil || *plain == "" {
|
||||
return nil, nil
|
||||
}
|
||||
sealed, err := Seal(purpose, owner, *plain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sealed, nil
|
||||
}
|
||||
Reference in New Issue
Block a user