.unified totp
This commit is contained in:
+7
-178
@@ -1,9 +1,6 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -20,16 +17,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
spotifyServerTimeURL = "https://open.spotify.com/api/server-time"
|
||||
spotifySessionTokenURL = "https://open.spotify.com/api/token"
|
||||
spotifyTOTPSecretsURL = "https://git.gay/thereallo/totp-secrets/raw/branch/main/secrets/secretDict.json"
|
||||
spotifyGIDMetadataURL = "https://spclient.wg.spotify.com/metadata/4/%s/%s?market=from_token"
|
||||
spotifyTOTPPeriod = 30
|
||||
spotifyTOTPDigits = 6
|
||||
spotifyBase62Alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
spotifyTokenCacheFile = ".isrc-finder-token.json"
|
||||
spotifySecretsCacheFile = "spotify-secret-dict-cache.json"
|
||||
spotifySecretsCacheTTL = 24 * time.Hour
|
||||
spotifySessionTokenURL = "https://open.spotify.com/api/token"
|
||||
spotifyGIDMetadataURL = "https://spclient.wg.spotify.com/metadata/4/%s/%s?market=from_token"
|
||||
spotifyBase62Alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
spotifyTokenCacheFile = ".isrc-finder-token.json"
|
||||
)
|
||||
|
||||
var spotifyAnonymousTokenMu sync.Mutex
|
||||
@@ -39,15 +30,6 @@ type spotifyAnonymousToken struct {
|
||||
AccessTokenExpirationTimestampMs int64 `json:"accessTokenExpirationTimestampMs"`
|
||||
}
|
||||
|
||||
type spotifyServerTimeResponse struct {
|
||||
ServerTime int64 `json:"serverTime"`
|
||||
}
|
||||
|
||||
type spotifySecretsCache struct {
|
||||
FetchedAtUnix int64 `json:"fetched_at_unix"`
|
||||
Secrets map[string][]int `json:"secrets"`
|
||||
}
|
||||
|
||||
type spotifyTrackRawData struct {
|
||||
Album struct {
|
||||
GID string `json:"gid"`
|
||||
@@ -361,50 +343,6 @@ func saveSpotifyCachedToken(token *spotifyAnonymousToken) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadSpotifyCachedSecrets() (*spotifySecretsCache, error) {
|
||||
cachePath, err := spotifySecretsCachePath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := os.ReadFile(cachePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read secrets cache: %w", err)
|
||||
}
|
||||
|
||||
var cache spotifySecretsCache
|
||||
if err := json.Unmarshal(body, &cache); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse secrets cache: %w", err)
|
||||
}
|
||||
|
||||
return &cache, nil
|
||||
}
|
||||
|
||||
func saveSpotifyCachedSecrets(cache *spotifySecretsCache) error {
|
||||
cachePath, err := spotifySecretsCachePath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create secrets cache directory: %w", err)
|
||||
}
|
||||
|
||||
body, err := json.MarshalIndent(cache, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(cachePath, body, 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write secrets cache: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func spotifyTokenCachePath() (string, error) {
|
||||
appDir, err := EnsureAppDir()
|
||||
if err != nil {
|
||||
@@ -414,15 +352,6 @@ func spotifyTokenCachePath() (string, error) {
|
||||
return filepath.Join(appDir, spotifyTokenCacheFile), nil
|
||||
}
|
||||
|
||||
func spotifySecretsCachePath() (string, error) {
|
||||
appDir, err := EnsureAppDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filepath.Join(appDir, spotifySecretsCacheFile), nil
|
||||
}
|
||||
|
||||
func spotifyTokenIsValid(token *spotifyAnonymousToken) bool {
|
||||
if token == nil || token.AccessToken == "" || token.AccessTokenExpirationTimestampMs == 0 {
|
||||
return false
|
||||
@@ -431,47 +360,6 @@ func spotifyTokenIsValid(token *spotifyAnonymousToken) bool {
|
||||
return time.Now().UnixMilli() < token.AccessTokenExpirationTimestampMs-30_000
|
||||
}
|
||||
|
||||
func spotifySecretsCacheIsValid(cache *spotifySecretsCache) bool {
|
||||
if cache == nil || cache.FetchedAtUnix == 0 || len(cache.Secrets) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return time.Since(time.Unix(cache.FetchedAtUnix, 0)) < spotifySecretsCacheTTL
|
||||
}
|
||||
|
||||
func deriveSpotifyTOTPSecret(ciphertext []int) []byte {
|
||||
var builder strings.Builder
|
||||
|
||||
for index, value := range ciphertext {
|
||||
builder.WriteString(strconv.Itoa(value ^ ((index % 33) + 9)))
|
||||
}
|
||||
|
||||
return []byte(builder.String())
|
||||
}
|
||||
|
||||
func generateSpotifyTOTP(secret []byte, timestampMs int64) string {
|
||||
counter := timestampMs / 1000 / spotifyTOTPPeriod
|
||||
counterBytes := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(counterBytes, uint64(counter))
|
||||
|
||||
mac := hmac.New(sha1.New, secret)
|
||||
mac.Write(counterBytes)
|
||||
digest := mac.Sum(nil)
|
||||
|
||||
offset := digest[len(digest)-1] & 0x0f
|
||||
binaryCode := (int(digest[offset])&0x7f)<<24 |
|
||||
(int(digest[offset+1])&0xff)<<16 |
|
||||
(int(digest[offset+2])&0xff)<<8 |
|
||||
(int(digest[offset+3]) & 0xff)
|
||||
|
||||
modulo := 1
|
||||
for i := 0; i < spotifyTOTPDigits; i++ {
|
||||
modulo *= 10
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%0*d", spotifyTOTPDigits, binaryCode%modulo)
|
||||
}
|
||||
|
||||
func requestSpotifyAnonymousAccessToken(client *http.Client) (string, error) {
|
||||
spotifyAnonymousTokenMu.Lock()
|
||||
defer spotifyAnonymousTokenMu.Unlock()
|
||||
@@ -485,52 +373,17 @@ func requestSpotifyAnonymousAccessToken(client *http.Client) (string, error) {
|
||||
return cachedToken.AccessToken, nil
|
||||
}
|
||||
|
||||
var serverTime spotifyServerTimeResponse
|
||||
if err := requestSpotifyJSON(client, spotifyServerTimeURL, nil, &serverTime); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var secrets map[string][]int
|
||||
cachedSecrets, err := loadSpotifyCachedSecrets()
|
||||
generatedTOTP, version, err := generateSpotifyTOTP(time.Now())
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to read Spotify secrets cache: %v\n", err)
|
||||
return "", fmt.Errorf("failed to generate Spotify TOTP: %w", err)
|
||||
}
|
||||
|
||||
if spotifySecretsCacheIsValid(cachedSecrets) {
|
||||
secrets = cachedSecrets.Secrets
|
||||
} else {
|
||||
if err := requestSpotifyJSON(client, spotifyTOTPSecretsURL, nil, &secrets); err != nil {
|
||||
if cachedSecrets != nil && len(cachedSecrets.Secrets) > 0 {
|
||||
fmt.Printf("Warning: failed to refresh Spotify secrets cache, using stale cache: %v\n", err)
|
||||
secrets = cachedSecrets.Secrets
|
||||
} else {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
cache := &spotifySecretsCache{
|
||||
FetchedAtUnix: time.Now().Unix(),
|
||||
Secrets: secrets,
|
||||
}
|
||||
if err := saveSpotifyCachedSecrets(cache); err != nil {
|
||||
fmt.Printf("Warning: failed to write Spotify secrets cache: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
version, err := latestSpotifySecretVersion(secrets)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
secret := deriveSpotifyTOTPSecret(secrets[version])
|
||||
generatedTOTP := generateSpotifyTOTP(secret, serverTime.ServerTime*1000)
|
||||
|
||||
query := url.Values{
|
||||
"reason": {"init"},
|
||||
"productType": {"web-player"},
|
||||
"totp": {generatedTOTP},
|
||||
"totpServer": {generatedTOTP},
|
||||
"totpVer": {version},
|
||||
"totpVer": {strconv.Itoa(version)},
|
||||
}
|
||||
|
||||
var token spotifyAnonymousToken
|
||||
@@ -545,30 +398,6 @@ func requestSpotifyAnonymousAccessToken(client *http.Client) (string, error) {
|
||||
return token.AccessToken, nil
|
||||
}
|
||||
|
||||
func latestSpotifySecretVersion(secrets map[string][]int) (string, error) {
|
||||
var (
|
||||
bestVersion string
|
||||
bestNumber int
|
||||
)
|
||||
|
||||
for version := range secrets {
|
||||
number, err := strconv.Atoi(version)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid secret version %q: %w", version, err)
|
||||
}
|
||||
if bestVersion == "" || number > bestNumber {
|
||||
bestVersion = version
|
||||
bestNumber = number
|
||||
}
|
||||
}
|
||||
|
||||
if bestVersion == "" {
|
||||
return "", errors.New("no TOTP secret versions available")
|
||||
}
|
||||
|
||||
return bestVersion, nil
|
||||
}
|
||||
|
||||
func extractSpotifyTrackID(value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
|
||||
Reference in New Issue
Block a user