From 80afc62268e10aa6df82d008495e100eef462cd6 Mon Sep 17 00:00:00 2001 From: Samuel Elliott Date: Wed, 7 Sep 2022 21:48:25 +0100 Subject: [PATCH] Add optional strict validation, automatically retry after reconnecting and report server timing for `f` parameter generation --- docs/cli.md | 12 +- src/cli/android-znca-api-server-frida.ts | 199 ++++++++++++++++------- 2 files changed, 148 insertions(+), 63 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index c933043..6fd89e9 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -531,10 +531,10 @@ This server has a single endpoint, `/api/znca/f`, which is fully compatible with ```ts interface AndroidZncaApiRequest { /** - * `"1"` for Coral (Nintendo Switch Online app) authentication (`Account/Login` and `Account/GetToken`). - * `"2"` for web service authentication (`Game/GetWebServiceToken`). + * `"1"` or `1` for Coral (Nintendo Switch Online app) authentication (`Account/Login` and `Account/GetToken`). + * `"2"` or `2` for web service authentication (`Game/GetWebServiceToken`). */ - hash_method: '1' | '2'; + hash_method: '1' | '2' | 1 | 2; /** * The token used to authenticate to the Coral API: * The Nintendo Account `id_token` for Coral authentication. @@ -578,6 +578,12 @@ nxapi android-znca-api-server-frida android.local:5555 --exec-command "/system/b # Specify a different location to the frida-server executable nxapi android-znca-api-server-frida android.local:5555 --frida-server-path "/data/local/tmp/frida-server-15.1.17-android-arm" +# Strictly validate the timestamp and request_id parameters sent by the client are likely to be accepted by Nintendo's API +nxapi android-znca-api-server-frida android.local:5555 --strict-validate + +# Don't validate the token sent by the client +nxapi android-znca-api-server-frida android.local:5555 --no-validate-tokens + # Make imink-compatible API requests using curl curl --header "Content-Type: application/json" --data '{"hash_method": "1", "token": "..."}' "http://[::1]:12345/api/znca/f" curl --header "Content-Type: application/json" --data '{"hash_method": "1", "token": "...", "request_id": "..."}' "http://[::1]:12345/api/znca/f" diff --git a/src/cli/android-znca-api-server-frida.ts b/src/cli/android-znca-api-server-frida.ts index 82877f4..2ed53c1 100644 --- a/src/cli/android-znca-api-server-frida.ts +++ b/src/cli/android-znca-api-server-frida.ts @@ -38,6 +38,10 @@ export function builder(yargs: Argv) { describe: 'Path to the frida-server executable on the device', type: 'string', default: '/data/local/tmp/frida-server', + }).option('strict-validate', { + describe: 'Validate data exactly matches the format that would be generated by Nintendo\'s Android app', + type: 'boolean', + default: false, }).option('validate-tokens', { describe: 'Validate tokens before passing them to znca', type: 'boolean', @@ -88,6 +92,12 @@ interface SystemInfo { interface FResult { f: string; timestamp: string; + /** Queue wait duration */ + dw: number; + /** Initialisation duration */ + di: number; + /** Processing duration */ + dp: number; } export async function handler(argv: ArgumentsCamelCase) { @@ -179,47 +189,44 @@ export async function handler(argv: ArgumentsCamelCase) { }); app.post('/api/znca/f', bodyParser.json(), async (req, res) => { + const start = Date.now(); + + if (req.body && 'type' in req.body) req.body = { + hash_method: + req.body.type === 'nso' ? '1' : + req.body.type === 'app' ? '2' : null!, + token: req.body.token, + timestamp: '' + req.body.timestamp, + request_id: req.body.uuid, + }; + + if (req.body && typeof req.body.hash_method === 'number') req.body.hash_method = '' + req.body.hash_method; + + const data: { + hash_method: '1' | '2' | 1 | 2; + token: string; + timestamp?: string | number; + request_id?: string; + } = req.body; + + 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; + } + + const timestamp = 'timestamp' in data ? '' + data.timestamp : undefined; + const request_id = 'request_id' in data ? data.request_id! : uuidgen(); + 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(data.token); @@ -268,19 +275,45 @@ export async function handler(argv: ArgumentsCamelCase) { 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); + if (argv.validateTokens) throw err; + debug('Error validating token from %s, continuing anyway', req.ip, err); + } + + if (argv.strictValidate && 'timestamp' in data) { + if (!timestamp!.match(/^\d+$/)) { + throw new Error('Non-numeric timestamp is not likely to be accepted by the Coral API'); + } + + // For Android the timestamp should be in milliseconds + const timestamp_ms = parseInt(timestamp!); + const now_ms = Date.now(); + + if (timestamp_ms > now_ms + 10000 || timestamp_ms + 10000 < now_ms) { + throw new Error('Timestamp not matching the Android device is not likely to be accepted by the Coral API'); } } - const timestamp = data.timestamp ? '' + data.timestamp : undefined; - const request_id = data.request_id ? data.request_id : uuidgen(); + if (argv.strictValidate && 'request_id' in data) { + // For Android the request_id should be lowercase hex + if (!request_id!.match(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/)) { + throw new Error('Request ID not a valid lowercase-hex v4 UUID is not likely to be accepted by the Coral API'); + } + } + } catch (err) { + debug('Error validating request from %s', req.ip, err); + res.statusCode = 400; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Server-Timing', 'validate;dur=' + (Date.now() - start)); + res.end(JSON.stringify({error: 'invalid_request', error_message: (err as Error)?.message})); + return; + } + + const validated = Date.now(); + + const handle = async () => { + const was_connected = !ready; + await ready; + const connected = Date.now(); debugApi('Calling %s', data.hash_method === '2' ? 'genAudioH2' : 'genAudioH'); @@ -297,16 +330,42 @@ export async function handler(argv: ArgumentsCamelCase) { }; res.setHeader('Content-Type', 'application/json'); + res.setHeader('Server-Timing', + 'validate;dur=' + (validated - start) + ',' + + (!was_connected ? 'attach;dur=' + (connected - validated) + ',' : '') + + 'queue;dur=' + result.dw + ',' + + 'init;dur=' + result.di + ',' + + 'process;dur=' + result.dp); res.end(JSON.stringify(response)); + }; + + try { + await handle(); } 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') { + debugApi('Error in request from %s, retrying', req.ip, err); + reattach(); + + try { + await handle(); + } 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(); + } + } + } else { + debugApi('Error in request from %s', req.ip, err); + + res.statusCode = 500; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({error: 'unknown'})); } } }); @@ -409,30 +468,50 @@ rpc.exports = { }); }, genAudioH(token, timestamp, request_id) { + const called = Date.now(); + return perform(() => { + const start = Date.now(); + 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(); + const init = Date.now(); + const f = libvoip.genAudioH(token, '' + timestamp, request_id); + const end = Date.now(); + return { - f: libvoip.genAudioH(token, '' + timestamp, request_id), - timestamp, + f, timestamp, + dw: start - called, + di: init - start, + dp: end - init, }; }); }, genAudioH2(token, timestamp, request_id) { + const called = Date.now(); + return perform(() => { + const start = Date.now(); + 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(); + const init = Date.now(); + const f = libvoip.genAudioH2(token, '' + timestamp, request_id); + const end = Date.now(); + return { - f: libvoip.genAudioH2(token, '' + timestamp, request_id), - timestamp, + f, timestamp, + dw: start - called, + di: init - start, + dp: end - init, }; }); },