mirror of
https://github.com/samuelthomas2774/nxapi.git
synced 2026-07-18 16:32:17 -05:00
Validate session tokens and znca id_tokens
This commit is contained in:
parent
f6fe7b57ba
commit
e801487010
|
|
@ -7,7 +7,7 @@ import { DailySummaries, Devices, MonthlySummaries, MonthlySummary, MoonError, S
|
|||
const debug = createDebug('api:moon');
|
||||
|
||||
const MOON_URL = 'https://api-lp1.pctl.srv.nintendo.net/moon';
|
||||
export const MOON_CLIENT_ID = '54789befb391a838';
|
||||
export const ZNMA_CLIENT_ID = '54789befb391a838';
|
||||
|
||||
export default class MoonApi {
|
||||
constructor(
|
||||
|
|
@ -95,7 +95,7 @@ export default class MoonApi {
|
|||
|
||||
static async loginWithSessionToken(token: string) {
|
||||
// Nintendo Account token
|
||||
const nintendoAccountToken = await getNintendoAccountToken(token, MOON_CLIENT_ID);
|
||||
const nintendoAccountToken = await getNintendoAccountToken(token, ZNMA_CLIENT_ID);
|
||||
|
||||
// Nintendo Account user data
|
||||
const user = await getNintendoAccountUser(nintendoAccountToken);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import fetch from 'node-fetch';
|
||||
import createDebug from 'debug';
|
||||
import { ErrorResponse } from './util.js';
|
||||
import { ErrorResponse, JwtPayload } from './util.js';
|
||||
|
||||
const debug = createDebug('api:na');
|
||||
|
||||
|
|
@ -89,6 +89,18 @@ export interface NintendoAccountSessionToken {
|
|||
session_token: string;
|
||||
code: string;
|
||||
}
|
||||
export interface NintendoAccountSessionTokenJwtPayload extends JwtPayload {
|
||||
jti: string;
|
||||
typ: 'session_token';
|
||||
iss: 'https://accounts.nintendo.com';
|
||||
'st:scp': number[];
|
||||
/** Subject (Nintendo Account ID) */
|
||||
sub: string;
|
||||
exp: number;
|
||||
/** Audience (client ID) */
|
||||
aud: string;
|
||||
iat: number;
|
||||
}
|
||||
|
||||
export interface NintendoAccountToken {
|
||||
scope: string[];
|
||||
|
|
@ -97,6 +109,32 @@ export interface NintendoAccountToken {
|
|||
access_token?: string;
|
||||
expires_in: 900;
|
||||
}
|
||||
export interface NintendoAccountIdTokenJwtPayload extends JwtPayload {
|
||||
/** Subject (Nintendo Account ID) */
|
||||
sub: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
/** Audience (client ID) */
|
||||
aud: string;
|
||||
iss: 'https://accounts.nintendo.com';
|
||||
jti: string;
|
||||
at_hash: string; // ??
|
||||
typ: 'id_token';
|
||||
country: string;
|
||||
}
|
||||
export interface NintendoAccountAccessTokenJwtPayload extends JwtPayload {
|
||||
iss: 'https://accounts.nintendo.com';
|
||||
jti: string;
|
||||
typ: 'token';
|
||||
/** Subject (Nintendo Account ID) */
|
||||
sub: string;
|
||||
iat: number;
|
||||
'ac:grt': number; // ??
|
||||
'ac:scp': number[]; // ??
|
||||
exp: number;
|
||||
/** Audience (client ID) */
|
||||
aud: string;
|
||||
}
|
||||
|
||||
export interface NintendoAccountUser {
|
||||
emailOptedIn: boolean;
|
||||
|
|
|
|||
109
src/api/util.ts
109
src/api/util.ts
|
|
@ -1,3 +1,4 @@
|
|||
import * as crypto from 'crypto';
|
||||
import { Response } from 'node-fetch';
|
||||
|
||||
export class ErrorResponse<T = unknown> extends Error {
|
||||
|
|
@ -9,3 +10,111 @@ export class ErrorResponse<T = unknown> extends Error {
|
|||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export interface JwtHeader {
|
||||
typ?: 'JWT';
|
||||
alg: JwtAlgorithm;
|
||||
/** Key ID */
|
||||
kid?: string;
|
||||
/** JSON Web Key Set URL */
|
||||
jku?: string;
|
||||
}
|
||||
export enum JwtAlgorithm {
|
||||
RS256 = 'RS256',
|
||||
}
|
||||
|
||||
export interface JwtPayload {
|
||||
/** Audience */
|
||||
aud: string;
|
||||
/** Expiration timestamp (seconds) */
|
||||
exp: number;
|
||||
/** Issue timestamp (seconds) */
|
||||
iat: number;
|
||||
/** Issuer */
|
||||
iss: string;
|
||||
/** Token ID */
|
||||
jti: string;
|
||||
/** Subject */
|
||||
sub: string | number;
|
||||
/** Token type */
|
||||
typ: string;
|
||||
}
|
||||
|
||||
type JwtVerifier = (data: Buffer, signature: Buffer, key: string) => boolean;
|
||||
|
||||
export class Jwt<T = JwtPayload, H extends JwtHeader = JwtHeader> {
|
||||
constructor(
|
||||
readonly header: H,
|
||||
readonly payload: T
|
||||
) {}
|
||||
|
||||
static decode<T = JwtPayload, H extends JwtHeader = JwtHeader>(token: string) {
|
||||
const [header_str, payload_str, signature_str] = token.split('.', 3);
|
||||
|
||||
const header = JSON.parse(Buffer.from(header_str, 'base64url').toString());
|
||||
const payload = JSON.parse(Buffer.from(payload_str, 'base64url').toString());
|
||||
const signature = Buffer.from(signature_str, 'base64url');
|
||||
|
||||
if ('typ' in header && header.typ !== 'JWT') {
|
||||
throw new Error('Invalid JWT');
|
||||
}
|
||||
|
||||
const jwt = new this<T, H>(header, payload);
|
||||
return [jwt, signature] as const;
|
||||
}
|
||||
|
||||
verify(signature: Buffer, key: string, verifier?: JwtVerifier) {
|
||||
const header_str = Buffer.from(JSON.stringify(this.header)).toString('base64url');
|
||||
const payload_str = Buffer.from(JSON.stringify(this.payload)).toString('base64url');
|
||||
const sign_data = header_str + '.' + payload_str;
|
||||
|
||||
if (!verifier) {
|
||||
if (!(this.header.alg in Jwt.verifiers) || !Jwt.verifiers[this.header.alg]) {
|
||||
throw new Error('Unknown algorithm');
|
||||
}
|
||||
|
||||
verifier = Jwt.verifiers[this.header.alg];
|
||||
}
|
||||
|
||||
return verifier.call(null, Buffer.from(sign_data), signature, key);
|
||||
}
|
||||
|
||||
static verifiers: Record<JwtAlgorithm, JwtVerifier> = {
|
||||
[JwtAlgorithm.RS256]: (data, signature, key) => {
|
||||
const verify = crypto.createVerify('RSA-SHA256');
|
||||
verify.end(data);
|
||||
return verify.verify(key, signature);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface Jwks {
|
||||
keys: Jwk[];
|
||||
}
|
||||
export interface Jwk {
|
||||
/** Key type */
|
||||
kty: string;
|
||||
use?: JwkUse | string;
|
||||
key_ops?: JwkKeyOperation | string;
|
||||
alg?: JwtAlgorithm | string;
|
||||
/** Key ID */
|
||||
kid?: string;
|
||||
x5u?: string[];
|
||||
x5c?: string[];
|
||||
x5t?: string;
|
||||
'x5t#S256'?: string;
|
||||
}
|
||||
export enum JwkUse {
|
||||
SIGNATURE = 'sig',
|
||||
ENCRYPTION = 'enc',
|
||||
}
|
||||
export enum JwkKeyOperation {
|
||||
SIGN = 'sign',
|
||||
VERIFY = 'verify',
|
||||
ENCRYPT = 'encrypt',
|
||||
DECRYPT = 'decrypt',
|
||||
WRAP_KEY = 'wrapKey',
|
||||
UNWRAP_KEY = 'unwrapKey',
|
||||
DERIVE_KEY = 'deriveKey',
|
||||
DERIVE_BITS = 'deriveBits',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import createDebug from 'debug';
|
|||
import { flapg, FlapgIid, genfc } from './f.js';
|
||||
import { AccountLogin, ActiveEvent, Announcement, CurrentUser, Friends, WebService, WebServiceToken, ZncResponse } from './znc-types.js';
|
||||
import { getNintendoAccountToken, getNintendoAccountUser } from './na.js';
|
||||
import { ErrorResponse } from './util.js';
|
||||
import { ErrorResponse, JwtPayload } from './util.js';
|
||||
|
||||
const debug = createDebug('api:znc');
|
||||
|
||||
|
|
@ -169,3 +169,37 @@ export default class ZncApi {
|
|||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface ZncJwtPayload extends JwtPayload {
|
||||
isChildRestricted: boolean;
|
||||
membership: {
|
||||
active: boolean;
|
||||
};
|
||||
aud: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
iss: 'api-lp1.znc.srv.nintendo.net';
|
||||
/** User ID (CurrentUser.id, not CurrentUser.nsaID) */
|
||||
sub: number;
|
||||
typ: 'id_token';
|
||||
}
|
||||
export interface ZncWebServiceJwtPayload extends JwtPayload {
|
||||
isChildRestricted: boolean;
|
||||
aud: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
iss: 'api-lp1.znc.srv.nintendo.net';
|
||||
jti: string;
|
||||
/** User ID (CurrentUser.id, not CurrentUser.nsaID) */
|
||||
sub: number;
|
||||
links: {
|
||||
networkServiceAccount: {
|
||||
/** NSA ID (CurrentUser.nsaID) */
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
typ: 'id_token';
|
||||
membership: {
|
||||
active: boolean;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ import frida, { Session } from 'frida';
|
|||
import express from 'express';
|
||||
import bodyParser from 'body-parser';
|
||||
import type { Arguments as ParentArguments } from '../cli.js';
|
||||
import { ArgumentsCamelCase, Argv, YargsArguments } from '../util.js';
|
||||
import { ArgumentsCamelCase, Argv, getJwks, initStorage, YargsArguments } from '../util.js';
|
||||
import { Jwt } from '../api/util.js';
|
||||
import { NintendoAccountIdTokenJwtPayload } from '../api/na.js';
|
||||
import { ZNCA_CLIENT_ID, ZncJwtPayload } from '../api/znc.js';
|
||||
|
||||
const debug = createDebug('cli:android-znca-api-server-frida');
|
||||
const debugApi = createDebug('cli:android-znca-api-server-frida:api');
|
||||
|
|
@ -32,6 +35,7 @@ export function builder(yargs: Argv<ParentArguments>) {
|
|||
type Arguments = YargsArguments<ReturnType<typeof builder>>;
|
||||
|
||||
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
||||
const storage = await initStorage(argv.dataPath);
|
||||
await setup(argv);
|
||||
|
||||
let {session, script} = await attach(argv);
|
||||
|
|
@ -43,7 +47,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
|||
genAudioH2(token: string, timestamp: string, uuid: string): Promise<string>;
|
||||
} = script.exports as any;
|
||||
|
||||
process.on('beforeExit', () => {
|
||||
process.on('exit', () => {
|
||||
debug('Releasing wake lock', argv.device);
|
||||
execFileSync('adb', [
|
||||
'-s',
|
||||
|
|
@ -103,6 +107,60 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
|||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const [jwt, sig] = Jwt.decode<NintendoAccountIdTokenJwtPayload | ZncJwtPayload>(data.token);
|
||||
|
||||
const check_signature = jwt.payload.iss === 'https://accounts.nintendo.com';
|
||||
|
||||
if (data.type === 'nso' && jwt.payload.iss !== 'https://accounts.nintendo.com') {
|
||||
throw new Error('Invalid token issuer');
|
||||
}
|
||||
if (data.type === 'nso' && jwt.payload.aud !== ZNCA_CLIENT_ID) {
|
||||
throw new Error('Invalid token audience');
|
||||
}
|
||||
if (data.type === 'app' && 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) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({error: 'invalid_token', error_message: (err as Error).message}));
|
||||
return;
|
||||
}
|
||||
|
||||
debugApi('Calling %s', data.type === 'app' ? 'genAudioH2' : 'genAudioH');
|
||||
|
||||
const result = data.type === 'app' ?
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import * as crypto from 'crypto';
|
|||
import type { Arguments as ParentArguments } from '../../cli.js';
|
||||
import { ArgumentsCamelCase, Argv, getPctlToken, initStorage, YargsArguments } from '../../util.js';
|
||||
import { getNintendoAccountSessionToken } from '../../api/na.js';
|
||||
import { MOON_CLIENT_ID } from '../../api/moon.js';
|
||||
import { ZNMA_CLIENT_ID } from '../../api/moon.js';
|
||||
|
||||
const debug = createDebug('cli:pctl:auth');
|
||||
|
||||
|
|
@ -87,7 +87,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
|||
debug('Redirect URL parameters', [...authorisedparams.entries()]);
|
||||
|
||||
const code = authorisedparams.get('session_token_code')!;
|
||||
const token = await getNintendoAccountSessionToken(code, verifier, MOON_CLIENT_ID);
|
||||
const token = await getNintendoAccountSessionToken(code, verifier, ZNMA_CLIENT_ID);
|
||||
|
||||
console.log('Session token', token);
|
||||
|
||||
|
|
|
|||
70
src/util.ts
70
src/util.ts
|
|
@ -4,12 +4,14 @@ import type * as yargstypes from '../node_modules/@types/yargs/index.js';
|
|||
import createDebug from 'debug';
|
||||
import persist from 'node-persist';
|
||||
import getPaths from 'env-paths';
|
||||
import fetch from 'node-fetch';
|
||||
import { FlapgApiResponse } from './api/f.js';
|
||||
import { NintendoAccountToken, NintendoAccountUser } from './api/na.js';
|
||||
import { NintendoAccountSessionTokenJwtPayload, NintendoAccountToken, NintendoAccountUser } from './api/na.js';
|
||||
import { AccountLogin } from './api/znc-types.js';
|
||||
import ZncApi from './api/znc.js';
|
||||
import ZncApi, { ZNCA_CLIENT_ID } from './api/znc.js';
|
||||
import ZncProxyApi from './api/znc-proxy.js';
|
||||
import MoonApi from './api/moon.js';
|
||||
import MoonApi, { ZNMA_CLIENT_ID } from './api/moon.js';
|
||||
import { Jwks, Jwt } from './api/util.js';
|
||||
|
||||
const debug = createDebug('cli');
|
||||
|
||||
|
|
@ -62,6 +64,23 @@ export async function getToken(storage: persist.LocalStorage, token: string, pro
|
|||
throw new Error('Invalid token');
|
||||
}
|
||||
|
||||
const [jwt, sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(token);
|
||||
|
||||
if (jwt.payload.iss !== 'https://accounts.nintendo.com') {
|
||||
throw new Error('Invalid Nintendo Account session token issuer');
|
||||
}
|
||||
if (jwt.payload.typ !== 'session_token') {
|
||||
throw new Error('Invalid Nintendo Account session token type');
|
||||
}
|
||||
if (jwt.payload.aud !== ZNCA_CLIENT_ID) {
|
||||
throw new Error('Invalid Nintendo Account session token audience');
|
||||
}
|
||||
if (jwt.payload.exp <= (Date.now() / 1000)) {
|
||||
throw new Error('Nintendo Account session token expired');
|
||||
}
|
||||
|
||||
// Nintendo Account session tokens use a HMAC SHA256 signature, so we can't verify this is valid
|
||||
|
||||
const existingToken: SavedToken | undefined = await storage.getItem('NsoToken.' + token);
|
||||
|
||||
if (!existingToken || existingToken.expires_at <= Date.now()) {
|
||||
|
|
@ -100,6 +119,23 @@ export async function getPctlToken(storage: persist.LocalStorage, token: string)
|
|||
throw new Error('Invalid token');
|
||||
}
|
||||
|
||||
const [jwt, sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(token);
|
||||
|
||||
if (jwt.payload.iss !== 'https://accounts.nintendo.com') {
|
||||
throw new Error('Invalid Nintendo Account session token issuer');
|
||||
}
|
||||
if (jwt.payload.typ !== 'session_token') {
|
||||
throw new Error('Invalid Nintendo Account session token type');
|
||||
}
|
||||
if (jwt.payload.aud !== ZNMA_CLIENT_ID) {
|
||||
throw new Error('Invalid Nintendo Account session token audience');
|
||||
}
|
||||
if (jwt.payload.exp <= (Date.now() / 1000)) {
|
||||
throw new Error('Nintendo Account session token expired');
|
||||
}
|
||||
|
||||
// Nintendo Account session tokens use a HMAC SHA256 signature, so we can't verify this is valid
|
||||
|
||||
const existingToken: SavedMoonToken | undefined = await storage.getItem('MoonToken.' + token);
|
||||
|
||||
if (!existingToken || existingToken.expires_at <= Date.now()) {
|
||||
|
|
@ -185,3 +221,31 @@ export enum LoopResult {
|
|||
OK = LoopRunOk as any,
|
||||
OK_SKIP_INTERVAL = LoopRunOkSkipInterval as any,
|
||||
}
|
||||
|
||||
interface SavedJwks {
|
||||
jwks: Jwks;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
export async function getJwks(url: string, storage?: persist.LocalStorage) {
|
||||
const cached_keyset: SavedJwks | undefined = await storage?.getItem('Jwks.' + url);
|
||||
|
||||
if (!cached_keyset || cached_keyset.expires_at <= Date.now()) {
|
||||
debug('Downloading JSON Web Key Set from %s', url);
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
const jwks = await response.json() as Jwks;
|
||||
|
||||
const cached_keyset: SavedJwks = {
|
||||
jwks,
|
||||
expires_at: Date.now() + (1 * 60 * 60 * 1000), // 1 hour
|
||||
};
|
||||
|
||||
await storage?.setItem('Jwks.' + url, cached_keyset);
|
||||
|
||||
return jwks;
|
||||
}
|
||||
|
||||
return cached_keyset.jwks;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user