diff --git a/example.config.json b/example.config.json index 8a1654d..6e28a91 100644 --- a/example.config.json +++ b/example.config.json @@ -16,5 +16,6 @@ "tester_roles": [ "role id" ] - } + }, + "aes_key": "hex key here" } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ce84c29..8c9d79c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "adm-zip": "^0.5.9", "colors": "^1.4.0", "cookie-parser": "^1.4.5", "discord-oauth2": "github:ryanblenis/discord-oauth2", @@ -306,6 +307,14 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adm-zip": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.9.tgz", + "integrity": "sha512-s+3fXLkeeLjZ2kLjCBwQufpI5fuN+kIGBxu6530nVQZGVol0d7Y/M88/xw9HGGUcJjKf8LutN3VPRUBq6N7Ajg==", + "engines": { + "node": ">=6.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2668,6 +2677,11 @@ "dev": true, "requires": {} }, + "adm-zip": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.9.tgz", + "integrity": "sha512-s+3fXLkeeLjZ2kLjCBwQufpI5fuN+kIGBxu6530nVQZGVol0d7Y/M88/xw9HGGUcJjKf8LutN3VPRUBq6N7Ajg==" + }, "ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", diff --git a/package.json b/package.json index 09f8cd3..cb903f0 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ }, "homepage": "https://github.com/PretendoNetwork/website#readme", "dependencies": { + "adm-zip": "^0.5.9", "colors": "^1.4.0", "cookie-parser": "^1.4.5", "discord-oauth2": "github:ryanblenis/discord-oauth2", diff --git a/public/assets/css/account.css b/public/assets/css/account.css index 9e2515b..8122bb6 100644 --- a/public/assets/css/account.css +++ b/public/assets/css/account.css @@ -25,6 +25,17 @@ border-radius: 100%; background: var(--btn-secondary); } +.account-sidebar .user #download-cemu-files { + width: 100%; + padding: 4px 16px; + cursor: pointer; + background: #383f6b; + font-size: 0.9rem; + margin: 8px 0 0; +} +.account-sidebar .user .cemu-warning { + font-size: 0.7rem; +} /* Settings */ .settings-wrapper { diff --git a/public/assets/js/account.js b/public/assets/js/account.js index ceed879..2e6da46 100644 --- a/public/assets/js/account.js +++ b/public/assets/js/account.js @@ -1,5 +1,5 @@ -document.getElementById('remove-discord-connection').addEventListener('click', () => { +document.getElementById('remove-discord-connection')?.addEventListener('click', () => { // TODO: Refresh access token if expired (move this to the backend maybe?) const tokenType = document.cookie.split('; ').find(row => row.startsWith('token_type=')).split('=')[1]; diff --git a/src/routers/account.js b/src/routers/account.js index 7554d71..0af5271 100644 --- a/src/routers/account.js +++ b/src/routers/account.js @@ -1,9 +1,11 @@ const { Router } = require('express'); const crypto = require('crypto'); const DiscordOauth2 = require('discord-oauth2'); +const AdmZip = require('adm-zip'); const util = require('../util'); const config = require('../../config.json'); const router = new Router(); +const aesKey = Buffer.from(config.aes_key, 'hex'); // Create OAuth client const discordOAuth = new DiscordOauth2({ @@ -15,7 +17,7 @@ const discordOAuth = new DiscordOauth2({ router.get('/', async (request, response) => { // Verify the user is logged in - if (!request.cookies.access_token || !request.cookies.refresh_token) { + if (!request.cookies.access_token || !request.cookies.refresh_token || !request.cookies.ph) { return response.redirect('/account/login'); } @@ -191,7 +193,7 @@ router.get('/login', async (request, response) => { router.post('/login', async (request, response) => { const { username, password } = request.body; - const apiResponse = await util.apiPostGetRequest('/v1/login', {}, { + let apiResponse = await util.apiPostGetRequest('/v1/login', {}, { username, password, grant_type: 'password' @@ -208,6 +210,22 @@ router.post('/login', async (request, response) => { response.cookie('access_token', tokens.access_token, { domain : '.pretendo.network' }); response.cookie('token_type', tokens.token_type, { domain : '.pretendo.network' }); + apiResponse = await util.apiGetRequest('/v1/user', { + 'Authorization': `${tokens.token_type} ${tokens.access_token}` + }); + + const account = apiResponse.body; + + const hashedPassword = util.nintendoPasswordHash(password, account.pid); + const hashedPasswordBuffer = Buffer.from(hashedPassword, 'hex'); + + const cipher = crypto.createCipheriv('aes-256-cbc', aesKey, Buffer.alloc(16)); + + let encryptedBody = cipher.update(hashedPasswordBuffer); + encryptedBody = Buffer.concat([encryptedBody, cipher.final()]); + + response.cookie('ph', encryptedBody.toString('hex'), { domain: '.pretendo.network' }); + response.redirect('/account'); }); @@ -304,9 +322,9 @@ router.get('/connect/discord', async (request, response) => { const tokens = apiResponse.body; - response.cookie('refresh_token', tokens.refresh_token, { domain : '.pretendo.network' }); - response.cookie('access_token', tokens.access_token, { domain : '.pretendo.network' }); - response.cookie('token_type', tokens.token_type, { domain : '.pretendo.network' }); + response.cookie('refresh_token', tokens.refresh_token, { domain: '.pretendo.network' }); + response.cookie('access_token', tokens.access_token, { domain: '.pretendo.network' }); + response.cookie('token_type', tokens.token_type, { domain: '.pretendo.network' }); apiResponse = await util.apiPostGetRequest('/v1/connections/add/discord', { 'Authorization': `${tokens.token_type} ${tokens.access_token}` @@ -330,4 +348,76 @@ router.get('/connect/discord', async (request, response) => { response.cookie('linked', 'Discord', { domain: '.pretendo.network' }).redirect('/account'); }); +router.get('/online-files', async (request, response) => { + + // Verify the user is logged in + if (!request.cookies.access_token || !request.cookies.refresh_token|| !request.cookies.ph) { + return response.redirect('/account/login'); + } + + // Attempt to get user data + let apiResponse = await util.apiGetRequest('/v1/user', { + 'Authorization': `${request.cookies.token_type} ${request.cookies.access_token}` + }); + + if (apiResponse.statusCode !== 200) { + // Assume expired, refresh and retry request + apiResponse = await util.apiPostGetRequest('/v1/login', {}, { + refresh_token: request.cookies.refresh_token, + grant_type: 'refresh_token' + }); + + if (apiResponse.statusCode !== 200) { + // TODO: Error message + return response.status(apiResponse.statusCode).json({ + error: 'Bad' + }); + } + + const tokens = apiResponse.body; + + response.cookie('refresh_token', tokens.refresh_token, { domain: '.pretendo.network' }); + response.cookie('access_token', tokens.access_token, { domain: '.pretendo.network' }); + response.cookie('token_type', tokens.token_type, { domain: '.pretendo.network' }); + + apiResponse = await util.apiGetRequest('/v1/user', { + 'Authorization': `${tokens.token_type} ${tokens.access_token}` + }); + } + + // If still failed, something went horribly wrong + if (apiResponse.statusCode !== 200) { + // TODO: Error message + return response.status(apiResponse.statusCode).json({ + error: 'Bad' + }); + } + + const account = apiResponse.body; + + const decipher = crypto.createDecipheriv('aes-256-cbc', aesKey, Buffer.alloc(16)); + + let decryptedPasswordHash = decipher.update(Buffer.from(request.cookies.ph, 'hex')); + decryptedPasswordHash = Buffer.concat([decryptedPasswordHash, decipher.final()]); + + let accountDat = 'AccountInstance_00000000\n'; + accountDat += `AccountPasswordCache=${decryptedPasswordHash.toString('hex')}\n`; + accountDat += 'IsPasswordCacheEnabled=1\n'; + accountDat += `AccountId=${account.username}\n`; + accountDat += 'PersistentId=80000001'; + + const onlineFiles = new AdmZip(); + + onlineFiles.addFile('mlc01/usr/save/system/act/80000001/account.dat', Buffer.from(accountDat)); // Minimal account.dat + onlineFiles.addFile('otp.bin', Buffer.alloc(0x400)); // nulled OTP + onlineFiles.addFile('seeprom.bin', Buffer.alloc(0x200)); // nulled SEEPROM + + response.writeHead(200, { + 'Content-Disposition': 'attachment; filename="Online Files.zip"', + 'Content-Type': 'application/zip', + }); + + response.end(onlineFiles.toBuffer()); +}); + module.exports = router; \ No newline at end of file diff --git a/src/util.js b/src/util.js index 5ff9a1d..5ad2251 100644 --- a/src/util.js +++ b/src/util.js @@ -1,5 +1,6 @@ const fs = require('fs-extra'); const got = require('got'); +const crypto = require('crypto'); const logger = require('./logger'); function fullUrl(request) { @@ -52,10 +53,26 @@ function apiDeleteGetRequest(path, headers, json) { }); } +function nintendoPasswordHash(password, pid) { + const pidBuffer = Buffer.alloc(4); + pidBuffer.writeUInt32LE(pid); + + const unpacked = Buffer.concat([ + pidBuffer, + Buffer.from('\x02\x65\x43\x46'), + Buffer.from(password) + ]); + + const hashed = crypto.createHash('sha256').update(unpacked).digest().toString('hex'); + + return hashed; +} + module.exports = { fullUrl, getLocale, apiGetRequest, apiPostGetRequest, - apiDeleteGetRequest + apiDeleteGetRequest, + nintendoPasswordHash }; \ No newline at end of file diff --git a/views/account.handlebars b/views/account.handlebars index ea5ca07..fe06330 100644 --- a/views/account.handlebars +++ b/views/account.handlebars @@ -9,7 +9,9 @@
{{account.mii.name}}
-PNID: {{account.username}}
+PNID: {{account.username}}
+ Download account files +(will not work with Nintendo Network)