import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import * as crypto from 'crypto'; import { execSync } from 'child_process'; export interface SavedCookie { name: string; value: string; domain: string; path: string; expires: number; httpOnly: boolean; secure: boolean; sameSite: 'Strict' | 'Lax' | 'None'; } const ALGORITHM = 'aes-256-cbc'; const SERVICE_NAME = 'atcoder-next'; const ACCOUNT_NAME = 'encryption-key'; function getKeyFromKeychain(): Buffer | null { try { const platform = process.platform; if (platform === 'darwin') { const cmd = `security find-generic-password -s "${SERVICE_NAME}" -a "${ACCOUNT_NAME}" -w`; const stdout = execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim(); if (stdout) { return Buffer.from(stdout, 'hex'); } } else if (platform === 'win32') { const keyFile = path.join(os.homedir(), '.atcoder-next', '.key.dpapi'); if (fs.existsSync(keyFile)) { const encryptedBase64 = fs.readFileSync(keyFile, 'utf8').trim(); const psCmd = `[System.Text.Encoding]::UTF8.GetString([System.Security.Cryptography.ProtectedData]::Unprotect([System.Convert]::FromBase64String('${encryptedBase64}'), $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser))`; const stdout = execSync(`powershell -NoProfile -NonInteractive -Command "${psCmd}"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim(); if (stdout) { return Buffer.from(stdout, 'hex'); } } } else if (platform === 'linux') { const cmd = `secret-tool lookup service "${SERVICE_NAME}" account "${ACCOUNT_NAME}"`; const stdout = execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim(); if (stdout) { return Buffer.from(stdout, 'hex'); } } } catch { // Keychain lookup failed or OS tool not available } return null; } function saveKeyToKeychain(key: Buffer): boolean { try { const platform = process.platform; const keyHex = key.toString('hex'); if (platform === 'darwin') { const cmd = `security add-generic-password -U -s "${SERVICE_NAME}" -a "${ACCOUNT_NAME}" -w "${keyHex}"`; execSync(cmd, { stdio: ['pipe', 'pipe', 'ignore'] }); return true; } else if (platform === 'win32') { const dir = path.join(os.homedir(), '.atcoder-next'); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } const keyFile = path.join(dir, '.key.dpapi'); const psCmd = `[System.Convert]::ToBase64String([System.Security.Cryptography.ProtectedData]::Protect([System.Text.Encoding]::UTF8.GetBytes('${keyHex}'), $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser))`; const encryptedBase64 = execSync(`powershell -NoProfile -NonInteractive -Command "${psCmd}"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim(); if (encryptedBase64) { fs.writeFileSync(keyFile, encryptedBase64, { encoding: 'utf8', mode: 0o600 }); return true; } } else if (platform === 'linux') { const cmd = `secret-tool store --label="AtCoder Next Key" service "${SERVICE_NAME}" account "${ACCOUNT_NAME}"`; execSync(cmd, { input: keyHex, encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }); return true; } } catch { // Keychain save failed } return false; } function getEncryptionKey(): Buffer { // 1. Try loading from OS keychain const keychainKey = getKeyFromKeychain(); if (keychainKey && keychainKey.length === 32) { return keychainKey; } const dir = path.join(os.homedir(), '.atcoder-next'); const keyPath = path.join(dir, '.key'); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } // 2. Try loading from existing fallback .key file if (fs.existsSync(keyPath)) { try { const fileKey = fs.readFileSync(keyPath); if (fileKey.length === 32) { // Upgrade: attempt to sync key into OS keychain for future use saveKeyToKeychain(fileKey); return fileKey; } } catch { // Fallback if read fails } } // 3. Generate new 32-byte key const newKey = crypto.randomBytes(32); // Try saving to OS keychain first const savedToKeychain = saveKeyToKeychain(newKey); // Always keep a local fallback key file if keychain is unavailable or fails if (!savedToKeychain || !fs.existsSync(keyPath)) { try { fs.writeFileSync(keyPath, newKey, { mode: 0o600 }); } catch {} } return newKey; } function encrypt(text: string): string { try { const key = getEncryptionKey(); const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv(ALGORITHM, key, iv); let encrypted = cipher.update(text, 'utf8', 'hex'); encrypted += cipher.final('hex'); return iv.toString('hex') + ':' + encrypted; } catch { return text; // Fallback } } function decrypt(text: string): string { try { const key = getEncryptionKey(); const parts = text.split(':'); if (parts.length < 2) return text; // Not encrypted const iv = Buffer.from(parts.shift() || '', 'hex'); const encryptedText = parts.join(':'); const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); let decrypted = decipher.update(encryptedText, 'hex', 'utf8'); decrypted += decipher.final('utf8'); return decrypted; } catch { return text; // Fallback } } export function getSessionPath(workspaceRoot?: string): string { return path.join(os.homedir(), '.atcoder-next', 'session.json'); } export function saveSession(workspaceRoot: string, cookies: SavedCookie[]): void { const sessionPath = getSessionPath(); const dir = path.dirname(sessionPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } const jsonStr = JSON.stringify(cookies, null, 2); const encryptedData = encrypt(jsonStr); fs.writeFileSync(sessionPath, encryptedData, { encoding: 'utf8', mode: 0o600 }); } export function loadSession(workspaceRoot: string): SavedCookie[] | null { const globalSessionPath = getSessionPath(); // Migration: if global session doesn't exist, but local session does if (!fs.existsSync(globalSessionPath) && workspaceRoot) { const localSessionPath = path.join(workspaceRoot, '.atcoder-next', 'session.json'); if (fs.existsSync(localSessionPath)) { try { const raw = fs.readFileSync(localSessionPath, 'utf8'); const cookies = JSON.parse(raw) as SavedCookie[]; // Save globally with encryption saveSession(workspaceRoot, cookies); // Remove local plaintext file immediately fs.unlinkSync(localSessionPath); return cookies; } catch (err) { return null; } } } if (!fs.existsSync(globalSessionPath)) { return null; } try { const raw = fs.readFileSync(globalSessionPath, 'utf8'); const decrypted = decrypt(raw); return JSON.parse(decrypted) as SavedCookie[]; } catch (e) { return null; } } export function clearSession(workspaceRoot: string): void { const globalSessionPath = getSessionPath(); if (fs.existsSync(globalSessionPath)) { try { fs.unlinkSync(globalSessionPath); } catch {} } // Also clean up local session if it exists if (workspaceRoot) { const localSessionPath = path.join(workspaceRoot, '.atcoder-next', 'session.json'); if (fs.existsSync(localSessionPath)) { try { fs.unlinkSync(localSessionPath); } catch (e) {} } } } export function getCookieHeaderString(cookies: SavedCookie[]): string { return cookies.map(c => `${c.name}=${c.value}`).join('; '); }