From 55ee3ce18b2f34e29c7d504ad14e25c7be0e7914 Mon Sep 17 00:00:00 2001 From: Jonathan Barrow Date: Sat, 1 Apr 2023 11:15:59 -0400 Subject: [PATCH] Changed token format to be much smaller (thanks 3DS) --- src/cache.ts | 130 ------------- src/config-manager.ts | 8 +- src/database.ts | 2 +- src/models/server.ts | 19 +- src/services/api/routes/v1/login.ts | 42 +--- src/services/api/routes/v1/register.ts | 41 +--- src/services/api/routes/v1/resetPassword.ts | 3 +- src/services/nasc/routes/ac.ts | 85 ++++---- src/services/nnid/routes/oauth.ts | 36 +--- src/services/nnid/routes/provider.ts | 182 ++++++----------- src/types/common/config.ts | 1 + src/types/common/crypto-options.ts | 4 - src/types/mongoose/server.ts | 19 +- src/util.ts | 205 ++++---------------- 14 files changed, 203 insertions(+), 574 deletions(-) delete mode 100644 src/types/common/crypto-options.ts diff --git a/src/cache.ts b/src/cache.ts index 912f78c..593554b 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -6,8 +6,6 @@ let client: redis.RedisClientType; const memoryCache: { [key: string]: Buffer } = {}; -const SERVICE_CERTS_BASE: string = `${__dirname}/../certs/service`; -const NEX_CERTS_BASE: string = `${__dirname}/../certs/nex`; const LOCAL_CDN_BASE: string = `${__dirname}/../cdn`; export async function connect(): Promise { @@ -42,134 +40,6 @@ export async function getCachedFile(fileName: string, encoding?: BufferEncoding) return cachedFile; } -// * NEX server cache functions - -export async function getNEXPublicKey(name: string, encoding?: BufferEncoding): Promise { - let publicKey: Buffer = await getCachedFile(`nex:${name}:public_key`, encoding); - - if (publicKey === null) { - publicKey = await fs.readFile(`${NEX_CERTS_BASE}/${name}/public.pem`); - await setNEXPublicKey(name, publicKey); - } - - return publicKey; -} - -export async function getNEXPrivateKey(name: string, encoding?: BufferEncoding): Promise { - let privateKey: Buffer = await getCachedFile(`nex:${name}:private_key`, encoding); - - if (privateKey === null) { - privateKey = await fs.readFile(`${NEX_CERTS_BASE}/${name}/private.pem`); - await setNEXPrivateKey(name, privateKey); - } - - return privateKey; -} - -export async function getNEXSecretKey(name: string, encoding?: BufferEncoding): Promise { - let secretKey: Buffer = await getCachedFile(`nex:${name}:secret_key`, encoding); - - if (secretKey === null) { - const fileBuffer: string = await fs.readFile(`${NEX_CERTS_BASE}/${name}/secret.key`, { encoding: 'utf8' }); - secretKey = Buffer.from(fileBuffer, encoding); - await setNEXSecretKey(name, secretKey); - } - - return secretKey; -} - -export async function getNEXAESKey(name: string, encoding?: BufferEncoding): Promise { - let aesKey: Buffer = await getCachedFile(`nex:${name}:aes_key`, encoding); - - if (aesKey === null) { - const fileBuffer: string = await fs.readFile(`${NEX_CERTS_BASE}/${name}/aes.key`, { encoding: 'utf8' }); - aesKey = Buffer.from(fileBuffer, encoding); - await setNEXAESKey(name, aesKey); - } - - return aesKey; -} - -export async function setNEXPublicKey(name: string, value: Buffer): Promise { - await setCachedFile(`nex:${name}:public_key`, value); -} - -export async function setNEXPrivateKey(name: string, value: Buffer): Promise { - await setCachedFile(`nex:${name}:private_key`, value); -} - -export async function setNEXSecretKey(name: string, value: Buffer): Promise { - await setCachedFile(`nex:${name}:secret_key`, value); -} - -export async function setNEXAESKey(name: string, value: Buffer): Promise { - await setCachedFile(`nex:${name}:aes_key`, value); -} - -// * 3rd party service cache functions - -export async function getServicePublicKey(name: string, encoding?: BufferEncoding): Promise { - let publicKey: Buffer = await getCachedFile(`service:${name}:public_key`, encoding); - - if (publicKey === null) { - publicKey = await fs.readFile(`${SERVICE_CERTS_BASE}/${name}/public.pem`); - await setServicePublicKey(name, publicKey); - } - - return publicKey; -} - -export async function getServicePrivateKey(name: string, encoding?: BufferEncoding): Promise { - let privateKey: Buffer = await getCachedFile(`service:${name}:private_key`, encoding); - - if (privateKey === null) { - privateKey = await fs.readFile(`${SERVICE_CERTS_BASE}/${name}/private.pem`); - await setServicePrivateKey(name, privateKey); - } - - return privateKey; -} - -export async function getServiceSecretKey(name: string, encoding?: BufferEncoding): Promise { - let secretKey: Buffer = await getCachedFile(`service:${name}:secret_key`, encoding); - - if (secretKey === null) { - const fileBuffer: string = await fs.readFile(`${SERVICE_CERTS_BASE}/${name}/secret.key`, { encoding: 'utf8' }); - secretKey = Buffer.from(fileBuffer, encoding); - await setServiceSecretKey(name, secretKey); - } - - return secretKey; -} - -export async function getServiceAESKey(name: string, encoding?: BufferEncoding): Promise { - let aesKey: Buffer = await getCachedFile(`service:${name}:aes_key`, encoding); - - if (aesKey === null) { - const fileBuffer: string = await fs.readFile(`${SERVICE_CERTS_BASE}/${name}/aes.key`, { encoding: 'utf8' }); - aesKey = Buffer.from(fileBuffer, encoding); - await setServiceAESKey(name, aesKey); - } - - return aesKey; -} - -export async function setServicePublicKey(name: string, value: Buffer): Promise { - await setCachedFile(`service:${name}:public_key`, value); -} - -export async function setServicePrivateKey(name: string, value: Buffer): Promise { - await setCachedFile(`service:${name}:private_key`, value); -} - -export async function setServiceSecretKey(name: string, value: Buffer): Promise { - await setCachedFile(`service:${name}:secret_key`, value); -} - -export async function setServiceAESKey(name: string, value: Buffer): Promise { - await setCachedFile(`service:${name}:aes_key`, value); -} - // * Local CDN cache functions export async function getLocalCDNFile(name: string, encoding?: BufferEncoding): Promise { diff --git a/src/config-manager.ts b/src/config-manager.ts index 9dc36e5..23c9f84 100644 --- a/src/config-manager.ts +++ b/src/config-manager.ts @@ -64,7 +64,8 @@ export const config: Config = { disk_path: process.env.PN_ACT_CONFIG_CDN_DISK_PATH || '', base_url: process.env.PN_ACT_CONFIG_CDN_BASE_URL || '' }, - website_base: process.env.PN_ACT_CONFIG_WEBSITE_BASE || '' + website_base: process.env.PN_ACT_CONFIG_WEBSITE_BASE || '', + aes_key: process.env.PN_ACT_CONFIG_AES_KEY || '' }; LOG_INFO('Config loaded, checking integrity'); @@ -162,4 +163,9 @@ if (disabledFeatures.s3) { if (disabledFeatures.redis) { LOG_WARN('Both s3 and Redis are disabled. Large CDN files will use the in-memory cache, which may result in high memory use. Please enable s3 if you\'re running a production server.'); } +} + +if (!config.aes_key) { + LOG_ERROR('Token AES key is not set. Set the PN_ACT_CONFIG_AES_KEY environment variable to your AES-256-CBC key'); + process.exit(0); } \ No newline at end of file diff --git a/src/database.ts b/src/database.ts index 09ef40d..e7f7748 100644 --- a/src/database.ts +++ b/src/database.ts @@ -104,7 +104,7 @@ export async function getPNIDByBearerAuth(token: string): Promise({ - ip: String, // Example: 1.1.1.1 - port: Number, // Example: 60000 - service_name: String, // Example: friends - service_type: String, // Example: nex - game_server_id: String, // Example: 00003200 - title_ids: [String], // Example: ["000500001018DB00", "000500001018DC00", "000500001018DD00"] - access_mode: String, // Example: prod - maintenance_mode: Boolean, // Example: false - device: Number, // Example: 1 (WiiU) + ip: String, + port: Number, + service_name: String, + service_type: String, + game_server_id: String, + title_ids: [String], + access_mode: String, + maintenance_mode: Boolean, + device: Number, + aes_key: String }); ServerSchema.plugin(uniqueValidator, { message: '{PATH} already in use.' }); diff --git a/src/services/api/routes/v1/login.ts b/src/services/api/routes/v1/login.ts index 61fcac9..400b3fe 100644 --- a/src/services/api/routes/v1/login.ts +++ b/src/services/api/routes/v1/login.ts @@ -1,10 +1,8 @@ import express from 'express'; import bcrypt from 'bcrypt'; -import fs from 'fs-extra'; import { getPNIDByUsername, getPNIDByBearerAuth } from '@/database'; -import { getServicePublicKey, getServiceSecretKey } from '@/cache'; import { nintendoPasswordHash, generateToken} from '@/util'; -import { CryptoOptions } from '@/types/common/crypto-options'; +import { config } from '@/config-manager'; import { TokenOptions } from '@/types/common/token-options'; import { HydratedPNIDDocument } from '@/types/mongoose/pnid'; @@ -88,45 +86,25 @@ router.post('/', async (request: express.Request, response: express.Response) => } } - const cryptoPath: string = `${__dirname}/../../../../../certs/service/account`; - - if (!await fs.pathExists(cryptoPath)) { - // Need to generate keys - return response.status(500).json({ - app: 'api', - status: 500, - error: 'Failed to locate crypto keys. Please contact an administrator' - }); - } - - const publicKey: Buffer = await getServicePublicKey('account'); - const secretKey: Buffer = await getServiceSecretKey('account'); - - const cryptoOptions: CryptoOptions = { - public_key: publicKey, - hmac_secret: secretKey - }; - const accessTokenOptions: TokenOptions = { - system_type: 0xF, // API - token_type: 0x1, // OAuth Access, + system_type: 0x1, // * WiiU + token_type: 0x1, // * OAuth Access pid: pnid.pid, - access_level: pnid.access_level, - title_id: BigInt(0), expire_time: BigInt(Date.now() + (3600 * 1000)) }; const refreshTokenOptions: TokenOptions = { - system_type: 0xF, // API - token_type: 0x2, // OAuth Refresh, + system_type: 0x1, // * WiiU + token_type: 0x2, // * OAuth Refresh pid: pnid.pid, - access_level: pnid.access_level, - title_id: BigInt(0), expire_time: BigInt(Date.now() + (3600 * 1000)) }; - const accessToken: string | null = await generateToken(cryptoOptions, accessTokenOptions); - const newRefreshToken: string | null = await generateToken(cryptoOptions, refreshTokenOptions); + const accessTokenBuffer: Buffer | null = await generateToken(config.aes_key, accessTokenOptions); + const refreshTokenBuffer: Buffer | null = await generateToken(config.aes_key, refreshTokenOptions); + + const accessToken: string = accessTokenBuffer ? accessTokenBuffer.toString('hex') : ''; + const newRefreshToken: string = refreshTokenBuffer ? refreshTokenBuffer.toString('hex') : ''; // TODO - Handle null tokens diff --git a/src/services/api/routes/v1/register.ts b/src/services/api/routes/v1/register.ts index 2487ae2..25d30ca 100644 --- a/src/services/api/routes/v1/register.ts +++ b/src/services/api/routes/v1/register.ts @@ -3,19 +3,16 @@ import crypto from 'node:crypto'; import express from 'express'; import emailvalidator from 'email-validator'; import bcrypt from 'bcrypt'; -import fs from 'fs-extra'; import moment from 'moment'; import hcaptcha from 'hcaptcha'; import Mii from 'mii-js'; import mongoose from 'mongoose'; import { doesPNIDExist, connection as databaseConnection } from '@/database'; -import { getServicePublicKey, getServiceSecretKey } from '@/cache'; import { nintendoPasswordHash, sendConfirmationEmail, generateToken } from '@/util'; import { LOG_ERROR } from '@/logger'; import { PNID } from '@/models/pnid'; import { NEXAccount } from '@/models/nex-account'; import { config, disabledFeatures } from '@/config-manager'; -import { CryptoOptions } from '@/types/common/crypto-options'; import { TokenOptions } from '@/types/common/token-options'; import { HydratedNEXAccountDocument } from '@/types/mongoose/nex-account'; import { HydratedPNIDDocument } from '@/types/mongoose/pnid'; @@ -326,45 +323,25 @@ router.post('/', async (request: express.Request, response: express.Response) => await sendConfirmationEmail(pnid); - const cryptoPath: string = `${__dirname}/../../../../../certs/service/account`; - - if (!await fs.pathExists(cryptoPath)) { - // Need to generate keys - return response.status(500).json({ - app: 'api', - status: 500, - error: 'Failed to locate crypto keys. Please contact an administrator' - }); - } - - const publicKey: Buffer = await getServicePublicKey('account'); - const secretKey: Buffer = await getServiceSecretKey('account'); - - const cryptoOptions: CryptoOptions = { - public_key: publicKey, - hmac_secret: secretKey - }; - const accessTokenOptions: TokenOptions = { - system_type: 0xF, // API - token_type: 0x1, // OAuth Access, + system_type: 0x1, // * WiiU + token_type: 0x1, // * OAuth Access pid: pnid.pid, - access_level: 0, - title_id: BigInt(0), expire_time: BigInt(Date.now() + (3600 * 1000)) }; const refreshTokenOptions: TokenOptions = { - system_type: 0xF, // API - token_type: 0x2, // OAuth Refresh, + system_type: 0x1, // * WiiU + token_type: 0x2, // * OAuth Refresh pid: pnid.pid, - access_level: 0, - title_id: BigInt(0), expire_time: BigInt(Date.now() + (3600 * 1000)) }; - const accessToken: string | null = await generateToken(cryptoOptions, accessTokenOptions); - const refreshToken: string | null = await generateToken(cryptoOptions, refreshTokenOptions); + const accessTokenBuffer: Buffer | null = await generateToken(config.aes_key, accessTokenOptions); + const refreshTokenBuffer: Buffer | null = await generateToken(config.aes_key, refreshTokenOptions); + + const accessToken: string = accessTokenBuffer ? accessTokenBuffer.toString('hex') : ''; + const refreshToken: string = refreshTokenBuffer ? refreshTokenBuffer.toString('hex') : ''; // TODO - Handle null tokens diff --git a/src/services/api/routes/v1/resetPassword.ts b/src/services/api/routes/v1/resetPassword.ts index 9b90138..266e887 100644 --- a/src/services/api/routes/v1/resetPassword.ts +++ b/src/services/api/routes/v1/resetPassword.ts @@ -2,6 +2,7 @@ import express from 'express'; import bcrypt from 'bcrypt'; import { PNID } from '@/models/pnid'; import { decryptToken, unpackToken, nintendoPasswordHash } from '@/util'; +import { config } from '@/config-manager'; import { Token } from '@/types/common/token'; import { HydratedPNIDDocument } from '@/types/mongoose/pnid'; @@ -28,7 +29,7 @@ router.post('/', async (request: express.Request, response: express.Response) => let unpackedToken: Token; try { - const decryptedToken: Buffer = await decryptToken(Buffer.from(token, 'base64')); + const decryptedToken: Buffer = await decryptToken(config.aes_key, Buffer.from(token, 'base64')); unpackedToken = unpackToken(decryptedToken); } catch (error) { console.log(error); diff --git a/src/services/nasc/routes/ac.ts b/src/services/nasc/routes/ac.ts index d00fae2..cd688d3 100644 --- a/src/services/nasc/routes/ac.ts +++ b/src/services/nasc/routes/ac.ts @@ -1,8 +1,6 @@ import express from 'express'; import { nintendoBase64Encode, nintendoBase64Decode, nascError, generateToken } from '@/util'; import { getServerByTitleId } from '@/database'; -import { getNEXPublicKey, getNEXSecretKey } from '@/cache'; -import { CryptoOptions } from '@/types/common/crypto-options'; import { TokenOptions } from '@/types/common/token-options'; import { NASCRequestParams } from '@/types/services/nasc/request-params'; import { HydratedNEXAccountDocument } from '@/types/mongoose/nex-account'; @@ -18,33 +16,17 @@ const router: express.Router = express.Router(); router.post('/', async (request: express.Request, response: express.Response) => { const requestParams: NASCRequestParams = request.body; const action: string = nintendoBase64Decode(requestParams.action).toString(); - let responseData: URLSearchParams = nascError('null'); - - switch (action) { - case 'LOGIN': - responseData = await processLoginRequest(request); - break; - case 'SVCLOC': - responseData = await processServiceTokenRequest(request); - break; - } - - response.status(200).send(responseData.toString()); -}); - -async function processLoginRequest(request: express.Request): Promise { - const requestParams: NASCRequestParams = request.body; const titleID: string = nintendoBase64Decode(requestParams.titleid).toString(); const nexAccount: HydratedNEXAccountDocument | null = request.nexAccount; + let responseData: URLSearchParams = nascError('null'); if (!nexAccount) { - // TODO - Research this error more - return nascError('null'); + return response.status(200).send(responseData.toString()); } // TODO: REMOVE AFTER PUBLIC LAUNCH - // LET EVERYONE IN THE `test` FRIENDS SERVER - // THAT WAY EVERYONE CAN GET AN ASSIGNED PID + // * LET EVERYONE IN THE `test` FRIENDS SERVER + // * THAT WAY EVERYONE CAN GET AN ASSIGNED PID let serverAccessLevel: string = 'test'; if (titleID !== '0004013000003202') { serverAccessLevel = nexAccount.server_access_level; @@ -52,32 +34,33 @@ async function processLoginRequest(request: express.Request): Promise { const tokenOptions: TokenOptions = { - system_type: 0x2, // 3DS - token_type: 0x3, // nex token, - pid: nexAccount.pid, + system_type: 0x2, // * 3DS + token_type: 0x3, // * NEX token + pid: pid, access_level: 0, title_id: BigInt(parseInt(titleID, 16)), expire_time: BigInt(Date.now() + (3600 * 1000)) @@ -85,11 +68,11 @@ async function processLoginRequest(request: express.Request): Promise { +async function processServiceTokenRequest(server: HydratedServerDocument, pid: number, titleID: string): Promise { + const tokenOptions: TokenOptions = { + system_type: 0x2, // * 3DS + token_type: 0x4, // * Service token + pid: pid, + access_level: 0, + title_id: BigInt(parseInt(titleID, 16)), + expire_time: BigInt(Date.now() + (3600 * 1000)) + }; + + // TODO - Handle null tokens + + const serviceTokenBuffer: Buffer | null = await generateToken(server.aes_key, tokenOptions); + const serviceToken: string = nintendoBase64Encode(serviceTokenBuffer || ''); + return new URLSearchParams({ retry: nintendoBase64Encode('0'), returncd: nintendoBase64Encode('007'), - servicetoken: nintendoBase64Encode(Buffer.alloc(64).toString()), // hard coded for now + servicetoken: serviceToken, statusdata: nintendoBase64Encode('Y'), svchost: nintendoBase64Encode('n/a'), datetime: nintendoBase64Encode(Date.now().toString()), diff --git a/src/services/nnid/routes/oauth.ts b/src/services/nnid/routes/oauth.ts index 867f93d..c276b9f 100644 --- a/src/services/nnid/routes/oauth.ts +++ b/src/services/nnid/routes/oauth.ts @@ -1,9 +1,9 @@ import express from 'express'; import xmlbuilder from 'xmlbuilder'; import bcrypt from 'bcrypt'; -import fs from 'fs-extra'; import { getPNIDByUsername } from '@/database'; import { generateToken } from '@/util'; +import { config } from '@/config-manager'; import { TokenOptions } from '@/types/common/token-options'; import { HydratedPNIDDocument } from '@/types/mongoose/pnid'; @@ -75,44 +75,28 @@ router.post('/access_token/generate', async (request: express.Request, response: }).end()); } - const cryptoPath: string = `${__dirname}/../../../../certs/service/account`; - - if (!await fs.pathExists(cryptoPath)) { - // Need to generate keys - return response.send(xmlbuilder.create({ - errors: { - error: { - code: '0000', - message: 'Could not find account access key crypto path' - } - } - }).end()); - } - const accessTokenOptions: TokenOptions = { - system_type: 0x1, // WiiU - token_type: 0x1, // OAuth Access, + system_type: 0x1, // * WiiU + token_type: 0x1, // * OAuth Access pid: pnid.pid, expire_time: BigInt(Date.now() + (3600 * 1000)) }; const refreshTokenOptions: TokenOptions = { - system_type: 0x1, // WiiU - token_type: 0x2, // OAuth Refresh, + system_type: 0x1, // * WiiU + token_type: 0x2, // * OAuth Refresh pid: pnid.pid, expire_time: BigInt(Date.now() + (3600 * 1000)) }; - let accessToken: string | null = await generateToken(null, accessTokenOptions); - let refreshToken: string | null = await generateToken(null, refreshTokenOptions); + const accessTokenBuffer: Buffer | null = await generateToken(config.aes_key, accessTokenOptions); + const refreshTokenBuffer: Buffer | null = await generateToken(config.aes_key, refreshTokenOptions); + + const accessToken: string = accessTokenBuffer ? accessTokenBuffer.toString('hex') : ''; + const refreshToken: string = refreshTokenBuffer ? refreshTokenBuffer.toString('hex') : ''; // TODO - Handle null tokens - if (request.isCemu) { - accessToken = Buffer.from(accessToken || '', 'base64').toString('hex'); - refreshToken = Buffer.from(refreshToken || '', 'base64').toString('hex'); - } - response.send(xmlbuilder.create({ OAuth20: { access_token: { diff --git a/src/services/nnid/routes/provider.ts b/src/services/nnid/routes/provider.ts index 93633c7..21160d3 100644 --- a/src/services/nnid/routes/provider.ts +++ b/src/services/nnid/routes/provider.ts @@ -1,11 +1,8 @@ import express from 'express'; import xmlbuilder from 'xmlbuilder'; -import fs from 'fs-extra'; import { getServerByTitleId, getServerByGameServerId } from '@/database'; import { generateToken, getValueFromHeaders, getValueFromQueryString } from '@/util'; -import { getServicePublicKey, getServiceSecretKey, getNEXPublicKey, getNEXSecretKey } from '@/cache'; import { NEXAccount } from '@/models/nex-account'; -import { CryptoOptions } from '@/types/common/crypto-options'; import { TokenOptions } from '@/types/common/token-options'; import { HydratedPNIDDocument } from '@/types/mongoose/pnid'; import { HydratedServerDocument } from '@/types/mongoose/server'; @@ -35,9 +32,9 @@ router.get('/service_token/@me', async (request: express.Request, response: expr }).end()); } - const titleId: string | undefined = getValueFromHeaders(request.headers, 'x-nintendo-title-id'); + const titleID: string | undefined = getValueFromHeaders(request.headers, 'x-nintendo-title-id'); - if (!titleId) { + if (!titleID) { // TODO - Research this error more return response.send(xmlbuilder.create({ errors: { @@ -50,9 +47,9 @@ router.get('/service_token/@me', async (request: express.Request, response: expr } const serverAccessLevel: string = pnid.server_access_level; - const server: HydratedServerDocument | null = await getServerByTitleId(titleId, serverAccessLevel); + const server: HydratedServerDocument | null = await getServerByTitleId(titleID, serverAccessLevel); - if (!server) { + if (!server || !server.aes_key) { return response.send(xmlbuilder.create({ errors: { error: { @@ -63,46 +60,20 @@ router.get('/service_token/@me', async (request: express.Request, response: expr }).end()); } - const serverName: string = server.service_name; - const device: number = server.device; - - const cryptoPath: string = `${__dirname}/../../../../certs/service/${serverName}`; - - if (!await fs.pathExists(cryptoPath)) { - // Need to generate keys - return response.send(xmlbuilder.create({ - errors: { - error: { - code: '1021', - message: 'The requested game server was not found' - } - } - }).end()); - } - - const publicKey: Buffer = await getServicePublicKey(serverName); - const secretKey: Buffer = await getServiceSecretKey(serverName); - - const cryptoOptions: CryptoOptions = { - public_key: publicKey, - hmac_secret: secretKey - }; - const tokenOptions: TokenOptions = { - system_type: device, - token_type: 0x4, // service token, + system_type: server.device, + token_type: 0x4, // * Service token pid: pnid.pid, access_level: pnid.access_level, - title_id: BigInt(parseInt(titleId, 16)), + title_id: BigInt(parseInt(titleID, 16)), expire_time: BigInt(Date.now() + (3600 * 1000)) }; - let serviceToken: string | null = await generateToken(cryptoOptions, tokenOptions); - - // TODO - Handle null tokens + const serviceTokenBuffer: Buffer | null = await generateToken(server.aes_key, tokenOptions); + let serviceToken: string = serviceTokenBuffer ? serviceTokenBuffer.toString('base64') : ''; if (request.isCemu) { - serviceToken = Buffer.from(serviceToken || '', 'base64').toString('hex'); + serviceToken = Buffer.from(serviceToken, 'base64').toString('hex'); } response.send(xmlbuilder.create({ @@ -134,82 +105,6 @@ router.get('/nex_token/@me', async (request: express.Request, response: express. }).end()); } - const gameServerID: string | undefined = getValueFromQueryString(request.query, 'game_server_id'); - - if (!gameServerID) { - return response.send(xmlbuilder.create({ - errors: { - error: { - code: '0118', - message: 'Unique ID and Game Server ID are not linked' - } - } - }).end()); - } - - const serverAccessLevel: string = pnid.server_access_level; - const server: HydratedServerDocument | null = await getServerByGameServerId(gameServerID, serverAccessLevel); - - if (!server) { - return response.send(xmlbuilder.create({ - errors: { - error: { - code: '1021', - message: 'The requested game server was not found' - } - } - }).end()); - } - - const serverName: string = server.service_name; - const ip: string = server.ip; - const port: number = server.port; - const device: number = server.device; - const titleId: string | undefined = getValueFromHeaders(request.headers, 'x-nintendo-title-id'); - - if (!titleId) { - // TODO - Research this error more - return response.send(xmlbuilder.create({ - errors: { - error: { - code: '1021', - message: 'The requested game server was not found' - } - } - }).end()); - } - - const cryptoPath: string = `${__dirname}/../../../../certs/nex/${serverName}`; - - if (!await fs.pathExists(cryptoPath)) { - // Need to generate keys - return response.send(xmlbuilder.create({ - errors: { - error: { - code: '1021', - message: 'The requested game server was not found' - } - } - }).end()); - } - - const publicKey: Buffer = await getNEXPublicKey(serverName); - const secretKey: Buffer = await getNEXSecretKey(serverName); - - const cryptoOptions: CryptoOptions = { - public_key: publicKey, - hmac_secret: secretKey - }; - - const tokenOptions: TokenOptions = { - system_type: device, - token_type: 0x3, // nex token, - pid: pnid.pid, - access_level: pnid.access_level, - title_id: BigInt(parseInt(titleId, 16)), - expire_time: BigInt(Date.now() + (3600 * 1000)) - }; - const nexAccount: HydratedNEXAccountDocument | null = await NEXAccount.findOne({ owning_pid: pnid.pid }); @@ -226,9 +121,58 @@ router.get('/nex_token/@me', async (request: express.Request, response: express. }).end()); } - let nexToken: string | null = await generateToken(cryptoOptions, tokenOptions); + const gameServerID: string | undefined = getValueFromQueryString(request.query, 'game_server_id'); - // TODO = Handle null tokens + if (!gameServerID) { + return response.send(xmlbuilder.create({ + errors: { + error: { + code: '0118', + message: 'Unique ID and Game Server ID are not linked' + } + } + }).end()); + } + + const serverAccessLevel: string = pnid.server_access_level; + const server: HydratedServerDocument | null = await getServerByGameServerId(gameServerID, serverAccessLevel); + + if (!server || !server.aes_key) { + return response.send(xmlbuilder.create({ + errors: { + error: { + code: '1021', + message: 'The requested game server was not found' + } + } + }).end()); + } + + const titleID: string | undefined = getValueFromHeaders(request.headers, 'x-nintendo-title-id'); + + if (!titleID) { + // TODO - Research this error more + return response.send(xmlbuilder.create({ + errors: { + error: { + code: '1021', + message: 'The requested game server was not found' + } + } + }).end()); + } + + const tokenOptions: TokenOptions = { + system_type: server.device, + token_type: 0x3, // nex token, + pid: pnid.pid, + access_level: pnid.access_level, + title_id: BigInt(parseInt(titleID, 16)), + expire_time: BigInt(Date.now() + (3600 * 1000)) + }; + + const nexTokenBuffer: Buffer | null = await generateToken(server.aes_key, tokenOptions); + let nexToken: string = nexTokenBuffer ? nexTokenBuffer.toString('base64') : ''; if (request.isCemu) { nexToken = Buffer.from(nexToken || '', 'base64').toString('hex'); @@ -236,10 +180,10 @@ router.get('/nex_token/@me', async (request: express.Request, response: express. response.send(xmlbuilder.create({ nex_token: { - host: ip, + host: server.ip, nex_password: nexAccount.password, pid: nexAccount.pid, - port: port, + port: server.port, token: nexToken } }).end()); diff --git a/src/types/common/config.ts b/src/types/common/config.ts index 4ce668d..4780d00 100644 --- a/src/types/common/config.ts +++ b/src/types/common/config.ts @@ -37,6 +37,7 @@ export interface Config { base_url: string; }; website_base: string; + aes_key: string; } export interface DisabledFeatures { diff --git a/src/types/common/crypto-options.ts b/src/types/common/crypto-options.ts deleted file mode 100644 index d4a5918..0000000 --- a/src/types/common/crypto-options.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface CryptoOptions { - public_key: Buffer; - hmac_secret: Buffer; -}; \ No newline at end of file diff --git a/src/types/mongoose/server.ts b/src/types/mongoose/server.ts index a385196..02fc372 100644 --- a/src/types/mongoose/server.ts +++ b/src/types/mongoose/server.ts @@ -1,15 +1,16 @@ import { Model, HydratedDocument } from 'mongoose'; export interface IServer { - ip: string; // Example: 1.1.1.1 - port: number; // Example: 60000 - service_name: string; // Example: friends - service_type: string; // Example: nex - game_server_id: string; // Example: 00003200 - title_ids: string[]; // Example: ["000500001018DB00", "000500001018DC00", "000500001018DD00"] - access_mode: string; // Example: prod - maintenance_mode: boolean; // Example: false - device: number; // Example: 1 (WiiU) + ip: string; + port: number; + service_name: string; + service_type: string; + game_server_id: string; + title_ids: string[]; + access_mode: string; + maintenance_mode: boolean; + device: number; + aes_key: string; } export interface IServerMethods {} diff --git a/src/util.ts b/src/util.ts index 16b1b08..a884cad 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,15 +1,12 @@ import crypto from 'node:crypto'; import path from 'node:path'; -import NodeRSA from 'node-rsa'; import aws from 'aws-sdk'; import fs from 'fs-extra'; import express from 'express'; import mongoose from 'mongoose'; import { ParsedQs } from 'qs'; import { sendMail } from '@/mailer'; -import { getServiceAESKey, getServicePrivateKey, getServiceSecretKey, getServicePublicKey } from '@/cache'; import { config, disabledFeatures } from '@/config-manager'; -import { CryptoOptions } from '@/types/common/crypto-options'; import { TokenOptions } from '@/types/common/token-options'; import { Token } from '@/types/common/token'; import { IPNID, IPNIDMethods } from '@/types/mongoose/pnid'; @@ -50,178 +47,61 @@ export function nintendoBase64Encode(decoded: string | Buffer): string { return encoded.replaceAll('+', '.').replaceAll('/', '-').replaceAll('=', '*'); } -export async function generateToken(cryptoOptions: CryptoOptions | null, tokenOptions: TokenOptions): Promise { - // Access and refresh tokens use a different format since they must be much smaller - // They take no extra crypto options - if (!cryptoOptions) { - const aesKey: Buffer = await getServiceAESKey('account', 'hex'); +export function generateToken(key: string, options: TokenOptions): Buffer | null { + let dataBuffer: Buffer = Buffer.alloc(1 + 1 + 4 + 8); - const dataBuffer: Buffer = Buffer.alloc(1 + 1 + 4 + 8); + dataBuffer.writeUInt8(options.system_type, 0x0); + dataBuffer.writeUInt8(options.token_type, 0x1); + dataBuffer.writeUInt32LE(options.pid, 0x2); + dataBuffer.writeBigUInt64LE(options.expire_time, 0x6); - dataBuffer.writeUInt8(tokenOptions.system_type, 0x0); - dataBuffer.writeUInt8(tokenOptions.token_type, 0x1); - dataBuffer.writeUInt32LE(tokenOptions.pid, 0x2); - dataBuffer.writeBigUInt64LE(tokenOptions.expire_time, 0x6); + if (options.token_type !== 0x1 && options.token_type !== 0x2) { + if (options.access_level === undefined || options.title_id === undefined) { + return null; + } - const iv: Buffer = Buffer.alloc(16); - const cipher: crypto.Cipher = crypto.createCipheriv('aes-128-cbc', aesKey, iv); + dataBuffer = Buffer.concat([ + dataBuffer, + Buffer.alloc(8 + 1) + ]); - let encryptedBody: Buffer = cipher.update(dataBuffer); - encryptedBody = Buffer.concat([encryptedBody, cipher.final()]); - - return encryptedBody.toString('base64'); - } else if (tokenOptions.access_level === undefined || tokenOptions.title_id === undefined) { - return null; + dataBuffer.writeBigUInt64LE(options.title_id, 0xE); + dataBuffer.writeUInt8(options.access_level, 0x16); } - const publicKey: NodeRSA = new NodeRSA(cryptoOptions.public_key, 'pkcs8-public-pem', { - environment: 'browser', - encryptionScheme: { - scheme: 'pkcs1_oaep', - hash: 'sha256' - } - }); + const iv: Buffer = Buffer.alloc(16); + const cipher: crypto.Cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(key, 'hex'), iv); - // Create the buffer containing the token data - const dataBuffer: Buffer = Buffer.alloc(1 + 1 + 4 + 1 + 8 + 8); - - dataBuffer.writeUInt8(tokenOptions.system_type, 0x0); - dataBuffer.writeUInt8(tokenOptions.token_type, 0x1); - dataBuffer.writeUInt32LE(tokenOptions.pid, 0x2); - dataBuffer.writeUInt8(tokenOptions.access_level, 0x6); - dataBuffer.writeBigUInt64LE(tokenOptions.title_id, 0x7); - dataBuffer.writeBigUInt64LE(tokenOptions.expire_time, 0xF); - - // Calculate the signature of the token body - const hmac: crypto.Hmac = crypto.createHmac('sha1', cryptoOptions.hmac_secret).update(dataBuffer); - const signature: Buffer = hmac.digest(); - - // You can thank the 3DS for the shit thats about to happen with the AES IV - // The 3DS only allows for strings up to 255 characters in NEX - // So this is done to reduce the token size as much as possible - // I am sorry, and have already asked every God I could think of for forgiveness - - // Generate random AES key - const key: Buffer = crypto.randomBytes(16); - - // Encrypt the AES key with RSA public key - const encryptedKey: Buffer = publicKey.encrypt(key); - - // Take two random points in the RSA encrypted key - const point1: number = ~~((encryptedKey.length - 8) * Math.random()); - const point2: number = ~~((encryptedKey.length - 8) * Math.random()); - - // Build an IV from each of the two points - const iv: Buffer = Buffer.concat([ - Buffer.from(encryptedKey.subarray(point1, point1 + 8)), - Buffer.from(encryptedKey.subarray(point2, point2 + 8)) - ]); - - const cipher: crypto.Cipher = crypto.createCipheriv('aes-128-cbc', key, iv); - - // Encrypt the token body with AES - const encryptedBody: Buffer = Buffer.concat([ + return Buffer.concat([ cipher.update(dataBuffer), cipher.final() ]); - - // Create crypto config token section - const cryptoConfig: Buffer = Buffer.concat([ - encryptedKey, - Buffer.from([point1, point2]) - ]); - - // Build the token - const token: Buffer = Buffer.concat([ - cryptoConfig, - signature, - encryptedBody - ]); - - return token.toString('base64'); // Encode to base64 for transport } -export async function decryptToken(token: Buffer): Promise { - // Access and refresh tokens use a different format since they must be much smaller - // Assume a small length means access or refresh token - if (token.length <= 32) { - const aesKey: Buffer = await getServiceAESKey('account', 'hex'); +export function decryptToken(key: string, token: Buffer): Buffer { + const iv: Buffer = Buffer.alloc(16); + const decipher: crypto.Decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(key, 'hex'), iv); - const iv: Buffer = Buffer.alloc(16); - - const decipher: crypto.Decipher = crypto.createDecipheriv('aes-128-cbc', aesKey, iv); - - const decryptedBody: Buffer = Buffer.concat([ - decipher.update(token), - decipher.final() - ]); - - return decryptedBody; - } - - const privateKeyBytes: Buffer = await getServicePrivateKey('account'); - const secretKey: Buffer = await getServiceSecretKey('account'); - - const privateKey: NodeRSA = new NodeRSA(privateKeyBytes, 'pkcs1-private-pem', { - environment: 'browser', - encryptionScheme: { - scheme: 'pkcs1_oaep', - hash: 'sha256' - } - }); - - const cryptoConfig: Buffer = token.subarray(0, 0x82); - const signature: Buffer = token.subarray(0x82, 0x96); - const encryptedBody: Buffer = token.subarray(0x96); - - const encryptedAESKey: Buffer = cryptoConfig.subarray(0, 128); - const point1: number = cryptoConfig.readInt8(0x80); - const point2: number = cryptoConfig.readInt8(0x81); - - const iv: Buffer = Buffer.concat([ - Buffer.from(encryptedAESKey.subarray(point1, point1 + 8)), - Buffer.from(encryptedAESKey.subarray(point2, point2 + 8)) - ]); - - const decryptedAESKey: Buffer = privateKey.decrypt(encryptedAESKey); - - const decipher: crypto.Decipher = crypto.createDecipheriv('aes-128-cbc', decryptedAESKey, iv); - - const decryptedBody: Buffer = Buffer.concat([ - decipher.update(encryptedBody), + return Buffer.concat([ + decipher.update(token), decipher.final() ]); - - const hmac: crypto.Hmac = crypto.createHmac('sha1', secretKey).update(decryptedBody); - const calculatedSignature: Buffer = hmac.digest(); - - if (!signature.equals(calculatedSignature)) { - // TODO - FIX THIS. ONLY DONE SO STRICT MODE DOESN'T YELL - console.log('Token signature did not match'); - return Buffer.alloc(0); - } - - return decryptedBody; } export function unpackToken(token: Buffer): Token { - if (token.length <= 14) { - return { - system_type: token.readUInt8(0x0), - token_type: token.readUInt8(0x1), - pid: token.readUInt32LE(0x2), - expire_time: token.readBigUInt64LE(0x6) - }; - } - - return { + const unpacked: Token = { system_type: token.readUInt8(0x0), token_type: token.readUInt8(0x1), pid: token.readUInt32LE(0x2), - access_level: token.readUInt8(0x6), - title_id: token.readBigUInt64LE(0x7), - expire_time: token.readBigUInt64LE(0xF) + expire_time: token.readBigUInt64LE(0x6) }; + + if (unpacked.token_type !== 0x1 && unpacked.token_type !== 0x2) { + unpacked.title_id = token.readBigUInt64LE(0xE); + unpacked.access_level = token.readUInt8(0x16); + } + + return unpacked; } export function fullUrl(request: express.Request): string { @@ -289,24 +169,17 @@ export async function sendEmailConfirmedEmail(pnid: mongoose.HydratedDocument): Promise { - const publicKey: Buffer = await getServicePublicKey('account'); - const secretKey: Buffer = await getServiceSecretKey('account'); - - const cryptoOptions: CryptoOptions = { - public_key: publicKey, - hmac_secret: secretKey - }; - const tokenOptions: TokenOptions = { - system_type: 0xF, // API - token_type: 0x5, // Password reset + system_type: 0xF, // * API + token_type: 0x5, // * Password reset pid: pnid.pid, access_level: pnid.access_level, title_id: BigInt(0), expire_time: BigInt(Date.now() + (24 * 60 * 60 * 1000)) // Only valid for 24 hours }; - const passwordResetToken: string | null = await generateToken(cryptoOptions, tokenOptions); + const tokenBuffer: Buffer | null = await generateToken(config.aes_key, tokenOptions); + const passwordResetToken: string = tokenBuffer ? tokenBuffer.toString('hex') : ''; // TODO - Handle null token @@ -317,9 +190,9 @@ export async function sendForgotPasswordEmail(pnid: mongoose.HydratedDocument