UNPKG

@origami-minecraft/devbuilds

Version:

Origami is a terminal-first Minecraft launcher that supports authentication, installation, and launching of Minecraft versions — with built-in support for Microsoft accounts, mod loaders, profile management, and more. Designed for power users, modders, an

344 lines (274 loc) 12 kB
import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import crypto from 'crypto'; import keytar from 'keytar'; import { LauncherAccounts, LauncherAccount } from '../../../types/launcher'; import { minecraft_dir } from '../../utils/common'; import { Credentials } from '../../../types/account'; import { logger } from '../launch/handler'; import chalk from 'chalk'; import inquirer from 'inquirer'; import { ORIGAMI_CLIENT_TOKEN } from '../../../config/defaults'; import { Separator } from '@inquirer/prompts'; import { getAuthProviders } from '.'; const SERVICE = 'OrigamiLauncher'; const ACCOUNT = os.userInfo().username; const IV_LENGTH = 16; const HMAC_ALGO = 'sha256'; const mcDir = minecraft_dir(true); const launcherProfilesPath = path.join(mcDir, 'accounts.dat'); const old_launcherProfilesPath = path.join(mcDir, 'launcher_profiles.json'); async function getOrGenerateKey(): Promise<Buffer> { const stored = await keytar.getPassword(SERVICE, ACCOUNT); if (stored) { return Buffer.from(stored, 'hex'); } const fingerprint = `${os.hostname()}-${os.arch()}-${os.platform()}-${ORIGAMI_CLIENT_TOKEN}`; const salt = crypto.randomBytes(16); const key = crypto.pbkdf2Sync(fingerprint, salt, 100_000, 32, 'sha256'); await keytar.setPassword(SERVICE, ACCOUNT, key.toString('hex')); return key; } function computeHMAC(data: string, key: Buffer): string { return crypto.createHmac(HMAC_ALGO, key).update(data).digest('base64'); } function encryptWithKey(text: string, key: Buffer): string { const iv = crypto.randomBytes(IV_LENGTH); const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); let encrypted = cipher.update(text, 'utf8', 'base64'); encrypted += cipher.final('base64'); return iv.toString('base64') + ':' + encrypted; } function decryptWithKey(text: string, key: Buffer): string { const [ivBase64, encrypted] = text.split(':'); const iv = Buffer.from(ivBase64, 'base64'); const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); let decrypted = decipher.update(encrypted, 'base64', 'utf8'); decrypted += decipher.final('utf8'); return decrypted; } async function migrateLegacyFormat(filePath: string, currentKey: Buffer): Promise<LauncherAccounts | null> { try { const old = fs.existsSync(old_launcherProfilesPath) ? fs.readFileSync(old_launcherProfilesPath, 'utf-8') : '{}'; // Case 1: Plain JSON file (unencrypted) try { const parsed = JSON.parse(old); if (parsed.accounts) { logger.warn('⚠️ Detected old launcher_profiles accounts. Migrating to encrypted format...'); const newData = parsed as LauncherAccounts; const plaintext = JSON.stringify(newData); const encrypted = encryptWithKey(plaintext, currentKey); const hmac = computeHMAC(encrypted, currentKey); const wrapped = { encrypted, hmac }; fs.writeFileSync(filePath, JSON.stringify(wrapped, null, 2)); if(fs.existsSync(old_launcherProfilesPath)) { delete parsed.accounts; fs.writeFileSync(old_launcherProfilesPath, JSON.stringify(parsed, null, 2)); }; return newData; } } catch (_) { // Not valid JSON, fall through to next check } const raw = fs.readFileSync(filePath, 'utf-8'); // Case 1: Plain JSON file (unencrypted) try { const parsed = JSON.parse(raw); if (parsed.accounts) { logger.warn('⚠️ Detected unencrypted accounts.dat. Migrating to encrypted format...'); const newData = parsed as LauncherAccounts; const plaintext = JSON.stringify(newData); const encrypted = encryptWithKey(plaintext, currentKey); const hmac = computeHMAC(encrypted, currentKey); const wrapped = { encrypted, hmac }; fs.writeFileSync(filePath, JSON.stringify(wrapped, null, 2)); return newData; } } catch (_) { // Not valid JSON, fall through to next check } // Case 2: Legacy encrypted format (AES-256-CBC with static ORIGAMI_CLIENT_TOKEN) try { const legacyKey = crypto.createHash('sha256').update(ORIGAMI_CLIENT_TOKEN).digest(); const decrypted = decryptWithKey(raw, legacyKey); const parsed = JSON.parse(decrypted); if (parsed.accounts) { logger.warn('⚠️ Detected legacy-encrypted accounts.dat. Migrating to encrypted format...'); const newData = parsed as LauncherAccounts; const plaintext = JSON.stringify(newData); const encrypted = encryptWithKey(plaintext, currentKey); const hmac = computeHMAC(encrypted, currentKey); const wrapped = { encrypted, hmac }; fs.writeFileSync(filePath, JSON.stringify(wrapped, null, 2)); return newData; } } catch (_) { // Not decryptable with legacy key } return null; } catch (err) { logger.error('❌ Failed to read or migrate legacy accounts.dat:', (err as Error).message); return null; } } export class LauncherAccountManager { private filePath: string; private data: LauncherAccounts; private key: Buffer | null = null; constructor(filePath: string = launcherProfilesPath) { this.filePath = filePath; this.data = { accounts: {} }; this.load(); } private async ensureKey() { if (!this.key) { this.key = await getOrGenerateKey(); } } async load() { await this.ensureKey(); if (fs.existsSync(this.filePath)) { try { const rawContent = fs.readFileSync(this.filePath, 'utf-8'); const parsed = JSON.parse(rawContent); if (!parsed.encrypted || !parsed.hmac) { throw new Error("Not new format"); } const { encrypted, hmac } = parsed; const computedHmac = computeHMAC(encrypted, this.key!); if (computedHmac !== hmac) { throw new Error('HMAC validation failed.'); } const decrypted = decryptWithKey(encrypted, this.key!); const json = JSON.parse(decrypted); if (json.selectedAccount) { this.data.selectedAccount = json.selectedAccount; } if (json.accounts) { this.data.accounts = json.accounts; } else { throw new Error("Decrypted JSON does not contain accounts."); } } catch (err) { logger.warn('⚠️ Encrypted load failed. Attempting migration...'); const migrated = await migrateLegacyFormat(this.filePath, this.key!); if (migrated) { this.data = migrated; } else { logger.error('❌ Could not migrate legacy accounts.dat. Starting fresh.'); this.data = { accounts: {} }; } } } else { await this.save(); } } async save() { await this.ensureKey(); const plaintext = JSON.stringify({ accounts: this.data.accounts, selectedAccount: this.data.selectedAccount }); const encrypted = encryptWithKey(plaintext, this.key!); const hmac = computeHMAC(encrypted, this.key!); const final = { encrypted, hmac }; fs.writeFileSync(this.filePath, JSON.stringify(final, null, 2)); } reset() { if (fs.existsSync(this.filePath)) { fs.unlinkSync(this.filePath); } } async addAccount(account: LauncherAccount) { await this.load(); this.data.accounts[account.id] = account; await this.save(); } async deleteAccount(id: string) { await this.load(); if (this.data.accounts[id]) { delete this.data.accounts[id]; if (this.data.selectedAccount === id) this.data.selectedAccount = undefined; await this.save(); return true; } return false; } async hasAccount(cred: Credentials, provider: string): Promise<boolean> { await this.load(); return Object.values(this.data.accounts).some(acc => acc.auth.name === provider.toLowerCase() && acc.credentials === cred); } async getAccount(id: string): Promise<LauncherAccount | null> { await this.load(); const acc = this.data.accounts[id]; if (!acc) { logger.error(`Account "${id}" does not exist.`); return null; } return acc; } async selectAccount(id: string): Promise<LauncherAccount | null> { const acc = await this.getAccount(id); if (!acc) return null; this.data.selectedAccount = acc.id; await this.save(); return acc; } async listAccounts(): Promise<LauncherAccount[]> { await this.load(); return Object.values(this.data.accounts); } async getSelectedAccount(): Promise<LauncherAccount | null> { await this.load(); return this.getAccount(this.data.selectedAccount || 'no-id'); } async chooseAccount(): Promise<LauncherAccount | null> { const accounts = await this.listAccounts(); if (accounts.length === 0) { console.log(chalk.red("❌ No accounts found.")); return null; } const allProviders = await getAuthProviders(); const providerMeta = new Map<string, { name: string; base: string }>(); for (const [key, ctor] of allProviders.entries()) { try { const meta = new ctor('', '').metadata; providerMeta.set(key, meta); } catch { providerMeta.set(key, { name: key, base: "Other" }); } } const groupedByBase: Record<string, LauncherAccount[]> = {}; for (const acc of accounts) { const authKey = acc.auth; const meta = providerMeta.get(authKey.name); const base = meta?.base ?? "Other"; if (!groupedByBase[base]) groupedByBase[base] = []; groupedByBase[base].push(acc); } const sortedBases = Object.keys(groupedByBase).sort(); const choices: Array<Separator | { name: string; value: string }> = []; for (const base of sortedBases) { choices.push(new inquirer.Separator(chalk.bold.cyan(`🔑 ${base.toUpperCase()}`))); const providerAccounts = groupedByBase[base] .sort((a, b) => (a.name || 'other').localeCompare(b.name || 'other')); for (const acc of providerAccounts) { const line = `${chalk.hex('#4ade80')(acc.name)} ${chalk.gray(`(${acc.uuid?.slice(0, 8)}...)`)} - ${chalk.hex('#facc15')(acc.auth.name || 'No info')}`; choices.push({ name: line, value: acc.id }); } } const { selectedId } = await inquirer.prompt([ { type: "list", name: "selectedId", message: chalk.hex("#60a5fa")("🎭 Choose an account to use:"), choices, loop: false } ]); const selectedAccount = await this.selectAccount(selectedId); if (selectedAccount) { console.log(chalk.green(`✅ Selected account: ${selectedAccount.name}`)); } return selectedAccount; } } export default LauncherAccountManager;