commit baac0bcc238112294c301be09f0365ccd69a9b33 Author: Vitaly Gashkov Date: Wed Mar 26 13:19:45 2025 +0500 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68b21f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/node_modules +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6ed8df4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Vitaly Gashkov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c30e7e2 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# Crunchys + +A utility to extract secrets from Crunchyroll mobile app + +## Prerequisites + +- [Node.js](https://nodejs.org/en) +- [jadx](https://github.com/skylot/jadx) + +## Installation + +```bash +npm i crunchys +``` + +## Usage + +#### Library + +```js +import { extractSecrets } from 'crunchys'; + +const { id, secret, encoded, header } = await extractSecrets(); + +// Do something with the extracted secrets +``` + +#### Command-line interface + +```bash +npx crunchys +``` + +## License + +MIT diff --git a/crunchys-cli.js b/crunchys-cli.js new file mode 100644 index 0000000..373e288 --- /dev/null +++ b/crunchys-cli.js @@ -0,0 +1,5 @@ +import { extractSecrets } from './crunchys'; + +(async () => { + await extractSecrets(); +})(); diff --git a/crunchys.d.ts b/crunchys.d.ts new file mode 100644 index 0000000..fac7cb0 --- /dev/null +++ b/crunchys.d.ts @@ -0,0 +1,10 @@ +export function extractSecrets(): Promise<{ + // Crunchyroll app ID + id: string; + // Crunchyroll app secret + secret: string; + // Base64 encoded `id:secret` string + encoded: string; + // Basic `Authorization` header to access Crunchyroll mobile APIs + header: string; +}>; diff --git a/crunchys.js b/crunchys.js new file mode 100644 index 0000000..b4ddf81 --- /dev/null +++ b/crunchys.js @@ -0,0 +1,90 @@ +const { join, basename } = require('node:path'); +const { createWriteStream } = require('node:fs'); +const { readdir, readFile, rm } = require('node:fs/promises'); +const { execSync } = require('node:child_process'); +const { Readable } = require('node:stream'); + +const downloadLatestApk = async () => { + const source = 'https://www.apk20.com/apk/com.crunchyroll.crunchyroid/download/'; + const page = await fetch(source); + const html = await page.text(); + const url = html.split(' writer.on('finish', resolve)); + } + return join(process.cwd(), fileName); +}; + +const decompileApk = (apkPath) => { + try { + execSync(`jadx ${apkPath}`, { stdio: 'inherit' }); + } catch (error) { + console.error(error); + } + return apkPath.replace('.xapk', ''); +}; + +const findConfigurationImpl = async (sourcesDir) => { + const entries = await readdir(sourcesDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const moduleDir = join(sourcesDir, entry.name); + const moduleEntries = await readdir(moduleDir, { withFileTypes: true }); + for (const moduleFile of moduleEntries) { + if (moduleFile.isDirectory()) continue; + const moduleFilePath = join(moduleDir, moduleFile.name); + const moduleContents = await readFile(moduleFilePath, 'utf8'); + if (moduleContents.includes(' ConfigurationImpl.kt')) { + return moduleContents; + } + } + } +}; + +const parseSecrets = (contents) => { + const lines = contents.split('\n'); + const startIndex = lines.findIndex((line) => line.includes('https://sso.crunchyroll.com')); + const endIndex = lines.findIndex((line) => line.includes('CR-AndroidMobile-SSAI-Prod')); + const results = lines + .slice(startIndex, endIndex) + .map((line) => line.replaceAll(';', '')) + .map((line) => line.replaceAll('"', '')) + .map((line) => line.split('= ')[1]) + .map((line) => line.trim()); + const [, , id, secret] = results; + const encoded = Buffer.from(`${id}:${secret}`).toString('base64'); + const header = `Basic ${encoded}`; + return { id, secret, encoded, header }; +}; + +export const extractSecrets = async ({ cleanup = true } = {}) => { + console.log('Downloading latest APK...'); + const apkPath = await downloadLatestApk(); + + console.log('Decompiling APK...'); + const decompiledDir = decompileApk(apkPath); + + console.log('Searching for secrets...'); + const sourcesDir = join(decompiledDir, 'sources'); + const configurationImpl = await findConfigurationImpl(sourcesDir); + + console.log('Parsing secrets...'); + const { id, secret, encoded, header } = parseSecrets(configurationImpl); + + console.log(`Cleaning up files...`); + if (cleanup) await rm(apkPath, { recursive: true, force: true }); + if (cleanup) await rm(decompiledDir, { recursive: true, force: true }); + + console.log(`ID: ${id}`); + console.log(`Secret: ${secret}`); + console.log(`Encoded ID with secret: ${encoded}`); + + return { id, secret, encoded, header }; +}; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..cbb47f5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,33 @@ +{ + "name": "crunchys", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "crunchys", + "version": "1.0.0", + "license": "MIT", + "bin": { + "crunchys": "cli.js" + }, + "devDependencies": { + "typescript": "^5.8.2" + } + }, + "node_modules/typescript": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", + "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..03a55ec --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "crunchys", + "version": "1.0.0", + "description": "A utility to extract secrets from Crunchyroll mobile app", + "main": "crunchys.js", + "types": "crunchys.d.ts", + "bin": "crunchys-cli.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "Vitaly Gashkov ", + "license": "MIT", + "devDependencies": { + "typescript": "^5.8.2" + } +}