package deezer import ( "bytes" "context" "crypto/aes" "encoding/hex" "encoding/json" "errors" "fmt" "io" "math/rand/v2" "net/http" "net/url" "os" "strings" "time" ) const ( gatewayBaseURL = "https://api.deezer.com/1.0/gateway.php" gatewayUserAgent = "Deezer/6.1.22.49 (Android; 9; Tablet; us) innotek GmbH VirtualBox" // nonceAlphabet omits '7' and 'c' on purpose. It mirrors the alphabet the // Android client uses to build its uniq_id, and the gateway is picky about // the shape of that value, so this must not be "completed" into a full hex // alphabet. nonceAlphabet = "012345689abdef" ) // The gateway only answers requests that look like they come from the Android // app, so these describe a plausible device. They are deliberately generic // rather than derived from the user's real machine: nothing here should // identify the person running godeez. const ( deviceOS = "Android" deviceName = "VirtualBox" deviceType = "tablet" deviceModel = "VirtualBox" devicePlatform = "innotek GmbH_x86_64_9" deviceSerial = "" ) type mobileClient struct { httpClient *http.Client apiKey string gwKey []byte sid string } // CheckGatewayEnv reports whether the mobile gateway keys are present and // well formed. It exists so the login command can fail immediately with a // clear message instead of prompting for a password it cannot use. func CheckGatewayEnv() error { _, _, err := gatewayEnv() return err } // gatewayEnv reads the two mobile gateway keys from the environment. // // They are not shipped with godeez: they are Deezer's, and baking them into a // public repository would be both a licensing problem and a fast route to // having them revoked. Users who want email and password login supply their // own, which is why this is the one feature gated behind environment // variables. The gateway key doubles as an AES key, hence the exact length // requirement. func gatewayEnv() (string, string, error) { apiKey := os.Getenv("DEEZER_MOBILE_API_KEY") gwKey := os.Getenv("DEEZER_MOBILE_GW_KEY") if apiKey == "" || gwKey == "" { return "", "", errors.New("DEEZER_MOBILE_API_KEY and DEEZER_MOBILE_GW_KEY must be set to use email/password login") } if len(gwKey) != aes.BlockSize { return "", "", fmt.Errorf("DEEZER_MOBILE_GW_KEY must be exactly %d bytes long", aes.BlockSize) } return apiKey, gwKey, nil } func newMobileClient() (*mobileClient, error) { apiKey, gwKey, err := gatewayEnv() if err != nil { return nil, err } return &mobileClient{ httpClient: &http.Client{Timeout: 20 * time.Second}, apiKey: apiKey, gwKey: []byte(gwKey), }, nil } // login runs the three step mobile handshake and returns the resulting // credentials plus the account's display name. // // The steps are ordered and stateful, so none of them can be skipped or // reordered: authenticate yields a token and two one-shot AES keys, // checkToken trades the token for a session id that gatewayRequest then // attaches to every later call, and only then will userAuth accept the // encrypted password and return an ARL. func (m *mobileClient) login(ctx context.Context, email, password string) (*Credentials, string, error) { token, tokenKey, userKey, err := m.authenticate(ctx) if err != nil { return nil, "", err } if err := m.checkToken(ctx, token, tokenKey); err != nil { return nil, "", err } arl, username, err := m.userAuth(ctx, email, password, userKey) if err != nil { return nil, "", err } return &Credentials{Email: email, Password: password, ARL: arl}, username, nil } // authenticate performs the first handshake step and returns the session // token, the key used to sign it back in checkToken, and the key used to // encrypt the password in userAuth. // // The gateway packs all three into one hex blob encrypted under the gateway // key, at fixed offsets: 64 bytes of token, then two 16 byte keys. The length // check guards against a short or error response being sliced blindly. // // Errors arrive with a 200 status and are only visible as markers in the // body, so they are matched as strings. func (m *mobileClient) authenticate(ctx context.Context) (string, string, string, error) { body, err := m.gatewayRequest(ctx, "mobile_auth", http.MethodGet, "uniq_id", genUniqID(), nil) if err != nil { return "", "", "", err } var res struct { Results struct { Token string `json:"TOKEN"` } `json:"results"` } if err := json.Unmarshal(body, &res); err != nil { return "", "", "", err } if strings.Contains(string(body), "Undefined or invalid API key") { return "", "", "", errors.New("DEEZER_MOBILE_API_KEY is invalid") } if strings.Contains(string(body), "GATEWAY_ERROR") || res.Results.Token == "" { return "", "", "", errors.New("unexpected response from gateway") } encrypted, err := hex.DecodeString(res.Results.Token) if err != nil { return "", "", "", err } decrypted, err := ecbDecrypt(m.gwKey, encrypted) if err != nil { return "", "", "", err } if len(decrypted) < 96 { return "", "", "", errors.New("unexpected response from gateway") } token := string(decrypted[0:64]) tokenKey := string(decrypted[64:80]) userKey := string(decrypted[80:96]) return token, tokenKey, userKey, nil } // checkToken proves possession of the token by returning it encrypted under // tokenKey, and stores the session id the gateway hands back. Every // subsequent request carries that id, so userAuth fails without this step. func (m *mobileClient) checkToken(ctx context.Context, token, tokenKey string) error { encrypted, err := ecbEncrypt([]byte(tokenKey), []byte(token)) if err != nil { return err } authToken := hex.EncodeToString(encrypted) body, err := m.gatewayRequest(ctx, "api_checkToken", http.MethodGet, "auth_token", authToken, nil) if err != nil { return err } var res struct { Results string `json:"results"` } if err := json.Unmarshal(body, &res); err != nil { return err } if res.Results == "" { return errors.New("unexpected response from gateway") } m.sid = res.Results return nil } // userAuth exchanges the user's credentials for an ARL cookie and returns it // along with the account's display name. // // The password is sent AES-ECB encrypted under userKey rather than in the // clear. The empty and constant fields in the payload are not padding: the // gateway rejects the request outright if any of them are missing. func (m *mobileClient) userAuth(ctx context.Context, email, password, userKey string) (string, string, error) { encryptedPassword, err := ecbEncrypt([]byte(userKey), zeroPad([]byte(password))) if err != nil { return "", "", err } payload := map[string]string{ "mail": email, "password": hex.EncodeToString(encryptedPassword), "device_serial": deviceSerial, "platform": devicePlatform, "custo_version_id": "", "custo_partner": "", "model": deviceModel, "device_name": deviceName, "device_os": deviceOS, "device_type": deviceType, "google_play_services_availability": "1", "consent_string": "", } jsonBody, err := json.Marshal(payload) if err != nil { return "", "", err } body, err := m.gatewayRequest(ctx, "mobile_userAuth", http.MethodPost, "", "", jsonBody) if err != nil { return "", "", err } if strings.Contains(string(body), "USER_AUTH_ERROR") { return "", "", errors.New("invalid email or password") } var res struct { Results struct { ARL string `json:"ARL"` UserID int `json:"USER_ID"` BlogName string `json:"BLOG_NAME"` } `json:"results"` } if err := json.Unmarshal(body, &res); err != nil { return "", "", err } if res.Results.ARL == "" || res.Results.UserID == 0 { return "", "", errors.New("unexpected response from gateway") } return res.Results.ARL, res.Results.BlogName, nil } func (m *mobileClient) gatewayRequest(ctx context.Context, method, httpMethod, paramKey, paramValue string, jsonBody []byte) ([]byte, error) { u, err := url.Parse(gatewayBaseURL) if err != nil { return nil, err } q := u.Query() q.Set("method", method) q.Set("api_key", m.apiKey) q.Set("output", "3") if httpMethod == http.MethodPost { q.Set("input", "3") } if m.sid != "" { q.Set("sid", m.sid) } if paramKey != "" { q.Set(paramKey, paramValue) } u.RawQuery = q.Encode() var reqBody io.Reader if jsonBody != nil { reqBody = bytes.NewReader(jsonBody) } req, err := http.NewRequestWithContext(ctx, httpMethod, u.String(), reqBody) if err != nil { return nil, err } req.Header.Set("User-Agent", gatewayUserAgent) if jsonBody != nil { req.Header.Set("Content-Type", "application/json") } resp, err := m.httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } return io.ReadAll(resp.Body) } // genUniqID builds the 32 character device identifier sent with the first // handshake request. It is regenerated per login on purpose, so that repeated // logins are not linkable to one another by a stable device id. func genUniqID() string { b := make([]byte, 32) for i := range b { b[i] = nonceAlphabet[rand.IntN(len(nonceAlphabet))] } return string(b) }