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