[spotify] Import librespot-c fixes and hashcash support

This commit is contained in:
ejurgensen 2025-01-17 17:48:39 +01:00
parent 032c564b5a
commit 7d4d5e98b3
6 changed files with 327 additions and 6 deletions

View File

@ -37,9 +37,13 @@ struct sp_metadata
size_t file_len;
};
// How to identify towards Spotify. The device_id can be set to an actual value
// identifying the client, but the rest are unfortunately best left as zeroes,
// which will make librespot-c use defaults that spoof whitelisted clients.
struct sp_sysinfo
{
char client_name[16];
char client_id[33];
char client_version[16];
char client_build_id[16];
char device_id[41]; // librespot gives a 20 byte id (so 40 char hex + 1 zero term)

View File

@ -919,6 +919,55 @@ handle_clienttoken(struct sp_message *msg, struct sp_session *session)
return ret;
}
static void
hashcash_challenges_free(struct crypto_hashcash_challenge **challenges, int *n_challenges)
{
for (int i = 0; i < *n_challenges; i++)
free(challenges[i]->ctx);
free(*challenges);
*challenges = NULL;
*n_challenges = 0;
}
static enum sp_error
handle_login5_challenges(Spotify__Login5__V3__Challenges *challenges, uint8_t *login_ctx, size_t login_ctx_len, struct sp_session *session)
{
Spotify__Login5__V3__Challenge *this_challenge;
struct crypto_hashcash_challenge *crypto_challenge;
int ret;
int i;
session->n_hashcash_challenges = challenges->n_challenges;
session->hashcash_challenges = calloc(challenges->n_challenges, sizeof(struct crypto_hashcash_challenge));
for (i = 0, crypto_challenge = session->hashcash_challenges; i < session->n_hashcash_challenges; i++, crypto_challenge++)
{
this_challenge = challenges->challenges[i];
if (this_challenge->challenge_case != SPOTIFY__LOGIN5__V3__CHALLENGE__CHALLENGE_HASHCASH)
RETURN_ERROR(SP_ERR_INVALID, "Received unsupported login5 challenge");
if (this_challenge->hashcash->prefix.len != sizeof(crypto_challenge->prefix))
RETURN_ERROR(SP_ERR_INVALID, "Received hashcash challenge with unexpected prefix length");
crypto_challenge->ctx_len = login_ctx_len;
crypto_challenge->ctx = malloc(login_ctx_len);
memcpy(crypto_challenge->ctx, login_ctx, login_ctx_len);
memcpy(crypto_challenge->prefix, this_challenge->hashcash->prefix.data, sizeof(crypto_challenge->prefix));
crypto_challenge->wanted_zero_bits = this_challenge->hashcash->length;
crypto_challenge->max_iterations = 100000; //TODO define or make variable
}
return SP_OK_DONE;
error:
hashcash_challenges_free(&session->hashcash_challenges, &session->n_hashcash_challenges);
return ret;
}
static enum sp_error
handle_login5(struct sp_message *msg, struct sp_session *session)
{
@ -950,7 +999,9 @@ handle_login5(struct sp_message *msg, struct sp_session *session)
break;
case SPOTIFY__LOGIN5__V3__LOGIN_RESPONSE__RESPONSE_CHALLENGES:
sp_cb.logmsg("Login %zu challenges\n", response->challenges->n_challenges);
// TODO support hashcash challenges
ret = handle_login5_challenges(response->challenges, response->login_context.data, response->login_context.len, session);
if (ret != SP_OK_DONE)
goto error;
break;
case SPOTIFY__LOGIN5__V3__LOGIN_RESPONSE__RESPONSE_ERROR:
RETURN_ERROR(SP_ERR_LOGINFAILED, err2txt(response->error, sp_login5_error_map, ARRAY_SIZE(sp_login5_error_map)));
@ -1728,7 +1779,7 @@ msg_make_clienttoken(struct sp_message *msg, struct sp_session *session)
dreq.connectivity_sdk_data = &sdk_data;
dreq.data_case = SPOTIFY__CLIENTTOKEN__HTTP__V0__CLIENT_DATA_REQUEST__DATA_CONNECTIVITY_SDK_DATA;
dreq.client_version = sp_sysinfo.client_version; // e.g. "0.0.0" (SpotifyLikeClient)
dreq.client_id = SP_CLIENT_ID_HEX;
dreq.client_id = sp_sysinfo.client_id;
treq.client_data = &dreq;
treq.request_type = SPOTIFY__CLIENTTOKEN__HTTP__V0__CLIENT_TOKEN_REQUEST_TYPE__REQUEST_CLIENT_DATA_REQUEST;
@ -1747,17 +1798,87 @@ msg_make_clienttoken(struct sp_message *msg, struct sp_session *session)
return 0;
}
static void
challenge_solutions_free(Spotify__Login5__V3__ChallengeSolutions *solutions)
{
Spotify__Login5__V3__ChallengeSolution *this_solution;
int i;
if (!solutions)
return;
for (i = 0; i < solutions->n_solutions; i++)
{
this_solution = solutions->solutions[i];
free(this_solution->hashcash->duration);
free(this_solution->hashcash->suffix.data);
free(this_solution->hashcash);
}
free(solutions->solutions);
}
// Finds solutions to the challenges stored in *challenges and adds them to *solutions
static int
challenge_solutions_append(Spotify__Login5__V3__ChallengeSolutions *solutions, struct crypto_hashcash_challenge *challenges, int n_challenges)
{
Spotify__Login5__V3__ChallengeSolution *this_solution;
struct crypto_hashcash_challenge *crypto_challenge;
struct crypto_hashcash_solution crypto_solution;
size_t suffix_len = sizeof(crypto_solution.suffix);
int ret;
int i;
solutions->n_solutions = n_challenges;
solutions->solutions = calloc(n_challenges, sizeof(Spotify__Login5__V3__ChallengeSolution));
for (i = 0, crypto_challenge = challenges; i < n_challenges; i++, crypto_challenge++)
{
ret = crypto_hashcash_solve(&crypto_solution, crypto_challenge, &sp_errmsg);
if (ret < 0)
RETURN_ERROR(SP_ERR_INVALID, sp_errmsg);
this_solution = solutions->solutions[i];
spotify__login5__v3__challenge_solution__init(this_solution);
this_solution->solution_case = SPOTIFY__LOGIN5__V3__CHALLENGE_SOLUTION__SOLUTION_HASHCASH;
this_solution->hashcash = malloc(sizeof(Spotify__Login5__V3__Challenges__HashcashSolution));
spotify__login5__v3__challenges__hashcash_solution__init(this_solution->hashcash);
this_solution->hashcash->duration = malloc(sizeof(Google__Protobuf__Duration));
google__protobuf__duration__init(this_solution->hashcash->duration);
this_solution->hashcash->suffix.len = suffix_len;
this_solution->hashcash->suffix.data = malloc(suffix_len);
memcpy(this_solution->hashcash->suffix.data, crypto_solution.suffix, suffix_len);
this_solution->hashcash->duration->seconds = crypto_solution.duration.tv_sec;
this_solution->hashcash->duration->nanos = crypto_solution.duration.tv_nsec;;
}
return 0;
error:
challenge_solutions_free(solutions);
return ret;
}
// Ref. login5/login5.go
static int
msg_make_login5(struct sp_message *msg, struct sp_session *session)
{
struct http_request *hreq = &msg->payload.hreq;
Spotify__Login5__V3__LoginRequest req = SPOTIFY__LOGIN5__V3__LOGIN_REQUEST__INIT;
Spotify__Login5__V3__ChallengeSolutions solutions = SPOTIFY__LOGIN5__V3__CHALLENGE_SOLUTIONS__INIT;
Spotify__Login5__V3__ClientInfo client_info = SPOTIFY__LOGIN5__V3__CLIENT_INFO__INIT;
Spotify__Login5__V3__Credentials__StoredCredential stored_credential = SPOTIFY__LOGIN5__V3__CREDENTIALS__STORED_CREDENTIAL__INIT;
struct sp_token *token = &session->http_accesstoken;
uint8_t *login_context = NULL;
size_t login_context_len;
time_t now = time(NULL);
bool must_refresh;
int ret;
must_refresh = (now > token->received_ts + token->expires_after_seconds);
if (!must_refresh)
@ -1766,7 +1887,25 @@ msg_make_login5(struct sp_message *msg, struct sp_session *session)
if (session->credentials.stored_cred_len == 0)
return -1;
client_info.client_id = SP_CLIENT_ID_HEX;
// This is our second login5 request - Spotify returned challenges after the first.
// The login_context is echoed from Spotify's response to the first login5.
if (session->hashcash_challenges)
{
login_context_len = session->hashcash_challenges->ctx_len;
login_context = malloc(login_context_len);
memcpy(login_context, session->hashcash_challenges->ctx, login_context_len);
ret = challenge_solutions_append(&solutions, session->hashcash_challenges, session->n_hashcash_challenges);
hashcash_challenges_free(&session->hashcash_challenges, &session->n_hashcash_challenges);
if (ret < 0)
goto error;
req.challenge_solutions = &solutions;
req.login_context.data = login_context;
req.login_context.len = login_context_len;
}
client_info.client_id = sp_sysinfo.client_id;
client_info.device_id = sp_sysinfo.device_id;
req.client_info = &client_info;
@ -1789,7 +1928,25 @@ msg_make_login5(struct sp_message *msg, struct sp_session *session)
hreq->headers[1] = asprintf_or_die("Content-Type: application/x-protobuf");
hreq->headers[2] = asprintf_or_die("Client-Token: %s", session->http_clienttoken.value);
challenge_solutions_free(&solutions);
free(login_context);
return 0;
error:
challenge_solutions_free(&solutions);
free(login_context);
return -1;
}
static int
msg_make_login5_challenges(struct sp_message *msg, struct sp_session *session)
{
// Spotify didn't give us any challenges during login5, so we can just proceed
if (!session->hashcash_challenges)
return 1; // Continue to next message
// Otherwise make another login5 request that includes the challenge responses
return msg_make_login5(msg, session);
}
// Ref. spclient/spclient.go
@ -1893,6 +2050,7 @@ static struct sp_seq_request seq_requests[][7] =
// The first two will be skipped if valid tokens already exist
{ SP_SEQ_MEDIA_OPEN, "CLIENTTOKEN", SP_PROTO_HTTP, msg_make_clienttoken, NULL, handle_clienttoken, },
{ SP_SEQ_MEDIA_OPEN, "LOGIN5", SP_PROTO_HTTP, msg_make_login5, NULL, handle_login5, },
{ SP_SEQ_MEDIA_OPEN, "LOGIN5_CHALLENGES", SP_PROTO_HTTP, msg_make_login5_challenges, NULL, handle_login5, },
{ SP_SEQ_MEDIA_OPEN, "METADATA_GET", SP_PROTO_HTTP, msg_make_metadata_get, NULL, handle_metadata_get, },
{ SP_SEQ_MEDIA_OPEN, "AUDIO_KEY_GET", SP_PROTO_TCP, msg_make_audio_key_get, prepare_tcp, handle_tcp_generic, },
{ SP_SEQ_MEDIA_OPEN, "STORAGE_RESOLVE", SP_PROTO_HTTP, msg_make_storage_resolve, NULL, handle_storage_resolve, },

