spotify-secrets/main.ts

227 lines
6.6 KiB
TypeScript

import { type ESTree, parse } from "meriyah";
import { traverse, VisitorOption } from "estraverse";
import { z } from "zod";
type Candidate = { key: number; prio: number };
const HOOK = `(() => {
if (globalThis.__secretHookInstalled) return;
globalThis.__secretHookInstalled = true;
globalThis.__captures = [];
globalThis.__validUntil = null;
Object.defineProperty(Object.prototype, "secret", {
configurable: true,
set: function (v) {
__captures.push(this);
Object.defineProperty(this, "secret", {
value: v,
writable: true,
configurable: true,
enumerable: true,
});
},
});
})();
`;
const MOD_LOADER = `
const modEnv = {};
let currentlyImporting = null;
function n(id) {
if (modEnv[id]) {
return modEnv[id];
}
if (__webpack_modules__[id]) {
modEnv[id] = {};
currentlyImporting = id
__webpack_modules__[id]({id}, modEnv[id], n);
console.error("imported", id)
currentlyImporting = null;
return modEnv[id];
}
console.error(\`failed to import \${id} (during import of \${currentlyImporting})\`);
return {};
}
n.d = () => {};
`;
const READOUT = `
globalThis.__captures.filter((c) => c.secret && c.version)
`;
const outputSchema = z
.array(z.object({ secret: z.string().nonempty(), version: z.int().positive() }))
.nonempty();
type SpotifySecrets = z.infer<typeof outputSchema>;
type SpotifySecretDict = { [key: number]: number[] };
type SpotifySecretBytes = { version: number; secret: number[] }[];
function extractWebpackModules(playerJs: string): ESTree.ObjectExpression {
const ast = parse(playerJs, { ranges: true });
let wpm = null;
traverse(ast, {
enter: (node) => {
if (node.type === "VariableDeclaration") {
const found = node.declarations.find(
(d) => d.id.type === "Identifier" && d.id.name === "__webpack_modules__"
);
if (found?.init && found.init.type === "ObjectExpression") {
wpm = found.init;
console.error("__webpack_modules__ found at", found.init.range);
return VisitorOption.Break;
}
}
},
});
if (!wpm) throw new Error("could not find __webpack_modules__");
return wpm;
}
function findOtpModule(playerJs: string, wpm: ESTree.ObjectExpression): Candidate[] {
const searchPatterns = [
"Hash#digest()",
".validUntil",
".secrets",
'"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/="',
];
const candidates: { key: number; prio: number }[] = [];
wpm.properties.forEach((p) => {
if (
p.type === "Property" &&
p.key.type === "Literal" &&
typeof p.key.value === "number" &&
(p.value.type === "ArrowFunctionExpression" ||
p.value.type === "FunctionExpression") &&
p.value.body
) {
const body_js = playerJs.substring(p.value.body.start!, p.value.body.end!);
const prio = searchPatterns.findIndex((x) => body_js.includes(x));
if (prio !== -1) {
candidates.push({ key: p.key.value, prio });
}
}
return false;
});
if (candidates.length === 0) throw new Error("could not find OTP module");
candidates.sort((a, b) => b.prio - a.prio);
return candidates;
}
function getWpmString(playerJs: string, wpm: ESTree.ObjectExpression): string {
return "const __webpack_modules__ = " + playerJs.substring(wpm.start!, wpm.end!);
}
function buildEvalScript(wpmStr: string, otpCandidates: Candidate[]): string {
let otpCode = "\n\n";
otpCandidates.forEach((c) => {
otpCode += `n(${c.key});\n`;
});
return HOOK + MOD_LOADER + wpmStr + otpCode + READOUT;
}
function runEvalScript(evalScript: string): SpotifySecrets {
const res = (() => eval(evalScript))();
const spotifySecrets = outputSchema.parse(res);
spotifySecrets.sort((a, b) => a.version - b.version);
return spotifySecrets;
}
function secretsToBytes(secrets: SpotifySecrets): SpotifySecretBytes {
return secrets.map(({ version, secret }) => {
return { version, secret: Array.from(secret).map((c) => c.charCodeAt(0)) };
});
}
function secretsToDict(secrets: SpotifySecrets): SpotifySecretDict {
return Object.fromEntries(
secrets.map(({ version, secret }) => [
String(version),
Array.from(secret).map((c) => c.charCodeAt(0)),
])
);
}
const HTTP_OPTIONS = {
headers: {
"User-Agent":
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
},
};
function isOk(status: number) {
if (status < 200 || status > 299) throw new Error(`HTTP status code ${status}`);
}
async function fetchPlayerJsUrl(): Promise<string> {
const htmlResp = await fetch("https://open.spotify.com/", HTTP_OPTIONS);
isOk(htmlResp.status);
const html = await htmlResp.text();
const rePlayerJs = /"(https:\/\/[^" ]+\/web-player\.[0-9a-f]+\.js)"/;
const m = html.match(rePlayerJs);
if (!m) throw new Error("Player JS URL not found");
const playerJsUrl = m[1];
return playerJsUrl;
}
async function fetchPlayerJs(playerJsUrl: string): Promise<string> {
const jsResp = await fetch(playerJsUrl, HTTP_OPTIONS);
isOk(jsResp.status);
const ct = jsResp.headers.get("content-type");
if (!ct?.match(/text\/javascript\b/)) throw new Error(`Invalid content type: ${ct}`);
const playerJs = await jsResp.text();
return playerJs;
}
async function main() {
let playerJs;
if (Deno.args[0]) {
playerJs = Deno.readTextFileSync(Deno.args[0]);
} else {
const playerJsUrl = await fetchPlayerJsUrl();
console.error("Player JS URL", playerJsUrl);
let lastPlayerJsUrl = "";
try {
lastPlayerJsUrl = Deno.readTextFileSync("tmp/playerUrl.txt").trim();
// deno-lint-ignore no-empty
} catch {}
if (playerJsUrl === lastPlayerJsUrl) {
console.error("no player updates");
return;
}
playerJs = await fetchPlayerJs(playerJsUrl);
Deno.writeTextFileSync("tmp/playerUrl.txt", playerJsUrl);
}
const wpm = extractWebpackModules(playerJs);
const otpCandidates = findOtpModule(playerJs, wpm);
const wpmString = getWpmString(playerJs, wpm);
const evalScript = buildEvalScript(wpmString, otpCandidates);
const spotifySecrets = runEvalScript(evalScript);
const spotifySecretBytes = secretsToBytes(spotifySecrets);
const spotifySecretDict = secretsToDict(spotifySecrets);
console.log(spotifySecrets);
Deno.writeTextFileSync(
"secrets/secrets.json",
JSON.stringify(spotifySecrets, null, 2)
);
Deno.writeTextFileSync(
"secrets/secretBytes.json",
JSON.stringify(spotifySecretBytes)
);
Deno.writeTextFileSync("secrets/secretDict.json", JSON.stringify(spotifySecretDict));
}
if (import.meta.main) {
main()
}