mirror of
https://github.com/samuelthomas2774/nxapi.git
synced 2026-04-02 07:35:16 -05:00
588 lines
19 KiB
TypeScript
588 lines
19 KiB
TypeScript
import process from 'node:process';
|
|
import * as path from 'node:path';
|
|
import { execFileSync } from 'node:child_process';
|
|
import * as net from 'node:net';
|
|
import * as fs from 'node:fs/promises';
|
|
import * as crypto from 'node:crypto';
|
|
import createDebug from 'debug';
|
|
import { v4 as uuidgen } from 'uuid';
|
|
import express from 'express';
|
|
import bodyParser from 'body-parser';
|
|
import mkdirp from 'mkdirp';
|
|
import type { Arguments as ParentArguments } from '../cli.js';
|
|
import { NintendoAccountIdTokenJwtPayload } from '../api/na.js';
|
|
import { CoralJwtPayload, ZNCA_CLIENT_ID } from '../api/coral.js';
|
|
import { ArgumentsCamelCase, Argv, YargsArguments } from '../util/yargs.js';
|
|
import { initStorage, paths } from '../util/storage.js';
|
|
import { getJwks, Jwt } from '../util/jwt.js';
|
|
import { product } from '../util/product.js';
|
|
import { parseListenAddress } from '../util/net.js';
|
|
|
|
const debug = createDebug('cli:android-znca-api-server-frida');
|
|
const debugApi = createDebug('cli:android-znca-api-server-frida:api');
|
|
|
|
const script_dir = path.join(paths.temp, 'android-znca-api-server');
|
|
|
|
export const command = 'android-znca-api-server-frida <device>';
|
|
export const desc = 'Connect to a rooted Android device with frida-server over ADB running the Nintendo Switch Online app and start a HTTP server to generate f parameters';
|
|
|
|
export function builder(yargs: Argv<ParentArguments>) {
|
|
return yargs.positional('device', {
|
|
describe: 'ADB server address/port',
|
|
type: 'string',
|
|
demandOption: true,
|
|
}).option('exec-command', {
|
|
describe: 'Command to use to run a file on the device',
|
|
type: 'string',
|
|
}).option('frida-server-path', {
|
|
describe: 'Path to the frida-server executable on the device',
|
|
type: 'string',
|
|
default: '/data/local/tmp/frida-server',
|
|
}).option('validate-tokens', {
|
|
describe: 'Validate tokens before passing them to znca',
|
|
type: 'boolean',
|
|
default: true,
|
|
}).option('listen', {
|
|
describe: 'Server address and port',
|
|
type: 'array',
|
|
default: ['[::]:0'],
|
|
});
|
|
}
|
|
|
|
type Arguments = YargsArguments<ReturnType<typeof builder>>;
|
|
|
|
interface PackageInfo {
|
|
name: string;
|
|
version: string;
|
|
build: number;
|
|
}
|
|
interface SystemInfo {
|
|
board: string;
|
|
bootloader: string;
|
|
brand: string;
|
|
abis: string[];
|
|
device: string;
|
|
display: string;
|
|
fingerprint: string;
|
|
hardware: string;
|
|
host: string;
|
|
id: string;
|
|
manufacturer: string;
|
|
model: string;
|
|
product: string;
|
|
tags: string;
|
|
time: string;
|
|
type: string;
|
|
user: string;
|
|
|
|
version: {
|
|
codename: string;
|
|
release: string;
|
|
// release_display: string;
|
|
sdk: string;
|
|
sdk_int: number;
|
|
security_patch: string;
|
|
};
|
|
}
|
|
|
|
interface FResult {
|
|
f: string;
|
|
timestamp: string;
|
|
}
|
|
|
|
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
|
await mkdirp(script_dir);
|
|
|
|
const storage = await initStorage(argv.dataPath);
|
|
await setup(argv);
|
|
|
|
let {session, script} = await attach(argv);
|
|
let ready: Promise<void> | null = null;
|
|
|
|
let api: {
|
|
ping(): Promise<true>;
|
|
getPackageInfo(): Promise<PackageInfo>;
|
|
getSystemInfo(): Promise<SystemInfo>;
|
|
genAudioH(token: string, timestamp: string | number | undefined, request_id: string): Promise<FResult>;
|
|
genAudioH2(token: string, timestamp: string | number | undefined, request_id: string): Promise<FResult>;
|
|
} = script.exports as any;
|
|
|
|
let system_info = await api.getSystemInfo();
|
|
let package_info = await api.getPackageInfo();
|
|
|
|
const onexit = (code: number | NodeJS.Signals) => {
|
|
// @ts-expect-error
|
|
process.removeListener('exit', onexit);
|
|
// @ts-expect-error
|
|
process.removeListener('SIGTERM', onexit);
|
|
// @ts-expect-error
|
|
process.removeListener('SIGINT', onexit);
|
|
|
|
debug('Exiting', code);
|
|
debug('Releasing wake lock', argv.device);
|
|
execScript(argv.device, '/data/local/tmp/android-znca-api-server-shutdown.sh', argv.execCommand);
|
|
process.exit(typeof code === 'number' ? code : 0);
|
|
};
|
|
|
|
process.on('exit', onexit);
|
|
process.on('SIGTERM', onexit);
|
|
process.on('SIGINT', onexit);
|
|
|
|
function reattach() {
|
|
// Already attempting to reattach
|
|
if (ready) return;
|
|
|
|
debug('Attempting to reconnect to the device');
|
|
|
|
ready = attach(argv).then(async a => {
|
|
ready = null;
|
|
session = a.session;
|
|
script = a.script;
|
|
api = script.exports as any;
|
|
|
|
const new_system_info = await api.getSystemInfo();
|
|
const new_package_info = await api.getPackageInfo();
|
|
|
|
if (system_info.version.sdk_int !== new_system_info.version.sdk_int) {
|
|
debug('Android system version updated while disconnected');
|
|
}
|
|
if (package_info.build !== new_package_info.build) {
|
|
debug('znca version updated while disconnected');
|
|
}
|
|
|
|
system_info = new_system_info;
|
|
package_info = new_package_info;
|
|
}).catch(err => {
|
|
console.error('Reattach failed', err);
|
|
process.exit(1);
|
|
});
|
|
}
|
|
|
|
const app = express();
|
|
|
|
app.use('/api/znca', (req, res, next) => {
|
|
console.log('[%s] %s %s HTTP/%s from %s, port %d%s, %s',
|
|
new Date(), req.method, req.path, req.httpVersion,
|
|
req.socket.remoteAddress, req.socket.remotePort,
|
|
req.headers['x-forwarded-for'] ? ' (' + req.headers['x-forwarded-for'] + ')' : '',
|
|
req.headers['user-agent']);
|
|
|
|
res.setHeader('Server', product + ' android-znca-api-frida');
|
|
res.setHeader('X-Android-Build-Type', system_info.type);
|
|
res.setHeader('X-Android-Release', system_info.version.release);
|
|
res.setHeader('X-Android-Platform-Version', system_info.version.sdk_int);
|
|
res.setHeader('X-znca-Platform', 'Android');
|
|
res.setHeader('X-znca-Version', package_info.version);
|
|
res.setHeader('X-znca-Build', package_info.build);
|
|
|
|
next();
|
|
});
|
|
|
|
app.post('/api/znca/f', bodyParser.json(), async (req, res) => {
|
|
try {
|
|
await ready;
|
|
|
|
let data: {
|
|
hash_method: '1' | '2' | 1 | 2;
|
|
token: string;
|
|
timestamp?: string | number;
|
|
request_id?: string;
|
|
} | {
|
|
type: 'nso' | 'app';
|
|
token: string;
|
|
timestamp?: string;
|
|
uuid?: string;
|
|
} = req.body;
|
|
|
|
if (data && 'type' in data) data = {
|
|
hash_method:
|
|
data.type === 'nso' ? '1' :
|
|
data.type === 'app' ? '2' : null!,
|
|
token: data.token,
|
|
timestamp: '' + data.timestamp,
|
|
request_id: data.uuid,
|
|
};
|
|
|
|
if (data && data.hash_method === 1) data.hash_method = '1';
|
|
if (data && data.hash_method === 2) data.hash_method = '2';
|
|
|
|
if (
|
|
!data ||
|
|
typeof data !== 'object' ||
|
|
(data.hash_method !== '1' && data.hash_method !== '2') ||
|
|
typeof data.token !== 'string' ||
|
|
(data.timestamp && typeof data.timestamp !== 'string' && typeof data.timestamp !== 'number') ||
|
|
(data.request_id && typeof data.request_id !== 'string')
|
|
) {
|
|
res.statusCode = 400;
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(JSON.stringify({error: 'invalid_request'}));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const [jwt, sig] = Jwt.decode<NintendoAccountIdTokenJwtPayload | CoralJwtPayload>(data.token);
|
|
|
|
const check_signature = jwt.payload.iss === 'https://accounts.nintendo.com';
|
|
|
|
if (data.hash_method === '1' && jwt.payload.iss !== 'https://accounts.nintendo.com') {
|
|
throw new Error('Invalid token issuer');
|
|
}
|
|
if (data.hash_method === '1' && jwt.payload.aud !== ZNCA_CLIENT_ID) {
|
|
throw new Error('Invalid token audience');
|
|
}
|
|
if (data.hash_method === '2' && jwt.payload.iss !== 'api-lp1.znc.srv.nintendo.net') {
|
|
throw new Error('Invalid token issuer');
|
|
}
|
|
|
|
if (jwt.payload.exp <= (Date.now() / 1000)) {
|
|
throw new Error('Token expired');
|
|
}
|
|
|
|
const jwks = jwt.header.kid &&
|
|
jwt.header.jku?.match(/^https\:\/\/([^/]+\.)?nintendo\.(com|net)(\/|$)/i) ?
|
|
await getJwks(jwt.header.jku, storage) : null;
|
|
|
|
if (check_signature && !jwks) {
|
|
throw new Error('Requires signature verification, but trusted JWKS URL and key ID not included in token');
|
|
}
|
|
|
|
const jwk = jwks?.keys.find(jwk => jwk.use === 'sig' && jwk.alg === jwt.header.alg &&
|
|
jwk.kid === jwt.header.kid && jwk.x5c?.length);
|
|
const cert = jwk?.x5c?.[0] ? '-----BEGIN CERTIFICATE-----\n' +
|
|
jwk.x5c[0].match(/.{1,64}/g)!.join('\n') + '\n-----END CERTIFICATE-----\n' : null;
|
|
|
|
if (!cert) {
|
|
if (check_signature) throw new Error('Not verifying signature, no JKW found for this token');
|
|
else debug('Not verifying signature, no JKW found for this token');
|
|
}
|
|
|
|
const signature_valid = cert && jwt.verify(sig, cert);
|
|
|
|
if (check_signature && !signature_valid) {
|
|
throw new Error('Invalid signature');
|
|
}
|
|
|
|
if (!check_signature) {
|
|
if (signature_valid) debug('JWT signature is valid');
|
|
else debug('JWT signature is not valid or not checked');
|
|
}
|
|
} catch (err) {
|
|
if (argv.validateTokens) {
|
|
debug('Error validating token from %s', req.ip, err);
|
|
res.statusCode = 400;
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(JSON.stringify({error: 'invalid_token', error_message: (err as Error).message}));
|
|
return;
|
|
} else {
|
|
debug('Error validating token from %s, continuing anyway', req.ip, err);
|
|
}
|
|
}
|
|
|
|
const timestamp = data.timestamp ? '' + data.timestamp : undefined;
|
|
const request_id = data.request_id ? data.request_id : uuidgen();
|
|
|
|
debugApi('Calling %s', data.hash_method === '2' ? 'genAudioH2' : 'genAudioH');
|
|
|
|
const result = data.hash_method === '2' ?
|
|
await api.genAudioH2(data.token, timestamp, request_id) :
|
|
await api.genAudioH(data.token, timestamp, request_id);
|
|
|
|
debugApi('Returned %s', result);
|
|
|
|
const response = {
|
|
f: result.f,
|
|
timestamp: data.timestamp ? undefined : result.timestamp,
|
|
request_id: data.request_id ? undefined : request_id,
|
|
};
|
|
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(JSON.stringify(response));
|
|
} catch (err) {
|
|
debugApi('Error in request from %s', req.ip, err);
|
|
|
|
res.statusCode = 500;
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(JSON.stringify({error: 'unknown'}));
|
|
|
|
if ((err as any)?.message === 'Script is destroyed') {
|
|
reattach();
|
|
}
|
|
}
|
|
});
|
|
|
|
for (const address of argv.listen) {
|
|
const [host, port] = parseListenAddress(address);
|
|
const server = app.listen(port, host ?? '::');
|
|
server.on('listening', () => {
|
|
const address = server.address() as net.AddressInfo;
|
|
console.log('Listening on %s, port %d', address.address, address.port);
|
|
});
|
|
}
|
|
|
|
setInterval(async () => {
|
|
try {
|
|
await api.ping();
|
|
} catch (err) {
|
|
if ((err as any)?.message === 'Script is destroyed') {
|
|
reattach();
|
|
return;
|
|
}
|
|
|
|
throw err;
|
|
}
|
|
}, 5000);
|
|
|
|
debug('System info', system_info);
|
|
debug('Package info', package_info);
|
|
|
|
try {
|
|
debug('Test gen_audio_h');
|
|
const result = await api.genAudioH('id_token', 'timestamp', 'request_id');
|
|
debug('Test returned', result);
|
|
} catch (err) {
|
|
debug('Test failed', err);
|
|
}
|
|
}
|
|
|
|
const frida_script = `
|
|
const perform = callback => new Promise((rs, rj) => {
|
|
Java.scheduleOnMainThread(() => {
|
|
try {
|
|
rs(callback());
|
|
} catch (err) {
|
|
rj(err);
|
|
}
|
|
});
|
|
});
|
|
|
|
rpc.exports = {
|
|
ping() {
|
|
return true;
|
|
},
|
|
getPackageInfo() {
|
|
return perform(() => {
|
|
const context = Java.use('android.app.ActivityThread').currentApplication().getApplicationContext();
|
|
|
|
const info = context.getPackageManager().getPackageInfo(context.getPackageName(), 0);
|
|
|
|
return {
|
|
name: info.packageName.value,
|
|
version: info.versionName.value,
|
|
build: info.versionCode.value,
|
|
// build: info.getLongVersionCode(),
|
|
};
|
|
});
|
|
},
|
|
getSystemInfo() {
|
|
return perform(() => {
|
|
const Build = Java.use('android.os.Build');
|
|
const Version = Java.use('android.os.Build$VERSION');
|
|
|
|
return {
|
|
board: Build.BOARD.value,
|
|
bootloader: Build.BOOTLOADER.value,
|
|
brand: Build.BRAND.value,
|
|
abis: Build.SUPPORTED_ABIS.value,
|
|
device: Build.DEVICE.value,
|
|
display: Build.DISPLAY.value,
|
|
fingerprint: Build.FINGERPRINT.value,
|
|
hardware: Build.HARDWARE.value,
|
|
host: Build.HOST.value,
|
|
id: Build.ID.value,
|
|
manufacturer: Build.MANUFACTURER.value,
|
|
model: Build.MODEL.value,
|
|
product: Build.PRODUCT.value,
|
|
tags: Build.TAGS.value,
|
|
time: Build.TIME.value,
|
|
type: Build.TYPE.value,
|
|
user: Build.USER.value,
|
|
|
|
version: {
|
|
codename: Version.CODENAME.value,
|
|
release: Version.RELEASE.value,
|
|
sdk: Version.SDK.value,
|
|
sdk_int: Version.SDK_INT.value,
|
|
security_patch: Version.SECURITY_PATCH.value,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
genAudioH(token, timestamp, request_id) {
|
|
return perform(() => {
|
|
const libvoip = Java.use('com.nintendo.coral.core.services.voip.LibvoipJni');
|
|
const context = Java.use('android.app.ActivityThread').currentApplication().getApplicationContext();
|
|
libvoip.init(context);
|
|
|
|
if (!timestamp) timestamp = Date.now();
|
|
|
|
return {
|
|
f: libvoip.genAudioH(token, '' + timestamp, request_id),
|
|
timestamp,
|
|
};
|
|
});
|
|
},
|
|
genAudioH2(token, timestamp, request_id) {
|
|
return perform(() => {
|
|
const libvoip = Java.use('com.nintendo.coral.core.services.voip.LibvoipJni');
|
|
const context = Java.use('android.app.ActivityThread').currentApplication().getApplicationContext();
|
|
libvoip.init(context);
|
|
|
|
if (!timestamp) timestamp = Date.now();
|
|
|
|
return {
|
|
f: libvoip.genAudioH2(token, '' + timestamp, request_id),
|
|
timestamp,
|
|
};
|
|
});
|
|
},
|
|
};
|
|
`;
|
|
|
|
const setup_script = (options: {
|
|
frida_server_path: string;
|
|
}) => `#!/system/bin/sh
|
|
|
|
# Ensure frida-server is running
|
|
echo "Running frida-server"
|
|
killall ${JSON.stringify(path.basename(options.frida_server_path))}
|
|
nohup ${JSON.stringify(options.frida_server_path)} >/dev/null 2>&1 &
|
|
|
|
if [ "$?" != "0" ]; then
|
|
echo "Failed to start frida-server"
|
|
exit 1
|
|
fi
|
|
|
|
sleep 1
|
|
|
|
# Ensure the app is running
|
|
echo "Starting com.nintendo.znca"
|
|
am start-foreground-service com.nintendo.znca/com.google.firebase.messaging.FirebaseMessagingService
|
|
am start-service com.nintendo.znca/com.google.firebase.messaging.FirebaseMessagingService
|
|
|
|
if [ "$?" != "0" ]; then
|
|
echo "Failed to start com.nintendo.znca"
|
|
exit 1
|
|
fi
|
|
|
|
echo "Acquiring wake lock"
|
|
echo androidzncaapiserver > /sys/power/wake_lock
|
|
`;
|
|
|
|
const shutdown_script = `#!/system/bin/sh
|
|
|
|
echo "Releasing wake lock"
|
|
echo androidzncaapiserver > /sys/power/wake_unlock
|
|
`;
|
|
|
|
async function setup(argv: ArgumentsCamelCase<Arguments>) {
|
|
debug('Connecting to device %s', argv.device);
|
|
let co = execFileSync('adb', [
|
|
'connect',
|
|
argv.device,
|
|
]);
|
|
|
|
while (co.toString().includes('failed to authenticate')) {
|
|
console.log('');
|
|
console.log('-- Allow this computer to connect to the device. --');
|
|
console.log('');
|
|
await new Promise(rs => setTimeout(rs, 5 * 1000));
|
|
|
|
execAdb([
|
|
'disconnect',
|
|
argv.device,
|
|
]);
|
|
|
|
debug('Connecting to device %s', argv.device);
|
|
co = execFileSync('adb', [
|
|
'connect',
|
|
argv.device,
|
|
]);
|
|
}
|
|
|
|
debug('Pushing scripts');
|
|
|
|
await pushScript(argv.device, setup_script({
|
|
frida_server_path: argv.fridaServerPath,
|
|
}), '/data/local/tmp/android-znca-api-server-setup.sh');
|
|
await pushScript(argv.device, shutdown_script, '/data/local/tmp/android-znca-api-server-shutdown.sh');
|
|
}
|
|
|
|
async function attach(argv: ArgumentsCamelCase<Arguments>) {
|
|
const frida = await import('frida');
|
|
type Session = import('frida').Session;
|
|
|
|
debug('Running scripts');
|
|
execScript(argv.device, '/data/local/tmp/android-znca-api-server-setup.sh', argv.execCommand);
|
|
|
|
debug('Done');
|
|
|
|
const device = await frida.getDevice(argv.device);
|
|
debug('Connected to frida device %s', device.name);
|
|
|
|
let session: Session;
|
|
|
|
try {
|
|
const process = await device.getProcess('Nintendo Switch Online');
|
|
|
|
debug('process', process);
|
|
|
|
session = await device.attach(process.pid);
|
|
} catch (err) {
|
|
debug('Could not attach to process', err);
|
|
throw new Error('Failed to attach to process');
|
|
}
|
|
|
|
debug('Attached to app process, pid %d', session.pid);
|
|
|
|
const script = await session.createScript(frida_script);
|
|
await script.load();
|
|
|
|
return {session, script};
|
|
}
|
|
|
|
function execAdb(args: string[], device?: string) {
|
|
execFileSync('adb', device ? ['-s', device, ...args] : args, {
|
|
stdio: 'inherit',
|
|
});
|
|
}
|
|
|
|
async function getScriptPath(content: string) {
|
|
const filename = path.join(script_dir, crypto.createHash('sha256').update(content).digest('hex') + '.sh');
|
|
|
|
await fs.writeFile(filename, content);
|
|
await fs.chmod(filename, 0o755);
|
|
|
|
return filename;
|
|
}
|
|
|
|
async function pushScript(device: string, content: string, path: string) {
|
|
const filename = await getScriptPath(content);
|
|
|
|
debug('Pushing script', path, filename);
|
|
|
|
execAdb([
|
|
'push',
|
|
filename,
|
|
path,
|
|
], device);
|
|
|
|
execAdb([
|
|
'shell',
|
|
'chmod 755 ' + JSON.stringify(path),
|
|
], device);
|
|
}
|
|
|
|
function execScript(device: string, path: string, exec_command?: string) {
|
|
const command = exec_command ?
|
|
exec_command.replace('{cmd}', JSON.stringify(path)) :
|
|
path;
|
|
|
|
debug('Running script', command);
|
|
|
|
execAdb([
|
|
'shell',
|
|
command,
|
|
], device);
|
|
}
|