package subsonic import ( "crypto/md5" "database/sql" "encoding/hex" "strconv" "strings" vsauth "git.noroutes.net/NoRoutes/vibetunes/internal/auth" "git.noroutes.net/NoRoutes/vibetunes/internal/database" ) // authenticate implements original Subsonic authentication: either // token/salt (t = md5(password + salt), the modern recommended mode) or // password (p= or p=enc:<hex>, kept for older clients). // // This never touches VibeSonic's Argon2id-hashed password directly for // token auth — that hash is one-way by design. Token auth is instead // verified against a separately maintained, AES-GCM-encrypted copy of // the password (see internal/auth/subsonic_secret.go); the /rest/* // surface never reintroduces MD5 or plaintext as VibeSonic's own storage // format, it only speaks it on the wire where the protocol requires it. func authenticate(db *sql.DB, encryptionKey []byte, p params) (*database.User, *apiError) { apiKey := p.Get("apiKey") username := p.Get("u") if apiKey != "" { // The apiKeyAuthentication extension requires a client not send // 'u' alongside 'apiKey' — accepting both would leave it // ambiguous which identity the caller actually intended, so this // is rejected outright rather than silently preferring one. if username != "" { return nil, errConflictingAuth } return authenticateAPIKey(db, apiKey) } if username == "" { return nil, errMissingParameter("u") } user, err := database.GetUserByUsername(db, username) if err != nil || user.Disabled { return nil, errWrongCredentials } token := p.Get("t") salt := p.Get("s") password := p.Get("p") switch { case token != "" && salt != "": if !user.SubsonicAuthSecret.Valid { return nil, errTokenAuthNotSupported } plaintext, derr := vsauth.DecryptPassword(encryptionKey, user.SubsonicAuthSecret.String) if derr != nil { return nil, errWrongCredentials } sum := md5.Sum([]byte(plaintext + salt)) expected := hex.EncodeToString(sum[:]) if !strings.EqualFold(expected, token) { return nil, errWrongCredentials } case password != "": plaintext := password if strings.HasPrefix(password, "enc:") { decoded, herr := hex.DecodeString(strings.TrimPrefix(password, "enc:")) if herr != nil { return nil, errWrongCredentials } plaintext = string(decoded) } if !user.PasswordHash.Valid { return nil, errWrongCredentials } ok, verr := vsauth.VerifyPassword(plaintext, user.PasswordHash.String) if verr != nil || !ok { return nil, errWrongCredentials } default: return nil, errMissingParameter("p or t/s") } return user, nil } // authenticateAPIKey implements the OpenSubsonic apiKeyAuthentication // extension: the key is hashed the same way as VibeSonic's own web // settings API keys (they are exactly the same keys — one system, not a // parallel one) and looked up by hash, never by scanning stored keys in // plaintext. func authenticateAPIKey(db *sql.DB, rawKey string) (*database.User, *apiError) { key, err := database.GetAPIKeyByHash(db, vsauth.HashAPIKey(rawKey)) if err != nil { return nil, errInvalidAPIKey } user, err := database.GetUserByID(db, key.UserID) if err != nil || user.Disabled { return nil, errInvalidAPIKey } _ = database.TouchAPIKey(db, key.ID) return user, nil } // checkVersion rejects clients whose declared protocol version is newer // than what this server implements. A missing or unparsable v is // tolerated (treated as compatible) rather than rejected outright, to // stay lenient with tooling that omits it — the protocol is additive, so // there is no reason to reject an old client either. func checkVersion(v string) *apiError { if v == "" { return nil } client, ok := parseVersion(v) if !ok { return nil } server, _ := parseVersion(ServerVersion) if versionGreater(client, server) { return errClientMustUpgrade } return nil } type semver struct{ major, minor, patch int } func parseVersion(v string) (semver, bool) { parts := strings.SplitN(v, ".", 3) if len(parts) == 0 { return semver{}, false } var s semver var err error s.major, err = strconv.Atoi(parts[0]) if err != nil { return semver{}, false } if len(parts) > 1 { s.minor, _ = strconv.Atoi(parts[1]) } if len(parts) > 2 { s.patch, _ = strconv.Atoi(parts[2]) } return s, true } func versionGreater(a, b semver) bool { if a.major != b.major { return a.major > b.major } if a.minor != b.minor { return a.minor > b.minor } return a.patch > b.patch }