Add optional strict validation, automatically retry after reconnecting and report server timing for f parameter generation

This commit is contained in:
Samuel Elliott 2022-09-07 21:48:25 +01:00
parent 5b93c63c23
commit 80afc62268
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
2 changed files with 148 additions and 63 deletions

View File

@ -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"

View File

@ -38,6 +38,10 @@ export function builder(yargs: Argv<ParentArguments>) {
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<Arguments>) {
@ -179,47 +189,44 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
});
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<NintendoAccountIdTokenJwtPayload | CoralJwtPayload>(data.token);
@ -268,19 +275,45 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
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<Arguments>) {
};
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,
};
});
},