Changed token format to be much smaller (thanks 3DS)

This commit is contained in:
Jonathan Barrow
2023-04-01 11:15:59 -04:00
parent 2e0bb207a9
commit 55ee3ce18b
14 changed files with 203 additions and 574 deletions

View File

@@ -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<void> {
@@ -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<Buffer> {
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<Buffer> {
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<Buffer> {
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<Buffer> {
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<void> {
await setCachedFile(`nex:${name}:public_key`, value);
}
export async function setNEXPrivateKey(name: string, value: Buffer): Promise<void> {
await setCachedFile(`nex:${name}:private_key`, value);
}
export async function setNEXSecretKey(name: string, value: Buffer): Promise<void> {
await setCachedFile(`nex:${name}:secret_key`, value);
}
export async function setNEXAESKey(name: string, value: Buffer): Promise<void> {
await setCachedFile(`nex:${name}:aes_key`, value);
}
// * 3rd party service cache functions
export async function getServicePublicKey(name: string, encoding?: BufferEncoding): Promise<Buffer> {
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<Buffer> {
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<Buffer> {
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<Buffer> {
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<void> {
await setCachedFile(`service:${name}:public_key`, value);
}
export async function setServicePrivateKey(name: string, value: Buffer): Promise<void> {
await setCachedFile(`service:${name}:private_key`, value);
}
export async function setServiceSecretKey(name: string, value: Buffer): Promise<void> {
await setCachedFile(`service:${name}:secret_key`, value);
}
export async function setServiceAESKey(name: string, value: Buffer): Promise<void> {
await setCachedFile(`service:${name}:aes_key`, value);
}
// * Local CDN cache functions
export async function getLocalCDNFile(name: string, encoding?: BufferEncoding): Promise<Buffer> {

View File

@@ -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);
}

View File

@@ -104,7 +104,7 @@ export async function getPNIDByBearerAuth(token: string): Promise<HydratedPNIDDo
verifyConnected();
try {
const decryptedToken: Buffer = await decryptToken(Buffer.from(token, 'base64'));
const decryptedToken: Buffer = await decryptToken(config.aes_key, Buffer.from(token, 'hex'));
const unpackedToken: Token = unpackToken(decryptedToken);
const pnid: HydratedPNIDDocument | null = await getPNIDByPID(unpackedToken.pid);

View File

@@ -3,15 +3,16 @@ import uniqueValidator from 'mongoose-unique-validator';
import { IServer, IServerMethods, ServerModel } from '@/types/mongoose/server';
const ServerSchema = new Schema<IServer, ServerModel, IServerMethods>({
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.' });

View File

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

View File

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

View File

@@ -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);

View File

@@ -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<URLSearchParams> {
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<URLSearchP
const server: HydratedServerDocument | null = await getServerByTitleId(titleID, serverAccessLevel);
if (!server || !server.service_name || !server.ip) {
return nascError('110');
if (!server || !server.aes_key) {
return response.status(200).send( nascError('110').toString());
}
if (server.port <= 0 && server.ip !== '0.0.0.0') {
if (action === 'LOGIN' && server.port <= 0 && server.ip !== '0.0.0.0') {
// * Addresses of 0.0.0.0:0 are allowed
// * They are expected for titles with no NEX server
return nascError('110');
return response.status(200).send( nascError('110').toString());
}
const serverName: string = server.service_name;
const ip: string = server.ip;
const port: number = server.port;
switch (action) {
case 'LOGIN':
responseData = await processLoginRequest(server, nexAccount.pid, titleID);
break;
case 'SVCLOC':
responseData = await processServiceTokenRequest(server, nexAccount.pid, titleID);
break;
}
const publicKey: Buffer = await getNEXPublicKey(serverName);
const secretKey: Buffer = await getNEXSecretKey(serverName);
const cryptoOptions: CryptoOptions = {
public_key: publicKey,
hmac_secret: secretKey
};
response.status(200).send(responseData.toString());
});
async function processLoginRequest(server: HydratedServerDocument, pid: number, titleID: string): Promise<URLSearchParams> {
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<URLSearchP
// TODO - Handle null tokens
let nexToken: string | null = await generateToken(cryptoOptions, tokenOptions);
nexToken = nintendoBase64Encode(Buffer.from(nexToken || '', 'base64'));
const nexTokenBuffer: Buffer | null = await generateToken(server.aes_key, tokenOptions);
const nexToken: string = nintendoBase64Encode(nexTokenBuffer || '');
return new URLSearchParams({
locator: nintendoBase64Encode(`${ip}:${port}`),
locator: nintendoBase64Encode(`${server.ip}:${server.port}`),
retry: nintendoBase64Encode('0'),
returncd: nintendoBase64Encode('001'),
token: nexToken,
@@ -97,11 +80,25 @@ async function processLoginRequest(request: express.Request): Promise<URLSearchP
});
}
async function processServiceTokenRequest(_request: express.Request): Promise<URLSearchParams> {
async function processServiceTokenRequest(server: HydratedServerDocument, pid: number, titleID: string): Promise<URLSearchParams> {
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()),

View File

@@ -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: {

View File

@@ -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());

View File

@@ -37,6 +37,7 @@ export interface Config {
base_url: string;
};
website_base: string;
aes_key: string;
}
export interface DisabledFeatures {

View File

@@ -1,4 +0,0 @@
export interface CryptoOptions {
public_key: Buffer;
hmac_secret: Buffer;
};

View File

@@ -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 {}

View File

@@ -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<string | null> {
// 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<Buffer> {
// 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<IP
}
export async function sendForgotPasswordEmail(pnid: mongoose.HydratedDocument<IPNID, IPNIDMethods>): Promise<void> {
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<IP
paragraph: 'a password reset has been requested from this account. If you did not request the password reset, please ignore this email. If you did request this password reset, please click the link below to reset your password.',
link: {
text: 'Reset password',
href: `${config.website_base}/account/reset-password?token=${encodeURIComponent(passwordResetToken || '')}`
href: `${config.website_base}/account/reset-password?token=${encodeURIComponent(passwordResetToken)}`
},
text: `Dear ${pnid.username}, a password reset has been requested from this account. \r\n\r\nIf you did not request the password reset, please ignore this email. \r\nIf you did request this password reset, please click the link to reset your password: ${config.website_base}/account/reset-password?token=${encodeURIComponent(passwordResetToken || '')}`
text: `Dear ${pnid.username}, a password reset has been requested from this account. \r\n\r\nIf you did not request the password reset, please ignore this email. \r\nIf you did request this password reset, please click the link to reset your password: ${config.website_base}/account/reset-password?token=${encodeURIComponent(passwordResetToken)}`
};
await sendMail(mailerOptions);