refactor: fold auth and crypto into deezer domain

This commit is contained in:
Mathis Maquenne
2026-08-05 11:40:14 +02:00
parent 16c71c8688
commit dbd7837b17
11 changed files with 60 additions and 72 deletions
+39
View File
@@ -0,0 +1,39 @@
package deezer
import (
"crypto/cipher"
"crypto/md5"
"encoding/hex"
"golang.org/x/crypto/blowfish"
)
var (
blowfishIV = []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}
blowfishSecretKey = []byte("g4el58wc0zvf9na1")
)
func BlowfishKey(trackID string) []byte {
hash := md5.Sum([]byte(trackID))
hashHex := hex.EncodeToString(hash[:])
key := make([]byte, len(blowfishSecretKey))
copy(key, blowfishSecretKey)
for i := range len(hash) {
key[i] = key[i] ^ hashHex[i] ^ hashHex[i+16]
}
return key
}
func DecryptBlowfish(data, key []byte) ([]byte, error) {
block, err := blowfish.NewCipher(key)
if err != nil {
return nil, err
}
decrypted := make([]byte, len(data))
cipher.NewCBCDecrypter(block, blowfishIV).CryptBlocks(decrypted, data)
return decrypted, nil
}
+8 -11
View File
@@ -8,17 +8,14 @@ import (
"io"
"net/http"
"strings"
"github.com/mathismqn/godeez/internal/auth"
"github.com/mathismqn/godeez/internal/config"
)
type Client struct {
Session *Session
}
func NewClient(ctx context.Context, appConfig *config.Config) (*Client, error) {
session, err := resolveSession(ctx, appConfig)
func NewClient(ctx context.Context, arlCookie string) (*Client, error) {
session, err := resolveSession(ctx, arlCookie)
if err != nil {
return nil, fmt.Errorf("failed to authenticate: %w", err)
}
@@ -28,14 +25,14 @@ func NewClient(ctx context.Context, appConfig *config.Config) (*Client, error) {
}, nil
}
func resolveSession(ctx context.Context, appConfig *config.Config) (*Session, error) {
if appConfig.ARLCookie != "" {
return Authenticate(ctx, appConfig.ARLCookie)
func resolveSession(ctx context.Context, arlCookie string) (*Session, error) {
if arlCookie != "" {
return authenticate(ctx, arlCookie)
}
var session *Session
validate := func(ctx context.Context, arl string) error {
s, err := Authenticate(ctx, arl)
s, err := authenticate(ctx, arl)
if err != nil {
return err
}
@@ -44,13 +41,13 @@ func resolveSession(ctx context.Context, appConfig *config.Config) (*Session, er
return nil
}
arl, err := auth.Resolve(ctx, validate)
arl, err := resolveARL(ctx, validate)
if err != nil {
return nil, err
}
if session == nil {
session, err = Authenticate(ctx, arl)
session, err = authenticate(ctx, arl)
if err != nil {
return nil, err
}
+61
View File
@@ -0,0 +1,61 @@
package deezer
import (
"encoding/json"
"errors"
"fmt"
"github.com/zalando/go-keyring"
)
const (
keyringService = "godeez"
keyringUser = "default"
)
type Credentials struct {
Email string `json:"email"`
Password string `json:"password"`
ARL string `json:"arl,omitempty"`
}
func LoadCredentials() (*Credentials, error) {
secret, err := keyring.Get(keyringService, keyringUser)
if err != nil {
if errors.Is(err, keyring.ErrNotFound) {
return nil, nil
}
return nil, fmt.Errorf("system keyring is unavailable: %v", err)
}
var creds Credentials
if err := json.Unmarshal([]byte(secret), &creds); err != nil {
return nil, err
}
return &creds, nil
}
func SaveCredentials(creds *Credentials) error {
data, err := json.Marshal(creds)
if err != nil {
return err
}
if err := keyring.Set(keyringService, keyringUser, string(data)); err != nil {
return fmt.Errorf("system keyring is unavailable: %v", err)
}
return nil
}
func ClearCredentials() error {
if err := keyring.Delete(keyringService, keyringUser); err != nil {
if errors.Is(err, keyring.ErrNotFound) {
return nil
}
return fmt.Errorf("system keyring is unavailable: %v", err)
}
return nil
}
+42
View File
@@ -0,0 +1,42 @@
package deezer
import (
"crypto/aes"
"crypto/cipher"
"fmt"
)
func zeroPad(data []byte) []byte {
bs := aes.BlockSize
padded := make([]byte, len(data)+(bs-len(data)%bs)%bs)
copy(padded, data)
return padded
}
func ecbEncrypt(key, data []byte) ([]byte, error) {
return ecbTransform(key, data, (cipher.Block).Encrypt)
}
func ecbDecrypt(key, data []byte) ([]byte, error) {
return ecbTransform(key, data, (cipher.Block).Decrypt)
}
func ecbTransform(key, data []byte, op func(cipher.Block, []byte, []byte)) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
bs := block.BlockSize()
if len(data)%bs != 0 {
return nil, fmt.Errorf("data length %d is not a multiple of the AES block size", len(data))
}
out := make([]byte, len(data))
for i := 0; i < len(data); i += bs {
op(block, out[i:i+bs], data[i:i+bs])
}
return out, nil
}
+264
View File
@@ -0,0 +1,264 @@
package deezer
import (
"bytes"
"context"
"crypto/aes"
"encoding/hex"
"encoding/json"
"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 = "012345689abdef"
)
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
}
func CheckGatewayEnv() error {
_, _, err := gatewayEnv()
return err
}
func gatewayEnv() (string, string, error) {
apiKey := os.Getenv("DEEZER_MOBILE_API_KEY")
gwKey := os.Getenv("DEEZER_MOBILE_GW_KEY")
if apiKey == "" || gwKey == "" {
return "", "", fmt.Errorf("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
}
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
}
func (m *mobileClient) authenticate(ctx context.Context) (string, string, string, error) {
body, err := m.gatewayRequest(ctx, "mobile_auth", "GET", "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 "", "", "", fmt.Errorf("DEEZER_MOBILE_API_KEY is invalid")
}
if strings.Contains(string(body), "GATEWAY_ERROR") || res.Results.Token == "" {
return "", "", "", fmt.Errorf("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
}
token := string(decrypted[0:64])
tokenKey := string(decrypted[64:80])
userKey := string(decrypted[80:96])
return token, tokenKey, userKey, nil
}
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", "GET", "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 fmt.Errorf("unexpected response from gateway")
}
m.sid = res.Results
return nil
}
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", "POST", "", "", jsonBody)
if err != nil {
return "", "", err
}
if strings.Contains(string(body), "USER_AUTH_ERROR") {
return "", "", fmt.Errorf("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 "", "", fmt.Errorf("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 == "POST" {
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)
}
func genUniqID() string {
b := make([]byte, 32)
for i := range b {
b[i] = nonceAlphabet[rand.IntN(len(nonceAlphabet))]
}
return string(b)
}
+48
View File
@@ -0,0 +1,48 @@
package deezer
import (
"context"
"fmt"
)
func resolveARL(ctx context.Context, validate func(ctx context.Context, arl string) error) (string, error) {
creds, err := LoadCredentials()
if err != nil {
return "", err
}
if creds != nil && creds.ARL != "" {
if verr := validate(ctx, creds.ARL); verr == nil {
return creds.ARL, nil
}
}
if creds != nil && creds.Email != "" && creds.Password != "" {
arl, _, err := Login(ctx, creds.Email, creds.Password)
if err != nil {
return "", fmt.Errorf("stored session expired and could not be renewed automatically: %w", err)
}
return arl, nil
}
return "", fmt.Errorf("run 'godeez login' or export DEEZER_ARL environment variable")
}
func Login(ctx context.Context, email, password string) (string, string, error) {
client, err := newMobileClient()
if err != nil {
return "", "", err
}
creds, username, err := client.login(ctx, email, password)
if err != nil {
return "", "", err
}
if err := SaveCredentials(creds); err != nil {
return "", "", err
}
return creds.ARL, username, nil
}
+1 -1
View File
@@ -17,7 +17,7 @@ type Session struct {
Premium bool
}
func Authenticate(ctx context.Context, arlCookie string) (*Session, error) {
func authenticate(ctx context.Context, arlCookie string) (*Session, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err