View File

@ -13,6 +13,7 @@
/* ----------------------------------- Crypto ------------------------------- */
#define SHA512_DIGEST_LENGTH 64
#define SHA1_DIGEST_LENGTH 20
#define bnum_new(bn) \
do { \
if (!gcry_control(GCRYCTL_INITIALIZATION_FINISHED_P)) { \
@ -462,3 +463,129 @@ crypto_base62_to_bin(uint8_t *out, size_t out_len, const char *in)
bnum_free(base);
return -1;
}
static int
count_trailing_zero_bits(uint8_t *data, size_t data_len)
{
int zero_bits = 0;
size_t idx;
int bit;
for (idx = data_len - 1; idx >= 0; idx--)
{
for (bit = 0; bit < 8; bit++)
{
if (data[idx] & (1 << bit))
return zero_bits;
zero_bits++;
}
}
return zero_bits;
}
static void
sha1_sum(uint8_t *digest, uint8_t *data, size_t data_len, gcry_md_hd_t hdl)
{
gcry_md_reset(hdl);
gcry_md_write(hdl, data, data_len);
gcry_md_final(hdl);
memcpy(digest, gcry_md_read(hdl, GCRY_MD_SHA1), SHA1_DIGEST_LENGTH);
}
static void
sha1_two_part_sum(uint8_t *digest, uint8_t *data1, size_t data1_len, uint8_t *data2, size_t data2_len, gcry_md_hd_t hdl)
{
gcry_md_reset(hdl);
gcry_md_write(hdl, data1, data1_len);
gcry_md_write(hdl, data2, data2_len);
gcry_md_final(hdl);
memcpy(digest, gcry_md_read(hdl, GCRY_MD_SHA1), SHA1_DIGEST_LENGTH);
}
static inline void
increase_hashcash(uint8_t *data, int idx)
{
while (++data[idx] == 0 && idx > 0)
idx--;
}
static void
timespec_sub(struct timespec *a, struct timespec *b, struct timespec *result)
{
result->tv_sec = a->tv_sec - b->tv_sec;
result->tv_nsec = a->tv_nsec - b->tv_nsec;
if (result->tv_nsec < 0)
{
--result->tv_sec;
result->tv_nsec += 1000000000L;
}
}
// Example challenge:
// - loginctx 0300c798435c4b0beb91e3b1db591d0a7f2e32816744a007af41cc7c8043b9295e1ed8a13cc323e4af2d0a3c42463b7a358ed116c33695989e0bfade0dab9c6bc6f7f928df5d49069e8ca4c04c34034669fc97e93da1ca17a7c11b2ffbb9b85f2265b10f6c83f7ef672240cb535eb122265da9b6f8d1a55af522fcbb40efc4eb753756ea38a63aff95d3228219afb0ab887075ac2fe941f7920fd19d32226052fe0956c71f0cb63ba702dd72d50d769920cd99ec6a45e00c85af5287b5d0031d6be4072efe71c59dffa5baa4077cd2eab4f22143eff18c31c69b8647e7f517468c84ed9548943fb1ba6b750ef63cdf9ce0a0fd07cb22d19484f4baa8ee6fa35fc573d9
// - prefix 48859603d6c16c3202292df155501c55
// - length (difficulty) 10
// Solution:
// - suffix 7f7e558bd10c37d200000000000002c7
int
crypto_hashcash_solve(struct crypto_hashcash_solution *solution, struct crypto_hashcash_challenge *challenge, const char **errmsg)
{
gcry_md_hd_t hdl;
struct timespec start_ts;
struct timespec stop_ts;
uint8_t digest[SHA1_DIGEST_LENGTH];
bool solution_found = false;
int i;
// 1. Hash loginctx
// 2. Create a 16 byte suffix, fill first 8 bytes with last 8 bytes of hash, last with zeroes
// 3. Hash challenge prefix + suffix
// 4. Check if X last bits of hash is zeroes, where X is challenge length
// 5. If not, increment both 8-byte parts of suffix and goto 3
memset(solution, 0, sizeof(struct crypto_hashcash_solution));
if (gcry_md_open(&hdl, GCRY_MD_SHA1, 0) != GPG_ERR_NO_ERROR)
{
*errmsg = "Error initialising SHA1 hasher";
return -1;
}
sha1_sum(digest, challenge->ctx, challenge->ctx_len, hdl);
memcpy(solution->suffix, digest + SHA1_DIGEST_LENGTH - 8, 8);
clock_gettime(CLOCK_MONOTONIC, &start_ts);
for (i = 0; i < challenge->max_iterations; i++)
{
sha1_two_part_sum(digest, challenge->prefix, sizeof(challenge->prefix), solution->suffix, sizeof(solution->suffix), hdl);
solution_found = (count_trailing_zero_bits(digest, SHA1_DIGEST_LENGTH) >= challenge->wanted_zero_bits);
if (solution_found)
break;
increase_hashcash(solution->suffix, 7);
increase_hashcash(solution->suffix + 8, 7);
}
clock_gettime(CLOCK_MONOTONIC, &stop_ts);
timespec_sub(&stop_ts, &start_ts, &solution->duration);
gcry_md_close(hdl);
if (!solution_found)
{
*errmsg = "Could not find a hashcash solution";
return -1;
}
return 0;
}

View File

@ -4,6 +4,7 @@
#include <inttypes.h>
#include <stddef.h>
#include <gcrypt.h>
#include <time.h>
#include "shannon/Shannon.h"
@ -33,6 +34,26 @@ struct crypto_keys
size_t shared_secret_len;
};
struct crypto_hashcash_challenge
{
uint8_t *ctx;
size_t ctx_len;
uint8_t prefix[16];
// Required number of trailing zero bits in the SHA1 of prefix and suffix.
// More bits -> more difficult.
int wanted_zero_bits;
// Give up limit
int max_iterations;
};
struct crypto_hashcash_solution
{
uint8_t suffix[16];
struct timespec duration;
};
void
crypto_shared_secret(uint8_t **shared_secret_bytes, size_t *shared_secret_bytes_len,
@ -72,4 +93,7 @@ crypto_aes_decrypt(uint8_t *encrypted, size_t encrypted_len, struct crypto_aes_c
int
crypto_base62_to_bin(uint8_t *out, size_t out_len, const char *in);
int
crypto_hashcash_solve(struct crypto_hashcash_solution *solution, struct crypto_hashcash_challenge *challenge, const char **errmsg);
#endif /* __CRYPTO_H__ */

View File

@ -81,8 +81,11 @@
#define SP_CLIENT_VERSION_DEFAULT "0.0.0"
#define SP_CLIENT_BUILD_ID_DEFAULT "aabbccdd"
// ClientIdHex from client_id.go
#define SP_CLIENT_ID_HEX "65b708073fc0480ea92a077233ca87bd"
// ClientIdHex from client_id.go. This seems to be the id that Spotify's own app
// uses. It is used in the call to https://clienttoken.spotify.com/v1/clienttoken.
// The endpoint doesn't accept client ID's of app registered at
// develop.spotify.com, so unfortunately spoofing is required.
#define SP_CLIENT_ID_DEFAULT "65b708073fc0480ea92a077233ca87bd"
// Shorthand for error handling
#define RETURN_ERROR(r, m) \
@ -382,6 +385,9 @@ struct sp_session
struct sp_token http_clienttoken;
struct sp_token http_accesstoken;
int n_hashcash_challenges;
struct crypto_hashcash_challenge *hashcash_challenges;
bool is_logged_in;
struct sp_credentials credentials;
char country[3]; // Incl null term

View File

@ -1016,6 +1016,8 @@ system_info_set(struct sp_sysinfo *si_out, struct sp_sysinfo *si_user)
if (si_out->client_name[0] == '\0')
snprintf(si_out->client_name, sizeof(si_out->client_name), SP_CLIENT_NAME_DEFAULT);
if (si_out->client_id[0] == '\0')
snprintf(si_out->client_id, sizeof(si_out->client_id), SP_CLIENT_ID_DEFAULT);
if (si_out->client_version[0] == '\0')
snprintf(si_out->client_version, sizeof(si_out->client_version), SP_CLIENT_VERSION_DEFAULT);
if (si_out->client_build_id[0] == '\0')
@ -1035,7 +1037,6 @@ librespotc_init(struct sp_sysinfo *sysinfo, struct sp_callbacks *callbacks)
RETURN_ERROR(SP_ERR_INVALID, "Bug! Misalignment between enum seq_type and seq_requests");
sp_cb = *callbacks;
sp_initialized = true;
system_info_set(&sp_sysinfo, sysinfo);
@ -1054,6 +1055,7 @@ librespotc_init(struct sp_sysinfo *sysinfo, struct sp_callbacks *callbacks)
if (sp_cb.thread_name_set)
sp_cb.thread_name_set(sp_tid);
sp_initialized = true;
return 0;
error: