From 7b19d4c1dd6f5a28b397afc83813c15c312d18c5 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Fri, 26 Aug 2022 01:51:00 +0200 Subject: [PATCH] Solve hash cash challenges (experimental) --- Cargo.lock | 2 + core/Cargo.toml | 3 +- core/src/config.rs | 2 +- core/src/http_client.rs | 11 ++- core/src/spclient.rs | 150 ++++++++++++++++++++++++++++++++-------- core/src/version.rs | 7 ++ 6 files changed, 138 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0c15d140..cd1a2a0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1404,6 +1404,7 @@ dependencies = [ "form_urlencoded", "futures-core", "futures-util", + "hex", "hmac", "http", "httparse", @@ -3065,6 +3066,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd6469f4314d5f1ffec476e05f17cc9a78bc7a27a6a857842170bdf8d6f98d2f" dependencies = [ "getrandom", + "rand", ] [[package]] diff --git a/core/Cargo.toml b/core/Cargo.toml index c2988bb8..b68c6434 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -22,6 +22,7 @@ dns-sd = { version = "0.1", optional = true } form_urlencoded = "1.0" futures-core = "0.3" futures-util = { version = "0.3", features = ["alloc", "bilock", "sink", "unstable"] } +hex = "0.4" hmac = "0.12" httparse = "1.7" http = "0.2" @@ -53,7 +54,7 @@ tokio-stream = "0.1" tokio-tungstenite = { version = "*", default-features = false, features = ["rustls-tls-native-roots"] } tokio-util = { version = "0.7", features = ["codec"] } url = "2" -uuid = { version = "1", default-features = false, features = ["v4"] } +uuid = { version = "1", default-features = false, features = ["fast-rng", "v4"] } [build-dependencies] rand = "0.8" diff --git a/core/src/config.rs b/core/src/config.rs index 7c5f75a1..27a77cec 100644 --- a/core/src/config.rs +++ b/core/src/config.rs @@ -2,7 +2,7 @@ use std::{fmt, path::PathBuf, str::FromStr}; use url::Url; -const KEYMASTER_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd"; +pub(crate) const KEYMASTER_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd"; #[derive(Clone, Debug)] pub struct SessionConfig { diff --git a/core/src/http_client.rs b/core/src/http_client.rs index 903894ad..f283ccf5 100644 --- a/core/src/http_client.rs +++ b/core/src/http_client.rs @@ -15,7 +15,7 @@ use thiserror::Error; use url::Url; use crate::{ - version::{FALLBACK_USER_AGENT, SPOTIFY_MOBILE_VERSION, SPOTIFY_VERSION, VERSION_STRING}, + version::{spotify_version, FALLBACK_USER_AGENT, VERSION_STRING}, Error, }; @@ -82,11 +82,6 @@ pub struct HttpClient { impl HttpClient { pub fn new(proxy_url: Option<&Url>) -> Self { - let spotify_version = match OS { - "android" | "ios" => SPOTIFY_MOBILE_VERSION.to_owned(), - _ => SPOTIFY_VERSION.to_string(), - }; - let spotify_platform = match OS { "android" => "Android/31", "ios" => "iOS/15.2.1", @@ -97,7 +92,9 @@ impl HttpClient { let user_agent_str = &format!( "Spotify/{} {} ({})", - spotify_version, spotify_platform, VERSION_STRING + spotify_version(), + spotify_platform, + VERSION_STRING ); let user_agent = HeaderValue::from_str(user_agent_str).unwrap_or_else(|err| { diff --git a/core/src/spclient.rs b/core/src/spclient.rs index 2d567f81..a910d0ae 100644 --- a/core/src/spclient.rs +++ b/core/src/spclient.rs @@ -4,30 +4,37 @@ use std::{ time::{Duration, Instant}, }; +use byteorder::{BigEndian, ByteOrder}; use bytes::Bytes; use futures_util::future::IntoStream; use http::header::HeaderValue; use hyper::{ client::ResponseFuture, - header::{HeaderName, ACCEPT, AUTHORIZATION, CONTENT_ENCODING, CONTENT_TYPE, RANGE}, + header::{HeaderName, ACCEPT, AUTHORIZATION, CONTENT_TYPE, RANGE}, Body, HeaderMap, Method, Request, }; -use protobuf::Message; +use protobuf::{Message, ProtobufEnum}; use rand::Rng; +use sha1::{Digest, Sha1}; use thiserror::Error; use crate::{ apresolve::SocketAddress, cdn_url::CdnUrl, + config::KEYMASTER_CLIENT_ID, error::ErrorKind, protocol::{ canvaz::EntityCanvazRequest, - clienttoken_http::{ClientTokenRequest, ClientTokenRequestType, ClientTokenResponse}, + clienttoken_http::{ + ChallengeAnswer, ChallengeType, ClientTokenRequest, ClientTokenRequestType, + ClientTokenResponse, ClientTokenResponseType, + }, connect::PutStateRequest, extended_metadata::BatchedEntityRequest, }, token::Token, - version, Error, FileId, SpotifyId, + version::spotify_version, + Error, FileId, SpotifyId, }; component! { @@ -99,6 +106,42 @@ impl SpClient { Ok(format!("https://{}:{}", ap.0, ap.1)) } + fn solve_hash_cash(ctx: &[u8], prefix: &[u8], length: i32, dst: &mut [u8]) { + let md = Sha1::digest(ctx); + + let mut counter: i64 = 0; + let target: i64 = BigEndian::read_i64(&md[12..20]); + + let suffix = loop { + let suffix = [(target + counter).to_be_bytes(), counter.to_be_bytes()].concat(); + + let mut hasher = Sha1::new(); + hasher.update(prefix); + hasher.update(&suffix); + let md = hasher.finalize(); + + if BigEndian::read_i64(&md[12..20]).trailing_zeros() >= (length as u32) { + break suffix; + } + + counter += 1; + }; + + dst.copy_from_slice(&suffix); + } + + async fn client_token_request(&self, message: &dyn Message) -> Result { + let body = message.write_to_bytes()?; + + let request = Request::builder() + .method(&Method::POST) + .uri("https://clienttoken.spotify.com/v1/clienttoken") + .header(ACCEPT, HeaderValue::from_static("application/x-protobuf")) + .body(Body::from(body))?; + + self.session().http_client().request_body(request).await + } + pub async fn client_token(&self) -> Result { let client_token = self.lock(|inner| { if let Some(token) = &inner.client_token { @@ -119,8 +162,8 @@ impl SpClient { message.set_request_type(ClientTokenRequestType::REQUEST_CLIENT_DATA_REQUEST); let client_data = message.mut_client_data(); - client_data.set_client_id(self.session().client_id()); - client_data.set_client_version(version::SEMVER.to_string()); + client_data.set_client_id(KEYMASTER_CLIENT_ID.to_string()); + client_data.set_client_version(spotify_version()); let connectivity_data = client_data.mut_connectivity_sdk_data(); connectivity_data.set_device_id(self.session().device_id().to_string()); @@ -169,40 +212,92 @@ impl SpClient { _ => { let linux_data = platform_data.mut_desktop_linux(); linux_data.set_system_name("Linux".to_string()); - linux_data.set_system_release("5.4.0-56-generic".to_string()); - linux_data - .set_system_version("#62-Ubuntu SMP Mon Nov 23 19:20:19 UTC 2020".to_string()); + linux_data.set_system_release("5.15.0-46-generic".to_string()); + linux_data.set_system_version( + "#49~20.04.1-Ubuntu SMP Thu Aug 4 19:15:44 UTC 2022".to_string(), + ); linux_data.set_hardware(std::env::consts::ARCH.to_string()); } } - let body = message.write_to_bytes()?; + let mut response = self.client_token_request(&message).await?; - let request = Request::builder() - .method(&Method::POST) - .uri("https://clienttoken.spotify.com/v1/clienttoken") - .header(ACCEPT, HeaderValue::from_static("application/x-protobuf")) - .header(CONTENT_ENCODING, HeaderValue::from_static("")) - .body(Body::from(body))?; + let token_response = loop { + let message = ClientTokenResponse::parse_from_bytes(&response)?; + match ClientTokenResponseType::from_i32(message.response_type.value()) { + // depending on the platform, you're either given a token immediately + // or are presented a hash cash challenge to solve first + Some(ClientTokenResponseType::RESPONSE_GRANTED_TOKEN_RESPONSE) => break message, + Some(ClientTokenResponseType::RESPONSE_CHALLENGES_RESPONSE) => { + trace!("received a hash cash challenge"); - let response = self.session().http_client().request_body(request).await?; - let message = ClientTokenResponse::parse_from_bytes(&response)?; + let challenges = message.get_challenges().clone(); + let state = challenges.get_state(); + if let Some(challenge) = challenges.challenges.first() { + let hash_cash_challenge = challenge.get_evaluate_hashcash_parameters(); - let client_token = self.lock(|inner| { - let access_token = message.get_granted_token().get_token().to_owned(); + let ctx = vec![]; + let prefix = hex::decode(&hash_cash_challenge.prefix).map_err(|e| { + Error::failed_precondition(format!( + "unable to decode Hashcash challenge: {}", + e + )) + })?; + let length = hash_cash_challenge.length; + let mut suffix = vec![0; 0x10]; + Self::solve_hash_cash(&ctx, &prefix, length, &mut suffix); + + // the suffix must be in uppercase + let suffix = hex::encode(suffix).to_uppercase(); + + let mut answer_message = ClientTokenRequest::new(); + answer_message.set_request_type( + ClientTokenRequestType::REQUEST_CHALLENGE_ANSWERS_REQUEST, + ); + + let challenge_answers = answer_message.mut_challenge_answers(); + + let mut challenge_answer = ChallengeAnswer::new(); + challenge_answer.mut_hash_cash().suffix = suffix.to_string(); + challenge_answer.ChallengeType = ChallengeType::CHALLENGE_HASH_CASH; + + challenge_answers.state = state.to_string(); + challenge_answers.answers.push(challenge_answer); + + response = self.client_token_request(&answer_message).await?; + + // we should have been granted a token now + continue; + } else { + return Err(Error::failed_precondition("no challenges found")); + } + } + + Some(unknown) => { + return Err(Error::unimplemented(format!( + "unknown client token response type: {:?}", + unknown + ))) + } + None => return Err(Error::failed_precondition("no client token response type")), + } + }; + + let granted_token = token_response.get_granted_token(); + let access_token = granted_token.get_token().to_owned(); + + self.lock(|inner| { let client_token = Token { access_token: access_token.clone(), expires_in: Duration::from_secs( - message - .get_granted_token() + granted_token .get_refresh_after_seconds() .try_into() .unwrap_or(7200), ), token_type: "client-token".to_string(), - scopes: message - .get_granted_token() + scopes: granted_token .get_domains() .iter() .map(|d| d.domain.clone()) @@ -210,13 +305,12 @@ impl SpClient { timestamp: Instant::now(), }; - trace!("Got client token: {:?}", client_token); - inner.client_token = Some(client_token); - access_token }); - Ok(client_token) + trace!("Got client token: {:?}", client_token); + + Ok(access_token) } pub async fn request_with_protobuf( diff --git a/core/src/version.rs b/core/src/version.rs index aadc1356..f2369c91 100644 --- a/core/src/version.rs +++ b/core/src/version.rs @@ -24,3 +24,10 @@ pub const SPOTIFY_MOBILE_VERSION: &str = "8.6.84"; /// The user agent to fall back to, if one could not be determined dynamically. pub const FALLBACK_USER_AGENT: &str = "Spotify/117300517 Linux/0 (librespot)"; + +pub fn spotify_version() -> String { + match std::env::consts::OS { + "android" | "ios" => SPOTIFY_MOBILE_VERSION.to_owned(), + _ => SPOTIFY_VERSION.to_string(), + } +}