Solve hash cash challenges (experimental)

This commit is contained in:
Roderick van Domburg 2022-08-26 01:51:00 +02:00
parent 42a665fb0d
commit 7b19d4c1dd
No known key found for this signature in database
GPG key ID: 87F5FDE8A56219F4
6 changed files with 138 additions and 37 deletions

2
Cargo.lock generated
View file

@ -1404,6 +1404,7 @@ dependencies = [
"form_urlencoded", "form_urlencoded",
"futures-core", "futures-core",
"futures-util", "futures-util",
"hex",
"hmac", "hmac",
"http", "http",
"httparse", "httparse",
@ -3065,6 +3066,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd6469f4314d5f1ffec476e05f17cc9a78bc7a27a6a857842170bdf8d6f98d2f" checksum = "dd6469f4314d5f1ffec476e05f17cc9a78bc7a27a6a857842170bdf8d6f98d2f"
dependencies = [ dependencies = [
"getrandom", "getrandom",
"rand",
] ]
[[package]] [[package]]

View file

@ -22,6 +22,7 @@ dns-sd = { version = "0.1", optional = true }
form_urlencoded = "1.0" form_urlencoded = "1.0"
futures-core = "0.3" futures-core = "0.3"
futures-util = { version = "0.3", features = ["alloc", "bilock", "sink", "unstable"] } futures-util = { version = "0.3", features = ["alloc", "bilock", "sink", "unstable"] }
hex = "0.4"
hmac = "0.12" hmac = "0.12"
httparse = "1.7" httparse = "1.7"
http = "0.2" http = "0.2"
@ -53,7 +54,7 @@ tokio-stream = "0.1"
tokio-tungstenite = { version = "*", default-features = false, features = ["rustls-tls-native-roots"] } tokio-tungstenite = { version = "*", default-features = false, features = ["rustls-tls-native-roots"] }
tokio-util = { version = "0.7", features = ["codec"] } tokio-util = { version = "0.7", features = ["codec"] }
url = "2" url = "2"
uuid = { version = "1", default-features = false, features = ["v4"] } uuid = { version = "1", default-features = false, features = ["fast-rng", "v4"] }
[build-dependencies] [build-dependencies]
rand = "0.8" rand = "0.8"

View file

@ -2,7 +2,7 @@ use std::{fmt, path::PathBuf, str::FromStr};
use url::Url; use url::Url;
const KEYMASTER_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd"; pub(crate) const KEYMASTER_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd";
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct SessionConfig { pub struct SessionConfig {

View file

@ -15,7 +15,7 @@ use thiserror::Error;
use url::Url; use url::Url;
use crate::{ use crate::{
version::{FALLBACK_USER_AGENT, SPOTIFY_MOBILE_VERSION, SPOTIFY_VERSION, VERSION_STRING}, version::{spotify_version, FALLBACK_USER_AGENT, VERSION_STRING},
Error, Error,
}; };
@ -82,11 +82,6 @@ pub struct HttpClient {
impl HttpClient { impl HttpClient {
pub fn new(proxy_url: Option<&Url>) -> Self { 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 { let spotify_platform = match OS {
"android" => "Android/31", "android" => "Android/31",
"ios" => "iOS/15.2.1", "ios" => "iOS/15.2.1",
@ -97,7 +92,9 @@ impl HttpClient {
let user_agent_str = &format!( let user_agent_str = &format!(
"Spotify/{} {} ({})", "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| { let user_agent = HeaderValue::from_str(user_agent_str).unwrap_or_else(|err| {

View file

@ -4,30 +4,37 @@ use std::{
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use byteorder::{BigEndian, ByteOrder};
use bytes::Bytes; use bytes::Bytes;
use futures_util::future::IntoStream; use futures_util::future::IntoStream;
use http::header::HeaderValue; use http::header::HeaderValue;
use hyper::{ use hyper::{
client::ResponseFuture, client::ResponseFuture,
header::{HeaderName, ACCEPT, AUTHORIZATION, CONTENT_ENCODING, CONTENT_TYPE, RANGE}, header::{HeaderName, ACCEPT, AUTHORIZATION, CONTENT_TYPE, RANGE},
Body, HeaderMap, Method, Request, Body, HeaderMap, Method, Request,
}; };
use protobuf::Message; use protobuf::{Message, ProtobufEnum};
use rand::Rng; use rand::Rng;
use sha1::{Digest, Sha1};
use thiserror::Error; use thiserror::Error;
use crate::{ use crate::{
apresolve::SocketAddress, apresolve::SocketAddress,
cdn_url::CdnUrl, cdn_url::CdnUrl,
config::KEYMASTER_CLIENT_ID,
error::ErrorKind, error::ErrorKind,
protocol::{ protocol::{
canvaz::EntityCanvazRequest, canvaz::EntityCanvazRequest,
clienttoken_http::{ClientTokenRequest, ClientTokenRequestType, ClientTokenResponse}, clienttoken_http::{
ChallengeAnswer, ChallengeType, ClientTokenRequest, ClientTokenRequestType,
ClientTokenResponse, ClientTokenResponseType,
},
connect::PutStateRequest, connect::PutStateRequest,
extended_metadata::BatchedEntityRequest, extended_metadata::BatchedEntityRequest,
}, },
token::Token, token::Token,
version, Error, FileId, SpotifyId, version::spotify_version,
Error, FileId, SpotifyId,
}; };
component! { component! {
@ -99,6 +106,42 @@ impl SpClient {
Ok(format!("https://{}:{}", ap.0, ap.1)) 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<Bytes, Error> {
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<String, Error> { pub async fn client_token(&self) -> Result<String, Error> {
let client_token = self.lock(|inner| { let client_token = self.lock(|inner| {
if let Some(token) = &inner.client_token { if let Some(token) = &inner.client_token {
@ -119,8 +162,8 @@ impl SpClient {
message.set_request_type(ClientTokenRequestType::REQUEST_CLIENT_DATA_REQUEST); message.set_request_type(ClientTokenRequestType::REQUEST_CLIENT_DATA_REQUEST);
let client_data = message.mut_client_data(); let client_data = message.mut_client_data();
client_data.set_client_id(self.session().client_id()); client_data.set_client_id(KEYMASTER_CLIENT_ID.to_string());
client_data.set_client_version(version::SEMVER.to_string()); client_data.set_client_version(spotify_version());
let connectivity_data = client_data.mut_connectivity_sdk_data(); let connectivity_data = client_data.mut_connectivity_sdk_data();
connectivity_data.set_device_id(self.session().device_id().to_string()); 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(); let linux_data = platform_data.mut_desktop_linux();
linux_data.set_system_name("Linux".to_string()); linux_data.set_system_name("Linux".to_string());
linux_data.set_system_release("5.4.0-56-generic".to_string()); linux_data.set_system_release("5.15.0-46-generic".to_string());
linux_data linux_data.set_system_version(
.set_system_version("#62-Ubuntu SMP Mon Nov 23 19:20:19 UTC 2020".to_string()); "#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()); 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() let token_response = loop {
.method(&Method::POST) let message = ClientTokenResponse::parse_from_bytes(&response)?;
.uri("https://clienttoken.spotify.com/v1/clienttoken") match ClientTokenResponseType::from_i32(message.response_type.value()) {
.header(ACCEPT, HeaderValue::from_static("application/x-protobuf")) // depending on the platform, you're either given a token immediately
.header(CONTENT_ENCODING, HeaderValue::from_static("")) // or are presented a hash cash challenge to solve first
.body(Body::from(body))?; 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 challenges = message.get_challenges().clone();
let message = ClientTokenResponse::parse_from_bytes(&response)?; 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 ctx = vec![];
let access_token = message.get_granted_token().get_token().to_owned(); 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 { let client_token = Token {
access_token: access_token.clone(), access_token: access_token.clone(),
expires_in: Duration::from_secs( expires_in: Duration::from_secs(
message granted_token
.get_granted_token()
.get_refresh_after_seconds() .get_refresh_after_seconds()
.try_into() .try_into()
.unwrap_or(7200), .unwrap_or(7200),
), ),
token_type: "client-token".to_string(), token_type: "client-token".to_string(),
scopes: message scopes: granted_token
.get_granted_token()
.get_domains() .get_domains()
.iter() .iter()
.map(|d| d.domain.clone()) .map(|d| d.domain.clone())
@ -210,13 +305,12 @@ impl SpClient {
timestamp: Instant::now(), timestamp: Instant::now(),
}; };
trace!("Got client token: {:?}", client_token);
inner.client_token = Some(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( pub async fn request_with_protobuf(

View file

@ -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. /// 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 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(),
}
}