diff --git a/config.toml.example b/config.toml.example index 69f0c32..47015f7 100644 --- a/config.toml.example +++ b/config.toml.example @@ -31,6 +31,11 @@ password_or_token = "" app_id = "" # Managed automatically by streamrip-go secrets = [] +# Vendor app credentials. Leave empty to use the values built into +# streamrip-go; set them only if Qobuz rotates theirs and you need to +# patch your install without waiting for a new release. +mobile_app_id = "" +mobile_app_secret = "" [tidal] # Quality ladder: @@ -50,6 +55,11 @@ access_token = "" refresh_token = "" # Unix timestamp when access_token expires token_expiry = 0 +# Vendor app credentials. Leave empty to use the values built into +# streamrip-go; set them only if Tidal rotates theirs and you need to +# patch your install without waiting for a new release. +app_client_id = "" +app_client_secret = "" [deezer] # Quality ladder: @@ -64,6 +74,13 @@ email = "" password = "" # Optional cached Deezer refresh token. Managed automatically when available. refresh_token = "" +# Vendor app credentials. Leave empty to use the values built into +# streamrip-go; set them only if Deezer rotates theirs and you need to +# patch your install without waiting for a new release. +api_key = "" +gateway_key = "" +# Must be exactly 16 characters if set. +blowfish_secret = "" [yandex] # Quality ladder: @@ -73,6 +90,11 @@ quality = 2 access_token = "" # Cached current account uid. Managed automatically when available. user_id = "" +# Vendor app credentials. Leave empty to use the values built into +# streamrip-go; set them only if Yandex rotates theirs and you need to +# patch your install without waiting for a new release. +sign_key = "" +legacy_mp3_sign_salt = "" [beatport] # Quality ladder: @@ -90,13 +112,14 @@ access_token = "" refresh_token = "" # Unix timestamp when access_token expires token_expiry = 0 +# Vendor app credentials. Leave empty to use the values built into +# streamrip-go; set them only if Beatport rotates theirs and you need to +# patch your install without waiting for a new release. +app_client_id = "" [soundcloud] # Quality is currently provider-defined (keep 0) quality = 0 -# Managed automatically when available -client_id = "" -app_version = "" [database] # Track IDs already downloaded are stored here and skipped next time diff --git a/internal/app/app.go b/internal/app/app.go index aff3efe..152f1e2 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -129,6 +129,9 @@ func New(cfg *config.Config) (*Main, error) { Pending: []media.Pending{}, Media: []media.Media{}, } + // The downloader does the Deezer decryption, so it needs the protocol + // secret from config rather than a constant of its own. + m.DL.DeezerBlowfishSecret = cfg.Session.Deezer.BlowfishSecret verbose.SetSink(func(msg string) { m.DL.Logf("%s", msg) }) return m, nil } diff --git a/internal/config/config.go b/internal/config/config.go index cd93558..4059a26 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -66,6 +66,11 @@ type QobuzConfig struct { PasswordOrToken string `toml:"password_or_token"` AppID string `toml:"app_id"` Secrets []string `toml:"secrets"` + + // Vendor app credentials for the mobile API fallback. Defaulted in code; + // override here when Qobuz rotates them, instead of waiting for a release. + MobileAppID string `toml:"mobile_app_id"` + MobileAppSecret string `toml:"mobile_app_secret"` } type TidalConfig struct { @@ -77,6 +82,10 @@ type TidalConfig struct { AccessToken string `toml:"access_token"` RefreshToken string `toml:"refresh_token"` TokenExpiry int64 `toml:"token_expiry"` + + // Vendor OAuth app credentials. Defaulted in code; override on rotation. + AppClientID string `toml:"app_client_id"` + AppClientSecret string `toml:"app_client_secret"` } type DeezerConfig struct { @@ -86,12 +95,21 @@ type DeezerConfig struct { Email string `toml:"email"` Password string `toml:"password"` RefreshToken string `toml:"refresh_token"` + + // Vendor protocol keys. Defaulted in code; override on rotation. + APIKey string `toml:"api_key"` + GatewayKey string `toml:"gateway_key"` + BlowfishSecret string `toml:"blowfish_secret"` } type YandexConfig struct { Quality int `toml:"quality"` AccessToken string `toml:"access_token"` UserID string `toml:"user_id"` + + // Vendor request-signing keys. Defaulted in code; override on rotation. + SignKey string `toml:"sign_key"` + LegacyMP3SignSalt string `toml:"legacy_mp3_sign_salt"` } type BeatportConfig struct { @@ -102,12 +120,15 @@ type BeatportConfig struct { AccessToken string `toml:"access_token"` RefreshToken string `toml:"refresh_token"` TokenExpiry int64 `toml:"token_expiry"` + + // Vendor OAuth app credential. Defaulted in code; override on rotation. + AppClientID string `toml:"app_client_id"` } +// SoundcloudConfig has no client_id/app_version: soundcloud is handled via +// yt-dlp, which supplies its own, so those keys were read by no code. type SoundcloudConfig struct { - Quality int `toml:"quality"` - ClientID string `toml:"client_id"` - AppVersion string `toml:"app_version"` + Quality int `toml:"quality"` } type DatabaseConfig struct { @@ -211,6 +232,60 @@ func Load(path string) (*Config, error) { return &Config{Path: resolvedPath, File: data, Session: cloneConfigData(data)}, nil } +// Vendor app credentials and protocol keys. +// +// These are the provider apps' own identifiers and signing keys, not user +// secrets, and they are baked in so the tool works out of the box. They are +// mirrored into the config so that when a provider rotates one, a user can fix +// their install with a config edit instead of waiting for a new release. +const ( + DefaultQobuzMobileAppID = "312369995" + DefaultQobuzMobileAppSecret = "e79f8b9be485692b0e5f9dd895826368" + DefaultTidalAppClientID = "fX2JxdmntZWK0ixT" + DefaultTidalAppClientSecret = "1Nm5AfDAjxrgJFJbKNWLeAyKGVGmINuXPPLHVXAvxAg=" + DefaultDeezerAPIKey = "4VCYIJUCDLOUELGD1V8WBVYBNVDYOXEWSLLZDONGBBDFVXTZJRXPR29JRLQFO6ZE" + DefaultDeezerGatewayKey = "VBK1FSUEXHTSDBJJ" + DefaultDeezerBlowfishSecret = "g4el58wc0zvf9na1" + DefaultYandexSignKey = "kzqU4XhfCaY6B6JTHODeq5" + DefaultYandexLegacyMP3Salt = "XGRlBW9FXlekgbPrRHuSiA" + DefaultBeatportAppClientID = "ryZ8LuyQVPqbK2mBX2Hwt4qSMtnWuTYSqBPO92yQ" +) + +// applyVendorDefaults fills in any vendor credential the user has not set, so +// the provider clients never have to nil-check them. +func applyVendorDefaults(data *ConfigData) { + if data.Qobuz.MobileAppID == "" { + data.Qobuz.MobileAppID = DefaultQobuzMobileAppID + } + if data.Qobuz.MobileAppSecret == "" { + data.Qobuz.MobileAppSecret = DefaultQobuzMobileAppSecret + } + if data.Tidal.AppClientID == "" { + data.Tidal.AppClientID = DefaultTidalAppClientID + } + if data.Tidal.AppClientSecret == "" { + data.Tidal.AppClientSecret = DefaultTidalAppClientSecret + } + if data.Deezer.APIKey == "" { + data.Deezer.APIKey = DefaultDeezerAPIKey + } + if data.Deezer.GatewayKey == "" { + data.Deezer.GatewayKey = DefaultDeezerGatewayKey + } + if data.Deezer.BlowfishSecret == "" { + data.Deezer.BlowfishSecret = DefaultDeezerBlowfishSecret + } + if data.Yandex.SignKey == "" { + data.Yandex.SignKey = DefaultYandexSignKey + } + if data.Yandex.LegacyMP3SignSalt == "" { + data.Yandex.LegacyMP3SignSalt = DefaultYandexLegacyMP3Salt + } + if data.Beatport.AppClientID == "" { + data.Beatport.AppClientID = DefaultBeatportAppClientID + } +} + // qualityRange is the accepted quality id range per source. Qobuz and Beatport // have no "lowest" tier, so their floor is 1. var qualityRange = map[string][2]int{ @@ -426,6 +501,7 @@ func DefaultConfigData() ConfigData { }, } + applyVendorDefaults(&data) return data } @@ -471,6 +547,7 @@ func saveConfigData(path string, data ConfigData) error { } func applyRuntimeDefaults(data *ConfigData) { + applyVendorDefaults(data) home, _ := os.UserHomeDir() appDir := defaultAppDir() if data.Downloads.Folder == "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 28f4e85..8496660 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,6 +1,7 @@ package config import ( + "encoding/hex" "errors" "fmt" "os" @@ -326,3 +327,67 @@ func TestUpdateTokensPersistsAndIsConcurrencySafe(t *testing.T) { t.Error("no token persisted") } } + +// TestVendorDefaultsAreApplied guards the Phase-3 move of vendor credentials +// into config: an empty value must resolve to the built-in constant, or the +// providers silently authenticate and sign with empty keys. +func TestVendorDefaultsAreApplied(t *testing.T) { + d := DefaultConfigData() + checks := map[string]string{ + "qobuz.mobile_app_id": d.Qobuz.MobileAppID, + "qobuz.mobile_app_secret": d.Qobuz.MobileAppSecret, + "tidal.app_client_id": d.Tidal.AppClientID, + "tidal.app_client_secret": d.Tidal.AppClientSecret, + "deezer.api_key": d.Deezer.APIKey, + "deezer.gateway_key": d.Deezer.GatewayKey, + "deezer.blowfish_secret": d.Deezer.BlowfishSecret, + "yandex.sign_key": d.Yandex.SignKey, + "yandex.legacy_sign_salt": d.Yandex.LegacyMP3SignSalt, + "beatport.app_client_id": d.Beatport.AppClientID, + } + for name, got := range checks { + if got == "" { + t.Errorf("%s is empty in DefaultConfigData()", name) + } + } + + // An explicitly empty config must be backfilled too, since that is what a + // user config written before these keys existed looks like. + var blank ConfigData + applyVendorDefaults(&blank) + if blank.Qobuz.MobileAppSecret != DefaultQobuzMobileAppSecret { + t.Error("applyVendorDefaults did not backfill qobuz.mobile_app_secret") + } + if blank.Deezer.BlowfishSecret != DefaultDeezerBlowfishSecret { + t.Error("applyVendorDefaults did not backfill deezer.blowfish_secret") + } +} + +func TestVendorDefaultsAreNotOverriddenWhenSet(t *testing.T) { + d := ConfigData{} + d.Tidal.AppClientID = "mine" + d.Deezer.BlowfishSecret = "0123456789abcdef" + applyVendorDefaults(&d) + if d.Tidal.AppClientID != "mine" { + t.Errorf("user value overwritten: %q", d.Tidal.AppClientID) + } + if d.Deezer.BlowfishSecret != "0123456789abcdef" { + t.Errorf("user value overwritten: %q", d.Deezer.BlowfishSecret) + } +} + +// TestDeezerBlowfishSecretLength pins the length the key derivation depends +// on: it XORs 16 bytes, so a different length would panic or silently derive a +// wrong key. +func TestDeezerBlowfishSecretLength(t *testing.T) { + if got := len(DefaultDeezerBlowfishSecret); got != 16 { + t.Fatalf("DefaultDeezerBlowfishSecret is %d bytes, want 16", got) + } +} + +// TestQobuzMobileAppSecretIsHex pins the format the HKDF step requires. +func TestQobuzMobileAppSecretIsHex(t *testing.T) { + if _, err := hex.DecodeString(DefaultQobuzMobileAppSecret); err != nil { + t.Fatalf("DefaultQobuzMobileAppSecret is not hex: %v", err) + } +} diff --git a/internal/download/downloader.go b/internal/download/downloader.go index 3d503de..ae078f1 100644 --- a/internal/download/downloader.go +++ b/internal/download/downloader.go @@ -21,6 +21,7 @@ import ( "github.com/vbauerster/mpb/v8/decor" "golang.org/x/term" + "streamrip-go/internal/config" "streamrip-go/internal/netutil" "streamrip-go/internal/verbose" @@ -32,6 +33,10 @@ type Downloader struct { showProgress bool progress *mpb.Progress barStarted atomic.Int32 + + // DeezerBlowfishSecret overrides the built-in Deezer protocol secret. + // Set from config by app.New; empty means use the default. + DeezerBlowfishSecret string } // downloadBufferSize sizes HTTP-to-disk copies. 1 MiB cuts read/write syscalls @@ -136,7 +141,7 @@ func (d *Downloader) FileDeezerEncrypted(ctx context.Context, sourceURL, outputP }() } - block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(trackID)) + block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(d.blowfishSecret(), trackID)) if err != nil { return err } @@ -831,10 +836,28 @@ const deezerBFChunkSize = 2048 var deezerBFIV = []byte{0, 1, 2, 3, 4, 5, 6, 7} -func deriveDeezerBlowfishKey(trackID string) []byte { +// defaultDeezerBlowfishSecret aliases the single definition in internal/config +// so the literal is not duplicated here. +const defaultDeezerBlowfishSecret = config.DefaultDeezerBlowfishSecret + +// blowfishSecret returns the configured Deezer blowfish secret. +// +// The key derivation XORs 16 bytes of it, so a secret of any other length +// would panic; fall back to the built-in value rather than crash on a +// mistyped config entry. +func (d *Downloader) blowfishSecret() string { + if len(d.DeezerBlowfishSecret) == 16 { + return d.DeezerBlowfishSecret + } + if d.DeezerBlowfishSecret != "" { + verbose.Printf(verbose.V, "ignoring deezer.blowfish_secret: got %d bytes, want 16\n", len(d.DeezerBlowfishSecret)) + } + return defaultDeezerBlowfishSecret +} + +func deriveDeezerBlowfishKey(secret, trackID string) []byte { sum := md5.Sum([]byte(trackID)) md5Hex := fmt.Sprintf("%x", sum) - secret := "g4el58wc0zvf9na1" key := make([]byte, 16) for i := 0; i < 16; i++ { key[i] = md5Hex[i] ^ md5Hex[i+16] ^ secret[i] diff --git a/internal/download/downloader_test.go b/internal/download/downloader_test.go index c21e4e2..c51f052 100644 --- a/internal/download/downloader_test.go +++ b/internal/download/downloader_test.go @@ -70,7 +70,7 @@ func TestManifestDetection(t *testing.T) { func TestDeezerBlowfishKeyDerivation(t *testing.T) { trackID := "3135556" - key := deriveDeezerBlowfishKey(trackID) + key := deriveDeezerBlowfishKey(defaultDeezerBlowfishSecret, trackID) if len(key) != 16 { t.Fatalf("blowfish key len = %d, want 16", len(key)) } @@ -85,7 +85,7 @@ func TestFileDeezerEncrypted(t *testing.T) { enc := make([]byte, len(plain)) copy(enc, plain) - block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(trackID)) + block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(defaultDeezerBlowfishSecret, trackID)) if err != nil { t.Fatalf("cipher error: %v", err) } diff --git a/internal/provider/beatport/client.go b/internal/provider/beatport/client.go index abcd137..1f16c2c 100644 --- a/internal/provider/beatport/client.go +++ b/internal/provider/beatport/client.go @@ -24,7 +24,6 @@ import ( const ( baseURL = "https://api.beatport.com/v4" - clientID = "ryZ8LuyQVPqbK2mBX2Hwt4qSMtnWuTYSqBPO92yQ" tokenEndpoint = "/auth/o/token/" authEndpoint = "/auth/o/authorize/" loginEndpoint = "/auth/login/" @@ -376,11 +375,11 @@ func (c *Client) loginPasswordFlow(ctx context.Context) error { if err != nil { return err } - return c.issueToken(ctx, map[string]string{"client_id": clientID, "grant_type": "authorization_code", "code": code}) + return c.issueToken(ctx, map[string]string{"client_id": c.appClientID(), "grant_type": "authorization_code", "code": code}) } func (c *Client) authorize(ctx context.Context) (string, error) { - params := url.Values{"client_id": []string{clientID}, "response_type": []string{"code"}} + params := url.Values{"client_id": []string{c.appClientID()}, "response_type": []string{"code"}} resp, status, err := c.rawRequest(ctx, http.MethodGet, authEndpoint, params, nil, "", false) if err != nil { return "", err @@ -397,7 +396,7 @@ func (c *Client) authorize(ctx context.Context) (string, error) { func (c *Client) refresh(ctx context.Context) error { b := &c.cfg.Session.Beatport - return c.issueToken(ctx, map[string]string{"client_id": clientID, "grant_type": "refresh_token", "refresh_token": b.RefreshToken}) + return c.issueToken(ctx, map[string]string{"client_id": c.appClientID(), "grant_type": "refresh_token", "refresh_token": b.RefreshToken}) } func (c *Client) issueToken(ctx context.Context, payload map[string]string) error { @@ -846,3 +845,12 @@ func cloneValues(in url.Values) url.Values { } return out } + +// appClientID reads the vendor OAuth app credential from config, falling back +// to the built-in default. +func (c *Client) appClientID() string { + if v := strings.TrimSpace(c.cfg.Session.Beatport.AppClientID); v != "" { + return v + } + return config.DefaultBeatportAppClientID +} diff --git a/internal/provider/deezer/client.go b/internal/provider/deezer/client.go index 50fd5d0..db2511a 100644 --- a/internal/provider/deezer/client.go +++ b/internal/provider/deezer/client.go @@ -33,8 +33,6 @@ var ( mediaURL = "https://media.deezer.com/v1/get_url" pipeURL = "https://pipe.deezer.com/api" authURL = "https://auth.deezer.com/login/renew" - apiKey = "4VCYIJUCDLOUELGD1V8WBVYBNVDYOXEWSLLZDONGBBDFVXTZJRXPR29JRLQFO6ZE" - gatewayDec = "VBK1FSUEXHTSDBJJ" deezerUAPool = []string{ "Deezer/9.0.11.4 (Android; 14; Mobile; us) Xiaomi Redmi Note 7", "Deezer/9.0.11.4 (Android; 14; Mobile; us) Samsung SM-G991B", @@ -499,7 +497,7 @@ func (c *Client) loginWithCredentials(ctx context.Context, email, password strin if err != nil { return err } - authToken, err := deriveGatewayAuthToken(mobileToken) + authToken, err := deriveGatewayAuthToken(c.gatewayKey(), mobileToken) if err != nil { return err } @@ -509,7 +507,7 @@ func (c *Client) loginWithCredentials(ctx context.Context, email, password strin } c.sid = jsonutil.FirstNonEmpty(c.sid, sid) - encryptedPassword, err := encryptPassword(mobileToken, password) + encryptedPassword, err := encryptPassword(c.gatewayKey(), mobileToken, password) if err != nil { return err } @@ -528,7 +526,7 @@ func (c *Client) loginWithCredentials(ctx context.Context, email, password strin "password": encryptedPassword, } params := url.Values{} - params.Set("api_key", apiKey) + params.Set("api_key", c.apiKey()) params.Set("sid", c.sid) params.Set("method", "mobile_userAuth") params.Set("output", "3") @@ -737,7 +735,7 @@ func (c *Client) bootstrapSID(ctx context.Context) (string, error) { if err != nil { return "", err } - authToken, err := deriveGatewayAuthToken(mobileToken) + authToken, err := deriveGatewayAuthToken(c.gatewayKey(), mobileToken) if err != nil { return "", err } @@ -749,7 +747,7 @@ func (c *Client) mobileAuth(ctx context.Context) (string, error) { return "", err } params := url.Values{} - params.Set("api_key", apiKey) + params.Set("api_key", c.apiKey()) params.Set("output", "3") params.Set("method", "mobile_auth") params.Set("network", randomHexN(32)) @@ -802,7 +800,7 @@ func (c *Client) apiCheckToken(ctx context.Context, authToken string) (string, e return "", err } params := url.Values{} - params.Set("api_key", apiKey) + params.Set("api_key", c.apiKey()) params.Set("method", "api_checkToken") params.Set("auth_token", authToken) params.Set("output", "3") @@ -865,7 +863,7 @@ func (c *Client) mobileUserAutolog(ctx context.Context) error { payload["refresh_token"] = strings.TrimSpace(c.refresh) } params := url.Values{} - params.Set("api_key", apiKey) + params.Set("api_key", c.apiKey()) params.Set("sid", c.sid) params.Set("output", "3") params.Set("input", "3") @@ -1075,8 +1073,10 @@ func (c *Client) refreshLicenseFromPipe(ctx context.Context) error { return nil } -func deriveGatewayAuthToken(mobileToken string) (string, error) { - dec, err := decryptMobileToken(mobileToken) +// deriveGatewayAuthToken and friends take gatewayKey explicitly so the key +// comes from config rather than a package constant. +func deriveGatewayAuthToken(gatewayKey, mobileToken string) (string, error) { + dec, err := decryptMobileToken(gatewayKey, mobileToken) if err != nil { return "", err } @@ -1092,19 +1092,19 @@ func deriveGatewayAuthToken(mobileToken string) (string, error) { return hex.EncodeToString(enc), nil } -func decryptMobileToken(mobileToken string) ([]byte, error) { +func decryptMobileToken(gatewayKey, mobileToken string) ([]byte, error) { b, err := hex.DecodeString(strings.TrimSpace(mobileToken)) if err != nil { return nil, err } - return aesECBDecrypt([]byte(gatewayDec), b) + return aesECBDecrypt([]byte(gatewayKey), b) } -func encryptPassword(mobileToken, password string) (string, error) { +func encryptPassword(gatewayKey, mobileToken, password string) (string, error) { if strings.TrimSpace(password) == "" { return "", errors.New("missing deezer password") } - dec, err := decryptMobileToken(mobileToken) + dec, err := decryptMobileToken(gatewayKey, mobileToken) if err != nil { return "", err } @@ -1513,3 +1513,19 @@ func enrichAlbumImage(meta map[string]any) { "original": cover, } } + +// apiKey and gatewayKey read the vendor protocol keys from config, falling +// back to the built-in defaults. +func (c *Client) apiKey() string { + if v := strings.TrimSpace(c.cfg.Session.Deezer.APIKey); v != "" { + return v + } + return config.DefaultDeezerAPIKey +} + +func (c *Client) gatewayKey() string { + if v := strings.TrimSpace(c.cfg.Session.Deezer.GatewayKey); v != "" { + return v + } + return config.DefaultDeezerGatewayKey +} diff --git a/internal/provider/deezer/client_test.go b/internal/provider/deezer/client_test.go index bbb174d..9938801 100644 --- a/internal/provider/deezer/client_test.go +++ b/internal/provider/deezer/client_test.go @@ -728,7 +728,7 @@ func TestMobileUserAutologIncludesRefreshToken(t *testing.T) { func testMobileToken(t *testing.T) string { t.Helper() plain := []byte(strings.Repeat("A", 64) + strings.Repeat("B", 16) + strings.Repeat("C", 16)) - enc, err := aesECBEncrypt([]byte(gatewayDec), plain) + enc, err := aesECBEncrypt([]byte(config.DefaultDeezerGatewayKey), plain) if err != nil { t.Fatalf("aesECBEncrypt() error = %v", err) } diff --git a/internal/provider/qobuz/client.go b/internal/provider/qobuz/client.go index dbec8f2..2aed919 100644 --- a/internal/provider/qobuz/client.go +++ b/internal/provider/qobuz/client.go @@ -40,8 +40,6 @@ import ( const baseURL = "https://www.qobuz.com/api.json/0.2" const ( - mobileAppID = "312369995" - mobileAppSecret = "e79f8b9be485692b0e5f9dd895826368" mobileUserAgent = "Dalvik/2.1.0 (Linux; U; Android 9; Nexus 6P Build/PQ3A.190801.002) QobuzMobileAndroid/9.7.0.3-b26022717" mobileAppVersion = "9.7.0.3" mobileSessionProf = "qbz-1" @@ -1046,11 +1044,11 @@ func (c *Client) ensureMobileSession(ctx context.Context) error { func (c *Client) mobileLogin(ctx context.Context, username, password string) error { ts := time.Now().Unix() params := url.Values{} - params.Set("app_id", mobileAppID) + params.Set("app_id", c.mobileAppID()) params.Set("username", username) params.Set("password", password) params.Set("request_ts", strconv.FormatInt(ts, 10)) - params.Set("request_sig", mobileSignRequest("oauth2/login", []kv{{"password", password}, {"username", username}}, ts)) + params.Set("request_sig", mobileSignRequest(c.mobileAppSecret(), "oauth2/login", []kv{{"password", password}, {"username", username}}, ts)) reqURL := baseURL + "/oauth2/login?" + params.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) @@ -1088,9 +1086,9 @@ func (c *Client) mobileLogin(ctx context.Context, username, password string) err func (c *Client) mobileStartSession(ctx context.Context) error { ts := time.Now().Unix() params := url.Values{} - params.Set("app_id", mobileAppID) + params.Set("app_id", c.mobileAppID()) params.Set("request_ts", strconv.FormatInt(ts, 10)) - params.Set("request_sig", mobileSignRequest("session/start", []kv{{"profile", mobileSessionProf}}, ts)) + params.Set("request_sig", mobileSignRequest(c.mobileAppSecret(), "session/start", []kv{{"profile", mobileSessionProf}}, ts)) reqURL := baseURL + "/session/start?" + params.Encode() form := url.Values{} @@ -1132,12 +1130,12 @@ func (c *Client) mobileStartSession(ctx context.Context) error { func (c *Client) mobileGetFileURL(ctx context.Context, trackID string, formatID int) (*mobileFileURL, error) { ts := time.Now().Unix() params := url.Values{} - params.Set("app_id", mobileAppID) + params.Set("app_id", c.mobileAppID()) params.Set("track_id", trackID) params.Set("format_id", strconv.Itoa(formatID)) params.Set("intent", "stream") params.Set("request_ts", strconv.FormatInt(ts, 10)) - params.Set("request_sig", mobileSignRequest("file/url", []kv{{"format_id", strconv.Itoa(formatID)}, {"intent", "stream"}, {"track_id", trackID}}, ts)) + params.Set("request_sig", mobileSignRequest(c.mobileAppSecret(), "file/url", []kv{{"format_id", strconv.Itoa(formatID)}, {"intent", "stream"}, {"track_id", trackID}}, ts)) reqURL := baseURL + "/file/url?" + params.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) if err != nil { @@ -1259,7 +1257,7 @@ func (c *Client) mobileDownloadSegment(ctx context.Context, sourceURL string) ([ func (c *Client) setMobileHeaders(req *http.Request) { req.Header.Set("User-Agent", mobileUserAgent) - req.Header.Set("X-App-Id", mobileAppID) + req.Header.Set("X-App-Id", c.mobileAppID()) req.Header.Set("X-App-Version", mobileAppVersion) req.Header.Set("X-Device-Platform", "android") req.Header.Set("X-Device-Model", "Nexus 6P") @@ -1272,7 +1270,9 @@ func (c *Client) setMobileHeaders(req *http.Request) { } } -func mobileSignRequest(endpoint string, params []kv, ts int64) string { +// mobileSignRequest signs a mobile API request. appSecret comes from config +// (Client.mobileAppSecret) so a rotation can be fixed without a rebuild. +func mobileSignRequest(appSecret, endpoint string, params []kv, ts int64) string { method := strings.ReplaceAll(endpoint, "/", "") sortKVs(params) var sb strings.Builder @@ -1282,7 +1282,7 @@ func mobileSignRequest(endpoint string, params []kv, ts int64) string { sb.WriteString(p.Value) } sb.WriteString(strconv.FormatInt(ts, 10)) - sb.WriteString(mobileAppSecret) + sb.WriteString(appSecret) h := md5.Sum([]byte(sb.String())) return hex.EncodeToString(h[:]) } @@ -1321,7 +1321,7 @@ func (c *Client) mobileDeriveTrackKey(encryptedKey string) ([]byte, error) { // A non-hex secret used to be swallowed and HKDF keyed on nil, which // derives a wrong KEK and yields silently corrupt audio rather than an // error. - secretKey, err := hex.DecodeString(mobileAppSecret) + secretKey, err := hex.DecodeString(c.mobileAppSecret()) if err != nil { return nil, fmt.Errorf("decode mobile app secret: %w", err) } @@ -1491,3 +1491,19 @@ func readU24BE(data []byte, off int) uint32 { } return uint32(data[off])<<16 | uint32(data[off+1])<<8 | uint32(data[off+2]) } + +// mobileAppID and mobileAppSecret read the vendor mobile app credentials from +// config, falling back to the built-in defaults. +func (c *Client) mobileAppID() string { + if v := strings.TrimSpace(c.cfg.Session.Qobuz.MobileAppID); v != "" { + return v + } + return config.DefaultQobuzMobileAppID +} + +func (c *Client) mobileAppSecret() string { + if v := strings.TrimSpace(c.cfg.Session.Qobuz.MobileAppSecret); v != "" { + return v + } + return config.DefaultQobuzMobileAppSecret +} diff --git a/internal/provider/tidal/client.go b/internal/provider/tidal/client.go index 666f393..33709b9 100644 --- a/internal/provider/tidal/client.go +++ b/internal/provider/tidal/client.go @@ -28,8 +28,6 @@ const ( lyricsAPIv1 = "https://api.tidal.com/v1" openAPIV2 = "https://openapi.tidal.com/v2" authURL = "https://auth.tidal.com/v1/oauth2" - clientID = "fX2JxdmntZWK0ixT" - clientSec = "1Nm5AfDAjxrgJFJbKNWLeAyKGVGmINuXPPLHVXAvxAg=" tidalRequestAttempts = 3 ) @@ -146,7 +144,7 @@ func (c *Client) refreshAccessToken(ctx context.Context) error { } form := url.Values{} - form.Set("client_id", clientID) + form.Set("client_id", c.appClientID()) form.Set("refresh_token", refresh) form.Set("grant_type", "refresh_token") form.Set("scope", "r_usr+w_usr+w_sub") @@ -972,7 +970,7 @@ func (c *Client) apiPost(ctx context.Context, endpoint string, form url.Values, req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("User-Agent", "streamrip-go/0.1") if basicAuth { - auth := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSec)) + auth := base64.StdEncoding.EncodeToString([]byte(c.appClientID() + ":" + c.appClientSecret())) req.Header.Set("Authorization", "Basic "+auth) } return req, nil @@ -1004,3 +1002,20 @@ func tidalImageMap(cover string) map[string]any { "original": base + "/1280x1280.jpg", } } + +// appClientID and appClientSecret read the vendor OAuth app credentials from +// config, falling back to the built-in defaults. Config is the source of truth +// so a rotation can be fixed without a rebuild. +func (c *Client) appClientID() string { + if v := strings.TrimSpace(c.cfg.Session.Tidal.AppClientID); v != "" { + return v + } + return config.DefaultTidalAppClientID +} + +func (c *Client) appClientSecret() string { + if v := strings.TrimSpace(c.cfg.Session.Tidal.AppClientSecret); v != "" { + return v + } + return config.DefaultTidalAppClientSecret +} diff --git a/internal/provider/yandex/client.go b/internal/provider/yandex/client.go index 2da05e3..0f1b816 100644 --- a/internal/provider/yandex/client.go +++ b/internal/provider/yandex/client.go @@ -30,12 +30,10 @@ import ( ) const ( - baseURL = "https://api.music.yandex.net" - desktopClientHeader = "YandexMusicDesktopAppWindows/5.13.2" - desktopOrigin = "music-application://desktop" - requestAttempts = 3 - desktopWindowsSignKey = "kzqU4XhfCaY6B6JTHODeq5" - legacyMP3SignSalt = "XGRlBW9FXlekgbPrRHuSiA" + baseURL = "https://api.music.yandex.net" + desktopClientHeader = "YandexMusicDesktopAppWindows/5.13.2" + desktopOrigin = "music-application://desktop" + requestAttempts = 3 ) var ErrMissingYandexToken = errors.New("missing yandex access_token") @@ -345,7 +343,7 @@ func (c *Client) getLegacyDownloadable(ctx context.Context, trackID string) (*pr if err != nil { return nil, err } - directURL, err := legacyDirectURL(xmlInfo) + directURL, err := legacyDirectURL(c.legacyMP3Salt(), xmlInfo) if err != nil { return nil, err } @@ -366,7 +364,7 @@ func (c *Client) getLegacyDownloadable(ctx context.Context, trackID string) (*pr func (c *Client) getDesktopDownloadable(ctx context.Context, trackID string, quality int, transport string) (*provider.Downloadable, error) { qualityParam, codecs := downloadRequestProfile(quality) - sign, ts := yandexDownloadSign(trackID, qualityParam, codecs, transport) + sign, ts := yandexDownloadSign(c.signKey(), trackID, qualityParam, codecs, transport) params := url.Values{} params.Set("trackId", trackID) params.Set("codecs", strings.Join(codecs, ",")) @@ -432,11 +430,11 @@ func (c *Client) fetchLegacyXML(ctx context.Context, xmlURL string) (*legacyDown return &out, nil } -func legacyDirectURL(info *legacyDownloadInfoXML) (string, error) { +func legacyDirectURL(salt string, info *legacyDownloadInfoXML) (string, error) { if info == nil || info.Host == "" || info.Path == "" || info.TS == "" || info.S == "" { return "", errors.New("invalid yandex legacy xml payload") } - sum := md5.Sum([]byte(legacyMP3SignSalt + strings.TrimPrefix(info.Path, "/") + info.S)) + sum := md5.Sum([]byte(salt + strings.TrimPrefix(info.Path, "/") + info.S)) return "https://" + info.Host + "/get-mp3/" + hex.EncodeToString(sum[:]) + "/" + info.TS + info.Path, nil } @@ -944,9 +942,11 @@ func isExplicit(raw map[string]any) bool { return jsonutil.BoolFromAny(raw["explicit"]) } -func yandexDownloadSign(trackID, quality string, codecs []string, transport string) (string, int64) { +// yandexDownloadSign signs a download request. signKey comes from config +// (Client.signKey) so a rotation can be fixed without a rebuild. +func yandexDownloadSign(signKey, trackID, quality string, codecs []string, transport string) (string, int64) { ts := time.Now().Unix() - mac := hmac.New(sha256.New, []byte(desktopWindowsSignKey)) + mac := hmac.New(sha256.New, []byte(signKey)) _, _ = mac.Write([]byte(strconv.FormatInt(ts, 10) + trackID + quality + strings.Join(codecs, "") + transport)) return strings.TrimRight(base64.StdEncoding.EncodeToString(mac.Sum(nil)), "="), ts } @@ -1027,3 +1027,19 @@ func firstPositiveInt(vals ...int) int { } return 0 } + +// signKey and legacyMP3Salt read the vendor request-signing keys from config, +// falling back to the built-in defaults. +func (c *Client) signKey() string { + if v := strings.TrimSpace(c.cfg.Session.Yandex.SignKey); v != "" { + return v + } + return config.DefaultYandexSignKey +} + +func (c *Client) legacyMP3Salt() string { + if v := strings.TrimSpace(c.cfg.Session.Yandex.LegacyMP3SignSalt); v != "" { + return v + } + return config.DefaultYandexLegacyMP3Salt +} diff --git a/internal/provider/yandex/client_test.go b/internal/provider/yandex/client_test.go index 7d74adc..1139a97 100644 --- a/internal/provider/yandex/client_test.go +++ b/internal/provider/yandex/client_test.go @@ -15,7 +15,7 @@ import ( ) func TestYandexDownloadSignMatchesCapturedFormat(t *testing.T) { - sign, ts := yandexDownloadSign("32038184", "lossless", []string{"flac", "aac", "he-aac", "mp3", "flac-mp4", "aac-mp4", "he-aac-mp4"}, "raw") + sign, ts := yandexDownloadSign(config.DefaultYandexSignKey, "32038184", "lossless", []string{"flac", "aac", "he-aac", "mp3", "flac-mp4", "aac-mp4", "he-aac-mp4"}, "raw") if ts <= 0 { t.Fatalf("timestamp = %d", ts) } @@ -168,7 +168,7 @@ func TestGetMetadataTrackUsesModernTracksEndpoint(t *testing.T) { } func TestLegacyDirectURLBuildsPlayableMP3URL(t *testing.T) { - url, err := legacyDirectURL(&legacyDownloadInfoXML{ + url, err := legacyDirectURL(config.DefaultYandexLegacyMP3Salt, &legacyDownloadInfoXML{ Host: "example.test", Path: "/abc123", TS: "1234567890",