diff --git a/README.md b/README.md index 2a15a71..d1655a9 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,18 @@ docker run -d --name 2fauth-worker \ +## 🔓 离线恢复 (Offline Recovery) + +即使本项目停止运行,您也可以通过备份文件和您的加密密码找回 TOTP 原始数据。我们提供了一个独立的离线解密脚本,它不依赖于任何外部服务器: + +- **脚本路径**:[scripts/decrypt_backup.js](file:///workspaces/2fa.hsiao.nom.za/scripts/decrypt_backup.js) +- **使用环境**:仅需安装 [Node.js](https://nodejs.org/) (内置 crypto 模块,无需 npm install)。 +- **使用方法**: + ```bash + node scripts/decrypt_backup.js <加密的备份文件.json> <您的密码> + ``` + 解密后的数据将以明文 JSON 格式输出到控制台或保存到文件。 + --- ## 🔒 深度安全审计 (Security Auditing) diff --git a/README_EN.md b/README_EN.md index b388375..fc51a0f 100644 --- a/README_EN.md +++ b/README_EN.md @@ -281,6 +281,18 @@ Regardless of the deployment method, these parameters are critical: +## 🔓 Offline Recovery + +Even if this project stops running, you can still recover your TOTP raw data using your backup file and encryption password. We provide a standalone offline decryption script that does not depend on any external servers: + +- **Script Path**: [scripts/decrypt_backup.js](file:///workspaces/2fa.hsiao.nom.za/scripts/decrypt_backup.js) +- **Requirement**: Just [Node.js](https://nodejs.org/) (uses built-in crypto module, no `npm install` required). +- **Usage**: + ```bash + node scripts/decrypt_backup.js + ``` + The decrypted data will be output to the console or saved to a file in plaintext JSON format. + --- ## 🔒 Security Auditing diff --git a/scripts/decrypt_backup.js b/scripts/decrypt_backup.js new file mode 100644 index 0000000..accc189 --- /dev/null +++ b/scripts/decrypt_backup.js @@ -0,0 +1,108 @@ +#!/usr/bin/env node + +/** + * 2FAuth Backup Decryptor + * ----------------------- + * A standalone script to decrypt 2FAuth backup files. + * + * Usage: node decrypt_backup.js + */ + +const fs = require('fs'); +const crypto = require('crypto'); +const path = require('path'); + +// Crypto Configuration (Must match 2FAuth standards) +const CRYPTO_CONFIG = { + ALGO_NAME: 'aes-256-gcm', + KDF_NAME: 'sha256', + ITERATIONS: 100000, + KEY_LEN: 32, + SALT_LEN: 16, + IV_LEN: 12, + TAG_LEN: 16 +}; + +function main() { + const args = process.argv.slice(2); + if (args.length < 2) { + console.log('\x1b[33m%s\x1b[0m', 'Usage: node decrypt_backup.js '); + process.exit(1); + } + + const filePath = path.resolve(args[0]); + const password = args[1]; + + if (!fs.existsSync(filePath)) { + console.error('\x1b[31m%s\x1b[0m', `Error: File not found at ${filePath}`); + process.exit(1); + } + + let backupContent; + try { + const fileData = fs.readFileSync(filePath, 'utf8'); + backupContent = JSON.parse(fileData); + } catch (e) { + console.error('\x1b[31m%s\x1b[0m', `Error: Failed to read or parse JSON file: ${e.message}`); + process.exit(1); + } + + if (!backupContent.encrypted || !backupContent.data) { + console.error('\x1b[31m%s\x1b[0m', 'Error: This file does not appear to be an encrypted 2FAuth backup or it is already decrypted.'); + process.exit(1); + } + + try { + const decryptedData = decrypt(backupContent.data, password); + const prettyJson = JSON.stringify(decryptedData, null, 2); + + console.log('\x1b[32m%s\x1b[0m', '✅ Decryption Successful!'); + console.log('\x1b[36m%s\x1b[0m', '--- Decrypted Content ---'); + console.log(prettyJson); + console.log('\x1b[36m%s\x1b[0m', '-------------------------'); + + // Optional: Save to file + const outputFilename = `decrypted-${path.basename(filePath)}`; + fs.writeFileSync(outputFilename, prettyJson); + console.log('\x1b[32m%s\x1b[0m', `Result saved to: ${outputFilename}`); + + } catch (e) { + console.error('\x1b[31m%s\x1b[0m', `❌ Decryption Failed: ${e.message}`); + console.error('\x1b[33m%s\x1b[0m', 'Check if your password is correct.'); + process.exit(1); + } +} + +/** + * Decrypts the base64 data string using the provided password + */ +function decrypt(base64Data, password) { + const combined = Buffer.from(base64Data, 'base64'); + + // Structure: [salt(16)] + [iv(12)] + [ciphertext(remaining - 16)] + [tag(16)] + // Note: Node.js decipher.setAuthTag requires the tag to be separated. + const salt = combined.subarray(0, CRYPTO_CONFIG.SALT_LEN); + const iv = combined.subarray(CRYPTO_CONFIG.SALT_LEN, CRYPTO_CONFIG.SALT_LEN + CRYPTO_CONFIG.IV_LEN); + const authTag = combined.subarray(combined.length - CRYPTO_CONFIG.TAG_LEN); + const ciphertext = combined.subarray(CRYPTO_CONFIG.SALT_LEN + CRYPTO_CONFIG.IV_LEN, combined.length - CRYPTO_CONFIG.TAG_LEN); + + // Derive key + const key = crypto.pbkdf2Sync( + password, + salt, + CRYPTO_CONFIG.ITERATIONS, + CRYPTO_CONFIG.KEY_LEN, + CRYPTO_CONFIG.KDF_NAME + ); + + // Decrypt + const decipher = crypto.createDecipheriv(CRYPTO_CONFIG.ALGO_NAME, key, iv); + decipher.setAuthTag(authTag); + + let decrypted = decipher.update(ciphertext, null, 'utf8'); + decrypted += decipher.final('utf8'); + + return JSON.parse(decrypted); +} + +main();