diff --git a/src/api/moon.ts b/src/api/moon.ts index 9dc4290..d131d5b 100644 --- a/src/api/moon.ts +++ b/src/api/moon.ts @@ -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); diff --git a/src/api/na.ts b/src/api/na.ts index 9ec9a0d..3c41130 100644 --- a/src/api/na.ts +++ b/src/api/na.ts @@ -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; diff --git a/src/api/util.ts b/src/api/util.ts index 664fe17..b793111 100644 --- a/src/api/util.ts +++ b/src/api/util.ts @@ -1,3 +1,4 @@ +import * as crypto from 'crypto'; import { Response } from 'node-fetch'; export class ErrorResponse extends Error { @@ -9,3 +10,111 @@ export class ErrorResponse 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 { + constructor( + readonly header: H, + readonly payload: T + ) {} + + static decode(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(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.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', +} diff --git a/src/api/znc.ts b/src/api/znc.ts index 1d38df2..965caa9 100644 --- a/src/api/znc.ts +++ b/src/api/znc.ts @@ -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; + }; +} diff --git a/src/cli/android-znca-api-server-frida.ts b/src/cli/android-znca-api-server-frida.ts index 859c4ba..bde0842 100644 --- a/src/cli/android-znca-api-server-frida.ts +++ b/src/cli/android-znca-api-server-frida.ts @@ -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) { type Arguments = YargsArguments>; export async function handler(argv: ArgumentsCamelCase) { + 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) { genAudioH2(token: string, timestamp: string, uuid: string): Promise; } = 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) { return; } + try { + const [jwt, sig] = Jwt.decode(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' ? diff --git a/src/cli/pctl/auth.ts b/src/cli/pctl/auth.ts index 41ffe2e..0dc48ca 100644 --- a/src/cli/pctl/auth.ts +++ b/src/cli/pctl/auth.ts @@ -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) { 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); diff --git a/src/util.ts b/src/util.ts index 5138061..5159b0a 100644 --- a/src/util.ts +++ b/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(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(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; +}