// 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 }