feat(subsonic): per-user encrypted app-password foundation
Subsonic auth (t=md5(password+salt), legacy p=) needs a recoverable secret, but login passwords are stored as a one-way argon2 hash. Add a separate, per-user app-password: high-entropy, random, and encrypted at rest with a Fernet key derived from SUBSONIC_SECRET_KEY (never stored in the DB). - SubsonicPasswordCipher + generate_subsonic_password in core.security - users.subsonic_password_enc column (+ Alembic migration), repo + port methods - SubsonicAuthService: verify (t+s / p / p=enc:) and rotate/reveal lifecycle - self-service GET/POST /users/me/subsonic-password + admin rotate endpoint - domain SubsonicCredentials + SubsonicCipher port; deps wiring Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,16 +6,54 @@ to this module (CLAUDE.md: security is a cross-cutting concern in ``core``).
|
||||
Higher layers depend only on the Protocols, never on pwdlib/pyjwt directly.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
import jwt
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from pwdlib import PasswordHash
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.domain.errors import AuthenticationError
|
||||
from app.domain.tokens import IssuedToken, TokenClaims, TokenType
|
||||
|
||||
# Length (in bytes of entropy) of a generated Subsonic app-password. 18 bytes of
|
||||
# url-safe base64 → 24 characters, well above the Subsonic auth threat model.
|
||||
_SUBSONIC_PASSWORD_ENTROPY_BYTES = 18
|
||||
|
||||
|
||||
def generate_subsonic_password() -> str:
|
||||
"""A fresh, high-entropy Subsonic app-password (url-safe, ~24 chars)."""
|
||||
return secrets.token_urlsafe(_SUBSONIC_PASSWORD_ENTROPY_BYTES)
|
||||
|
||||
|
||||
class SubsonicPasswordCipher:
|
||||
"""Symmetric encrypt/decrypt for the recoverable Subsonic app-password.
|
||||
|
||||
Subsonic auth (``t=md5(password+salt)`` and legacy ``p=``) needs the plaintext
|
||||
password server-side, so — unlike the argon2-hashed login password — the
|
||||
app-password is stored *encrypted*, not hashed. A Fernet key (AES-128-CBC +
|
||||
HMAC) is derived from the configured secret; the plaintext key never touches
|
||||
the DB. Implements :class:`app.domain.ports.SubsonicCipher`.
|
||||
"""
|
||||
|
||||
def __init__(self, secret_key: str) -> None:
|
||||
digest = hashlib.sha256(secret_key.encode("utf-8")).digest()
|
||||
self._fernet = Fernet(base64.urlsafe_b64encode(digest))
|
||||
|
||||
def encrypt(self, plaintext: str) -> str:
|
||||
return self._fernet.encrypt(plaintext.encode("utf-8")).decode("ascii")
|
||||
|
||||
def decrypt(self, token: str) -> str:
|
||||
try:
|
||||
return self._fernet.decrypt(token.encode("ascii")).decode("utf-8")
|
||||
except InvalidToken as exc:
|
||||
# Wrong/rotated secret key, or corrupted ciphertext.
|
||||
raise AuthenticationError("Stored Subsonic password could not be decrypted.") from exc
|
||||
|
||||
|
||||
class Argon2PasswordHasher:
|
||||
"""argon2id hasher with sensible defaults from pwdlib."""
|
||||
|
||||
Reference in New Issue
Block a user