mirror of
https://github.com/samuelthomas2774/nxapi.git
synced 2026-03-21 18:04:10 -05:00
Add optional strict validation, automatically retry after reconnecting and report server timing for f parameter generation
This commit is contained in:
parent
5b93c63c23
commit
80afc62268
12
docs/cli.md
12
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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user