Created
December 20, 2025 03:23
-
-
Save Sighyu/b0af7349cd5783654b54778e3cfd1d89 to your computer and use it in GitHub Desktop.
fk
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using Newtonsoft.Json; | |
| using Newtonsoft.Json.Linq; | |
| using System; | |
| using System.Collections.Generic; | |
| using System.ComponentModel; | |
| using System.IO; | |
| using System.Linq; | |
| using System.Net; | |
| using System.Runtime.InteropServices; | |
| using System.Security.Cryptography; | |
| using System.Text; | |
| namespace YUCP.GrooveKit.Credentials | |
| { | |
| public static class SpotifyCredentialManager | |
| { | |
| // --- Constants --- | |
| private const string BaseUrl = "https://open.spotify.com"; | |
| private const string ClientTokenUrl = "https://clienttoken.spotify.com/v1/clienttoken"; | |
| private const string ServerTimeUrl = "https://open.spotify.com/api/server-time"; | |
| private const string TokenUrl = "https://open.spotify.com/api/token"; | |
| private const string ProfileUrl = "https://api.spotify.com/v1/me"; | |
| // This appears to be a private backend for the mod to fetch TOTP secrets | |
| private const string SecretsUrl = "https://melody.yucp.club/secrets/dict"; | |
| private const string SecretsAuthHeader = "Bearer i0ng9l31ctCsBpcUQK3izwm2330PMKNu"; | |
| private const string CookieFileName = "sp_dc_cookie.dat"; | |
| // --- State --- | |
| private static Dictionary<int, byte[]> _totpSecrets; | |
| private static int _latestTotpVersion; | |
| // --- Public API --- | |
| public static bool LoadCookieFromVRCOSC(string vrcOscUserDataPath, out string spDcCookie) | |
| { | |
| return TryLoadCookie(vrcOscUserDataPath, out spDcCookie); | |
| } | |
| public static bool ValidateTokens(string accessToken, string clientToken, out JObject profileData) | |
| { | |
| return TryValidateToken(accessToken, clientToken, out profileData); | |
| } | |
| public static bool RefreshAccessToken(string vrcOscUserDataPath, string spDcCookie, out string newAccessToken, out string newClientToken) | |
| { | |
| return TryRefreshToken(vrcOscUserDataPath, spDcCookie, out newAccessToken, out newClientToken); | |
| } | |
| public static JObject FetchUserProfile(string accessToken) | |
| { | |
| return GetUserProfile(accessToken); | |
| } | |
| public static string DetectVRCOSCUserDataPath() | |
| { | |
| // Usually: %localappdata%\VRCOSC\current\user_data | |
| string path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "VRCOSC", "current", "user_data"); | |
| if (Directory.Exists(path)) | |
| { | |
| Console.WriteLine($"[SpotifyCredentials] Auto-detected VRC OSC path: {path}"); | |
| return path; | |
| } | |
| Console.WriteLine($"[SpotifyCredentials] Could not auto-detect VRC OSC path. Tried: {path}"); | |
| return null; | |
| } | |
| // --- Internal Logic --- | |
| private static bool TryLoadCookie(string path, out string decryptedCookie) | |
| { | |
| decryptedCookie = null; | |
| if (string.IsNullOrEmpty(path)) | |
| { | |
| Console.WriteLine("[SpotifyCredentials] VRC OSC user data path is not set"); | |
| return false; | |
| } | |
| if (!Directory.Exists(path)) | |
| { | |
| Console.WriteLine($"[SpotifyCredentials] VRC OSC user data directory does not exist: {path}"); | |
| return false; | |
| } | |
| Console.WriteLine($"[SpotifyCredentials] Loading sp_dc cookie from: {path}"); | |
| string cookieFilePath = Path.Combine(path, CookieFileName); | |
| if (!File.Exists(cookieFilePath)) | |
| { | |
| Console.WriteLine("[SpotifyCredentials] sp_dc cookie not found - cannot authenticate"); | |
| return false; | |
| } | |
| decryptedCookie = DecryptFile(cookieFilePath); | |
| if (string.IsNullOrEmpty(decryptedCookie)) | |
| { | |
| Console.WriteLine("[SpotifyCredentials] sp_dc cookie is empty or failed to decrypt"); | |
| return false; | |
| } | |
| Console.WriteLine($"[SpotifyCredentials] Loaded sp_dc cookie (length: {decryptedCookie.Length})"); | |
| return true; | |
| } | |
| private static bool TryValidateToken(string accessToken, string clientToken, out JObject profileData) | |
| { | |
| profileData = null; | |
| if (string.IsNullOrEmpty(accessToken)) | |
| { | |
| Console.WriteLine("[SpotifyCredentials] Cannot validate - access token is empty"); | |
| return false; | |
| } | |
| try | |
| { | |
| return ValidateTokenRequest(accessToken, out profileData); | |
| } | |
| catch (WebException ex) | |
| { | |
| Console.WriteLine($"[SpotifyCredentials] Token validation failed: {ex.Message}"); | |
| return false; | |
| } | |
| } | |
| private static bool ValidateTokenRequest(string accessToken, out JObject profileData) | |
| { | |
| profileData = null; | |
| using (WebClient webClient = new WebClient()) | |
| { | |
| webClient.Headers.Add("Authorization", "Bearer " + accessToken); | |
| webClient.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); | |
| string json = webClient.DownloadString(ProfileUrl); | |
| profileData = JObject.Parse(json); | |
| Console.WriteLine($"[SpotifyCredentials] Token validation successful - User: {profileData["display_name"]}"); | |
| return true; | |
| } | |
| } | |
| private static bool TryRefreshToken(string userDataPath, string spDcCookie, out string accessToken, out string clientToken) | |
| { | |
| accessToken = null; | |
| clientToken = null; | |
| if (string.IsNullOrEmpty(spDcCookie)) | |
| { | |
| Console.WriteLine("[SpotifyCredentials] Cannot refresh - sp_dc cookie is empty"); | |
| return false; | |
| } | |
| try | |
| { | |
| Console.WriteLine("[SpotifyCredentials] Refreshing tokens using sp_dc cookie..."); | |
| // 1. Ensure we have the TOTP secrets from the remote server | |
| EnsureSecretsLoaded(); | |
| // 2. Get Spotify Server Time | |
| long serverTime = GetSpotifyServerTime(spDcCookie); | |
| long localTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); | |
| long serverTimeMs = serverTime * 1000L; | |
| // 3. Generate TOTP codes | |
| string totpCodeLocal = GenerateTotp(localTime); | |
| string totpCodeServer = GenerateTotp(serverTimeMs); | |
| Console.WriteLine("[SpotifyCredentials] TOTP codes generated successfully"); | |
| // 4. Request the Token | |
| string tokenResponseJson = RequestToken(totpCodeLocal, totpCodeServer, serverTime, localTime, spDcCookie); | |
| JObject tokenObj = JObject.Parse(tokenResponseJson); | |
| accessToken = tokenObj["accessToken"]?.ToString(); | |
| string clientId = tokenObj["clientId"]?.ToString(); | |
| if (string.IsNullOrEmpty(accessToken)) | |
| { | |
| Console.WriteLine("[SpotifyCredentials] Access token not found in response"); | |
| return false; | |
| } | |
| Console.WriteLine("[SpotifyCredentials] Access token refreshed successfully"); | |
| // 5. Get Client Token using Client ID | |
| clientToken = FetchClientToken(clientId); | |
| if (!string.IsNullOrEmpty(accessToken) && !string.IsNullOrEmpty(clientToken)) | |
| { | |
| Console.WriteLine("[SpotifyCredentials] Token refresh successful!"); | |
| return true; | |
| } | |
| else | |
| { | |
| Console.WriteLine("[SpotifyCredentials] Token refresh returned invalid tokens"); | |
| return false; | |
| } | |
| } | |
| catch (Exception ex) | |
| { | |
| Console.WriteLine($"[SpotifyCredentials] Token refresh failed: {ex.Message}\n{ex.StackTrace}"); | |
| return false; | |
| } | |
| } | |
| private static long GetSpotifyServerTime(string spDcCookie) | |
| { | |
| using (WebClient webClient = new WebClient()) | |
| { | |
| webClient.Headers.Add("Cookie", "sp_dc=" + spDcCookie); | |
| webClient.Headers.Add("Origin", BaseUrl); | |
| webClient.Headers.Add("Referer", BaseUrl); | |
| webClient.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); | |
| string json = webClient.DownloadString(ServerTimeUrl); | |
| long serverTime = JObject.Parse(json)["serverTime"].Value<long>(); | |
| Console.WriteLine($"[SpotifyCredentials] Server time: {serverTime}"); | |
| return serverTime; | |
| } | |
| } | |
| private static string RequestToken(string totp, string totpServer, long sTime, long cTime, string spDcCookie) | |
| { | |
| var queryParams = new Dictionary<string, string> | |
| { | |
| ["reason"] = "transport", | |
| ["productType"] = "web-player", | |
| ["totp"] = totp, | |
| ["totpServer"] = totpServer, | |
| ["totpVer"] = _latestTotpVersion.ToString(), | |
| ["sTime"] = sTime.ToString(), | |
| ["cTime"] = cTime.ToString(), | |
| ["buildVer"] = "web-player_8d8596d6", | |
| ["buildDate"] = "2024-11-20" // Reconstructed from array logic in original | |
| }; | |
| string queryString = string.Join("&", queryParams.Select(kvp => $"{kvp.Key}={WebUtility.UrlEncode(kvp.Value)}")); | |
| string url = $"{TokenUrl}?{queryString}"; | |
| using (WebClient webClient = new WebClient()) | |
| { | |
| webClient.Headers.Add("Cookie", "sp_dc=" + spDcCookie); | |
| webClient.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); | |
| return webClient.DownloadString(url); | |
| } | |
| } | |
| private static JObject GetUserProfile(string accessToken) | |
| { | |
| if (string.IsNullOrEmpty(accessToken)) | |
| { | |
| Console.WriteLine("[SpotifyCredentials] Cannot fetch profile - access token is empty"); | |
| return null; | |
| } | |
| try | |
| { | |
| using (WebClient webClient = new WebClient()) | |
| { | |
| webClient.Headers.Add("Authorization", "Bearer " + accessToken); | |
| webClient.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); | |
| string json = webClient.DownloadString(ProfileUrl); | |
| JObject profile = JObject.Parse(json); | |
| string displayName = profile["display_name"]?.ToString() ?? "Unknown"; | |
| string product = profile["product"]?.ToString() ?? "free"; | |
| Console.WriteLine($"[SpotifyCredentials] Profile fetched: {displayName} ({product})"); | |
| return profile; | |
| } | |
| } | |
| catch (WebException ex) | |
| { | |
| HttpWebResponse response = ex.Response as HttpWebResponse; | |
| if (response != null) | |
| { | |
| Console.WriteLine($"[SpotifyCredentials] Profile fetch failed with status {response.StatusCode}: {ex.Message}"); | |
| if (response.StatusCode == HttpStatusCode.Unauthorized) | |
| { | |
| Console.WriteLine("[SpotifyCredentials] Access token expired (401 Unauthorized)"); | |
| } | |
| } | |
| else | |
| { | |
| Console.WriteLine("[SpotifyCredentials] Profile fetch failed: " + ex.Message); | |
| } | |
| return null; | |
| } | |
| } | |
| // --- TOTP / Secrets Logic --- | |
| private static void EnsureSecretsLoaded() | |
| { | |
| if (_totpSecrets != null) return; | |
| Console.WriteLine("[SpotifyCredentials] Downloading TOTP secrets from secrets endpoint..."); | |
| try | |
| { | |
| using (WebClient webClient = new WebClient()) | |
| { | |
| webClient.Headers.Add("Authorization", SecretsAuthHeader); | |
| string json = webClient.DownloadString(SecretsUrl); | |
| Console.WriteLine($"[SpotifyCredentials] Raw response from secrets endpoint: {json}"); | |
| _totpSecrets = ParseSecrets(json); | |
| if (_totpSecrets.Count == 0) | |
| { | |
| Console.WriteLine("[SpotifyCredentials] No TOTP secrets found in response."); | |
| throw new InvalidOperationException("No TOTP secrets found."); | |
| } | |
| _latestTotpVersion = _totpSecrets.Keys.Max(); | |
| Console.WriteLine($"[SpotifyCredentials] Downloaded {_totpSecrets.Count} TOTP versions; using latest={_latestTotpVersion}"); | |
| } | |
| } | |
| catch (Exception ex) | |
| { | |
| throw new InvalidOperationException("Failed to download TOTP secrets: " + ex.Message, ex); | |
| } | |
| } | |
| private static Dictionary<int, byte[]> ParseSecrets(string json) | |
| { | |
| var result = new Dictionary<int, byte[]>(); | |
| JObject root = JObject.Parse(json); | |
| // Handle both structure types (nested secretDict or flat) | |
| JObject dictToParse = root["secretDict"] as JObject ?? root; | |
| foreach (var prop in dictToParse.Properties()) | |
| { | |
| if (int.TryParse(prop.Name, out int version) && prop.Value is JArray bytesArray) | |
| { | |
| result[version] = bytesArray.Select(t => (byte)t.Value<int>()).ToArray(); | |
| } | |
| } | |
| return result; | |
| } | |
| private static string GenerateTotp(long time) | |
| { | |
| // Decrypt/XOR the secret using the specific key logic | |
| byte[] secret = _totpSecrets[_latestTotpVersion]; | |
| byte[] processedSecret = ProcessSecret(secret); | |
| string hexSecret = BytesToHexWithObfuscation(processedSecret); | |
| byte[] keyBytes = HexToBytes(hexSecret); | |
| // Calculate time step | |
| ulong timeStep = (ulong)(time / 1000L / 30L); // 30 second steps | |
| byte[] timeBytes = BitConverter.GetBytes(timeStep); | |
| if (BitConverter.IsLittleEndian) Array.Reverse(timeBytes); | |
| byte[] hash = ComputeHmacSha1(keyBytes, timeBytes); | |
| return TruncateHash(hash); | |
| } | |
| private static byte[] ProcessSecret(byte[] input) | |
| { | |
| // The original logic XORed bytes with (index % 33 + 9) | |
| return input.Select((b, i) => (byte)(b ^ (i % 33 + 9))).ToArray(); | |
| } | |
| private static string BytesToHexWithObfuscation(byte[] input) | |
| { | |
| // Changed lambda parameter to 'val' and local variable to 'bytes' | |
| string s = string.Concat(input.Select(val => val.ToString())); | |
| byte[] bytes = Encoding.UTF8.GetBytes(s); | |
| return BitConverter.ToString(bytes).Replace("-", "").ToLowerInvariant(); | |
| } | |
| private static byte[] HexToBytes(string hex) | |
| { | |
| return Enumerable.Range(0, hex.Length / 2) | |
| .Select(x => Convert.ToByte(hex.Substring(x * 2, 2), 16)) | |
| .ToArray(); | |
| } | |
| private static byte[] ComputeHmacSha1(byte[] key, byte[] data) | |
| { | |
| using (HMACSHA1 hmac = new HMACSHA1(key)) | |
| { | |
| return hmac.ComputeHash(data); | |
| } | |
| } | |
| private static string TruncateHash(byte[] hash) | |
| { | |
| // Standard TOTP Truncation | |
| int offset = hash[hash.Length - 1] & 0x0F; | |
| int binary = ((hash[offset] & 0x7f) << 24) | | |
| ((hash[offset + 1] & 0xff) << 16) | | |
| ((hash[offset + 2] & 0xff) << 8) | | |
| (hash[offset + 3] & 0xff); | |
| int otpDigits = 6; | |
| int otp = binary % (int)Math.Pow(10, otpDigits); | |
| return otp.ToString($"D{otpDigits}"); | |
| } | |
| // --- Client Token Fetcher --- | |
| private static string FetchClientToken(string clientId) | |
| { | |
| try | |
| { | |
| Console.WriteLine("[SpotifyCredentials] Fetching client token..."); | |
| using (WebClient webClient = new WebClient()) | |
| { | |
| SetupClientTokenHeaders(webClient); | |
| var body = new | |
| { | |
| client_data = new | |
| { | |
| client_version = "1.2.54.124.gc8ffdbcb", | |
| client_id = clientId, | |
| js_sdk_data = new | |
| { | |
| device_brand = "unknown", | |
| device_model = "unknown", | |
| os = "windows", | |
| os_version = "NT 10.0", | |
| device_id = Guid.NewGuid().ToString(), | |
| device_type = "computer" | |
| } | |
| } | |
| }; | |
| string jsonBody = JsonConvert.SerializeObject(body); | |
| Console.WriteLine($"[SpotifyCredentials] Client token request body: {jsonBody}"); | |
| string response = webClient.UploadString(ClientTokenUrl, "POST", jsonBody); | |
| string token = JObject.Parse(response)["granted_token"]?["token"]?.ToString(); | |
| if (!string.IsNullOrEmpty(token)) | |
| { | |
| Console.WriteLine("[SpotifyCredentials] Client token retrieved successfully"); | |
| return token; | |
| } | |
| Console.WriteLine("[SpotifyCredentials] Client token not found in response"); | |
| return null; | |
| } | |
| } | |
| catch (WebException ex) | |
| { | |
| Console.WriteLine($"[SpotifyCredentials] Failed to get client token: {ex.Message}"); | |
| using (var stream = ex.Response?.GetResponseStream()) | |
| { | |
| if (stream != null) | |
| { | |
| using (var reader = new StreamReader(stream)) | |
| { | |
| Console.WriteLine($"Details: {reader.ReadToEnd()}"); | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| } | |
| private static void SetupClientTokenHeaders(WebClient client) | |
| { | |
| client.Headers.Add("Accept", "application/json"); | |
| client.Headers.Add("Accept-Language", "en-US,en;q=0.9"); | |
| client.Headers.Add("Content-Type", "application/json"); | |
| client.Headers.Add("Sec-CH-UA", "\"Microsoft Edge\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\""); | |
| client.Headers.Add("Sec-CH-UA-Mobile", "?0"); | |
| client.Headers.Add("Sec-CH-UA-Platform", "\"Windows\""); | |
| client.Headers.Add("Sec-Fetch-Dest", "empty"); | |
| client.Headers.Add("Sec-Fetch-Mode", "cors"); | |
| client.Headers.Add("Sec-Fetch-Site", "same-site"); | |
| client.Headers.Add("Referer", "https://accounts.spotify.com/en/login?allow_password=1"); | |
| client.Headers.Add("Referrer-Policy", "strict-origin-when-cross-origin"); | |
| } | |
| // --- Encryption/Decryption (DPAPI) --- | |
| private static string DecryptFile(string filePath) | |
| { | |
| try | |
| { | |
| byte[] encryptedData = File.ReadAllBytes(filePath); | |
| byte[] decryptedData = UnprotectData(encryptedData); | |
| return Encoding.UTF8.GetString(decryptedData); | |
| } | |
| catch (Exception ex) | |
| { | |
| Console.WriteLine($"[SpotifyCredentials] Failed to decrypt {filePath}: {ex.Message}"); | |
| return null; | |
| } | |
| } | |
| // Native DPAPI Wrappers | |
| [DllImport("crypt32.dll", SetLastError = true, CharSet = CharSet.Auto)] | |
| private static extern bool CryptUnprotectData(ref DATA_BLOB pDataIn, string szDataDescr, ref DATA_BLOB pOptionalEntropy, IntPtr pvReserved, IntPtr pPromptStruct, uint dwFlags, ref DATA_BLOB pDataOut); | |
| [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] | |
| private struct DATA_BLOB | |
| { | |
| public int cbData; | |
| public IntPtr pbData; | |
| } | |
| private static byte[] UnprotectData(byte[] data) | |
| { | |
| DATA_BLOB inBlob = new DATA_BLOB(); | |
| DATA_BLOB outBlob = new DATA_BLOB(); | |
| try | |
| { | |
| inBlob.cbData = data.Length; | |
| inBlob.pbData = Marshal.AllocHGlobal(data.Length); | |
| Marshal.Copy(data, 0, inBlob.pbData, data.Length); | |
| DATA_BLOB entropy = default; | |
| if (CryptUnprotectData(ref inBlob, null, ref entropy, IntPtr.Zero, IntPtr.Zero, 0, ref outBlob)) | |
| { | |
| byte[] result = new byte[outBlob.cbData]; | |
| Marshal.Copy(outBlob.pbData, result, 0, outBlob.cbData); | |
| return result; | |
| } | |
| else | |
| { | |
| throw new Win32Exception(Marshal.GetLastWin32Error()); | |
| } | |
| } | |
| finally | |
| { | |
| if (inBlob.pbData != IntPtr.Zero) Marshal.FreeHGlobal(inBlob.pbData); | |
| if (outBlob.pbData != IntPtr.Zero) Marshal.FreeHGlobal(outBlob.pbData); | |
| } | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment