fix(import): remove legacy import bundle and restore decrypt flow

This commit is contained in:
wuzf
2026-03-29 22:02:27 +08:00
parent 82f3c908a8
commit fa68cf6bf6
6 changed files with 512 additions and 2493 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -259,6 +259,7 @@ export function getPreviewImportCode() {
updateImportStats(validCount, invalidCount, skippedCount);
previewDiv.style.display = 'block';
executeBtn.textContent = '📥 导入';
executeBtn.disabled = validCount === 0;
}
`;

View File

@@ -8,7 +8,7 @@
* @returns {string} JavaScript 代码
*/
export function getTOTPAuthDecryptCode() {
return `
return String.raw`
// ========== TOTP Authenticator 解密 ==========
// TOTP Authenticator 备份数据(临时存储)
@@ -100,11 +100,10 @@ export function getTOTPAuthDecryptCode() {
entries.forEach((entry, index) => {
try {
const issuer = entry.issuer || '';
const name = entry.name || ''; // 这是账户名
const name = entry.name || '';
let secret = entry.key || '';
const base = entry.base || 16; // 默认是十六进制
const base = entry.base || 16;
// 如果是十六进制格式,转换为 Base32
if (base === 16 && secret) {
secret = hexToBase32(secret);
}
@@ -114,11 +113,9 @@ export function getTOTPAuthDecryptCode() {
return;
}
// 获取其他参数(可能为空字符串)
const digits = entry.digits ? parseInt(entry.digits) : 6;
const period = entry.period ? parseInt(entry.period) : 30;
const digits = entry.digits ? parseInt(entry.digits, 10) : 6;
const period = entry.period ? parseInt(entry.period, 10) : 30;
// 构建 otpauth:// URL
let label = '';
if (issuer && name) {
label = encodeURIComponent(issuer) + ':' + encodeURIComponent(name);
@@ -161,6 +158,11 @@ export function getTOTPAuthDecryptCode() {
return;
}
if (!totpAuthBackupData) {
showCenterToast('❌', '未找到 TOTP Authenticator 备份数据');
return;
}
try {
showCenterToast('⏳', '正在解密...');
@@ -171,13 +173,7 @@ export function getTOTPAuthDecryptCode() {
return;
}
// 将解密后的 URL 填入输入框并触发预览
document.getElementById('importText').value = otpauthUrls.join('\\n');
// 隐藏密码输入区
document.getElementById('totpAuthPasswordSection').style.display = 'none';
// 触发预览
document.getElementById('importText').value = otpauthUrls.join('\n');
previewImport();
showCenterToast('✅', '解密成功,共 ' + otpauthUrls.length + ' 条');
@@ -198,7 +194,7 @@ export function getTOTPAuthDecryptCode() {
* @returns {string} JavaScript 代码
*/
export function getFreeOTPDecryptCode() {
return `
return String.raw`
// ========== FreeOTP 解密 ==========
// FreeOTP 备份数据(临时存储)
@@ -207,22 +203,20 @@ export function getFreeOTPDecryptCode() {
/**
* 解析 FreeOTP 加密备份格式Java 序列化的 HashMap
* @param {string} content - 文件内容
* @returns {Object|null} 解析结果,包含 tokens 和 masterKey
* @returns {Object|null} 解析结果,包含 tokens、tokenMeta 和 masterKey
*/
function parseFreeOTPBackup(content) {
// 检测是否是 FreeOTP 备份格式Java 序列化头)
if (!content.includes('java.util.HashMap') && !content.includes('masterKey')) {
return null;
}
const result = {
tokens: {}, // uuid -> { encrypted key data }
tokenMeta: {}, // uuid -> { algo, digits, issuerExt, label, period, type }
masterKey: null // masterKey data
tokens: {},
tokenMeta: {},
masterKey: null
};
try {
// 使用正则表达式提取 token 元数据(明文 JSON
const tokenMetaRegex = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-token[^{]*({[^}]+})/gi;
let match;
while ((match = tokenMetaRegex.exec(content)) !== null) {
@@ -230,12 +224,70 @@ export function getFreeOTPDecryptCode() {
try {
const meta = JSON.parse(match[2]);
result.tokenMeta[uuid] = meta;
} catch (e) {
// 解析失败时静默跳过
} catch (error) {
// 解析失败时静默跳过该条目
}
}
const uuidRegex = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?!-token)/gi;
while ((match = uuidRegex.exec(content)) !== null) {
const uuid = match[1];
if (!result.tokenMeta[uuid]) {
continue;
}
const startPos = match.index + uuid.length;
const keyMarker = '{"key":"';
const keyStart = content.indexOf(keyMarker, startPos);
if (keyStart === -1 || keyStart >= startPos + 50) {
continue;
}
let depth = 0;
let inString = false;
let escape = false;
let jsonEnd = -1;
for (let i = keyStart; i < content.length; i++) {
const char = content[i];
if (escape) {
escape = false;
continue;
}
if (char === '\\') {
escape = true;
continue;
}
if (char === '"') {
inString = !inString;
}
if (!inString) {
if (char === '{') depth++;
if (char === '}') {
depth--;
if (depth === 0) {
jsonEnd = i + 1;
break;
}
}
}
}
if (jsonEnd <= keyStart) {
continue;
}
try {
const keyJson = content.substring(keyStart, jsonEnd);
const keyData = JSON.parse(keyJson);
if (keyData.key) {
result.tokens[uuid] = JSON.parse(keyData.key);
}
} catch (error) {
// 解析失败时静默跳过该条目
}
}
// 提取 masterKey
const masterKeyStart = content.indexOf('"mAlgorithm"');
if (masterKeyStart !== -1) {
let jsonStart = masterKeyStart;
@@ -260,23 +312,382 @@ export function getFreeOTPDecryptCode() {
try {
const masterKeyJson = content.substring(jsonStart, jsonEnd);
result.masterKey = JSON.parse(masterKeyJson);
} catch (e) {
} catch (error) {
// masterKey 解析失败时静默跳过
}
}
}
// 检查是否成功解析
if (Object.keys(result.tokenMeta).length > 0) {
return result;
}
} catch (error) {
// 解析失败时静默返回 null
// 解析失败时返回 null
}
return null;
}
/**
* 将 Java 有符号字节数组转换为 Uint8Array
* @param {Array<number>|Uint8Array} bytes - 原始字节数组
* @returns {Uint8Array} 归一化后的字节数组
*/
function normalizeFreeOTPByteArray(bytes) {
if (bytes instanceof Uint8Array) {
return bytes;
}
if (!Array.isArray(bytes)) {
return new Uint8Array();
}
return new Uint8Array(bytes.map(byte => (byte < 0 ? byte + 256 : byte)));
}
/**
* 构建 FreeOTP PBKDF2 hash 候选列表
* @param {string} algorithm - FreeOTP 备份中的 mAlgorithm
* @returns {Array<string>} Web Crypto 支持的 hash 名称候选
*/
function buildFreeOTPPbkdf2HashCandidates(algorithm) {
const candidates = [];
function addCandidate(hashName) {
if (hashName && !candidates.includes(hashName)) {
candidates.push(hashName);
}
}
const aliasMap = {
SHA1: 'SHA-1',
SHA224: 'SHA-224',
SHA256: 'SHA-256',
SHA384: 'SHA-384',
SHA512: 'SHA-512'
};
const normalized = String(algorithm || '')
.toUpperCase()
.replace(/[^A-Z0-9]/g, '');
let parsedHash = '';
const pbkdf2Prefix = 'PBKDF2WITHHMAC';
if (normalized.startsWith(pbkdf2Prefix)) {
parsedHash = normalized.slice(pbkdf2Prefix.length);
} else if (normalized.startsWith('HMAC')) {
parsedHash = normalized.slice(4);
} else if (normalized.startsWith('SHA')) {
parsedHash = normalized;
}
addCandidate(aliasMap[parsedHash] || '');
['SHA-512', 'SHA-256', 'SHA-1', 'SHA-384', 'SHA-224'].forEach(addCandidate);
return candidates;
}
/**
* 解析 FreeOTP 的 GCM 参数
* @param {Array<number>|Uint8Array} parameters - ASN.1 编码的 GCM 参数
* @returns {{iv: Uint8Array, tagLengthCandidates: Array<number>}} 解析结果
*/
function parseFreeOTPGcmParameters(parameters) {
const bytes = normalizeFreeOTPByteArray(parameters);
let iv = new Uint8Array();
let parsedTagLengthBits = null;
if (bytes.length >= 4 && bytes[0] === 0x30) {
let cursor = 2;
while (cursor < bytes.length) {
const tag = bytes[cursor++];
if (cursor >= bytes.length) {
break;
}
let length = bytes[cursor++];
if ((length & 0x80) !== 0) {
const lengthBytes = length & 0x7f;
if (cursor + lengthBytes > bytes.length) {
break;
}
length = 0;
for (let i = 0; i < lengthBytes; i++) {
length = (length << 8) | bytes[cursor++];
}
}
if (cursor + length > bytes.length) {
break;
}
const value = bytes.slice(cursor, cursor + length);
if (tag === 0x04 && value.length > 0 && iv.length === 0) {
iv = value;
} else if (tag === 0x02 && value.length > 0 && parsedTagLengthBits === null) {
let parsedValue = 0;
for (let i = 0; i < value.length; i++) {
parsedValue = (parsedValue << 8) | value[i];
}
if (parsedValue > 0) {
parsedTagLengthBits = parsedValue <= 16 ? parsedValue * 8 : parsedValue;
}
}
cursor += length;
}
}
if (iv.length === 0 && bytes.length >= 16) {
iv = bytes.slice(4, 16);
}
if (iv.length === 0 && bytes.length > 0) {
iv = bytes.slice(0, Math.min(12, bytes.length));
}
const tagLengthCandidates = [];
function addTagLength(bits) {
if (typeof bits !== 'number') {
return;
}
if (bits < 96 || bits > 128 || bits % 8 !== 0) {
return;
}
if (!tagLengthCandidates.includes(bits)) {
tagLengthCandidates.push(bits);
}
}
addTagLength(parsedTagLengthBits);
[128, 120, 112, 104, 96].forEach(addTagLength);
return {
iv: iv,
tagLengthCandidates: tagLengthCandidates
};
}
/**
* 归一化 AAD 候选列表
* @param {Array<Uint8Array|string|null|undefined>} aadCandidates - 候选列表
* @returns {Array<Uint8Array|null>} 去重后的候选列表
*/
function normalizeFreeOTPAadCandidates(aadCandidates) {
const normalizedCandidates = [];
const seen = new Set();
function addCandidate(candidate) {
if (candidate == null) {
if (!seen.has('__none__')) {
seen.add('__none__');
normalizedCandidates.push(null);
}
return;
}
let bytes = candidate;
if (typeof bytes === 'string') {
bytes = new TextEncoder().encode(bytes);
} else if (Array.isArray(bytes)) {
bytes = new Uint8Array(bytes);
}
if (!(bytes instanceof Uint8Array)) {
return;
}
const key = Array.from(bytes).join(',');
if (!seen.has(key)) {
seen.add(key);
normalizedCandidates.push(bytes);
}
}
(aadCandidates || []).forEach(addCandidate);
addCandidate(null);
return normalizedCandidates;
}
/**
* 使用多组 AAD 与 tag length 回退解密 FreeOTP GCM 数据
* @param {CryptoKey} key - AES-GCM 密钥
* @param {Array<number>|Uint8Array} cipherText - 密文
* @param {Array<number>|Uint8Array} parameters - ASN.1 GCM 参数
* @param {Array<Uint8Array|string|null|undefined>} aadCandidates - AAD 候选列表
* @returns {Promise<ArrayBuffer>} 解密后的 ArrayBuffer
*/
async function decryptFreeOTPGcmWithFallback(key, cipherText, parameters, aadCandidates) {
const normalizedCipherText = normalizeFreeOTPByteArray(cipherText);
const { iv, tagLengthCandidates } = parseFreeOTPGcmParameters(parameters);
const normalizedAadCandidates = normalizeFreeOTPAadCandidates(aadCandidates);
if (iv.length === 0) {
throw new Error('FreeOTP GCM 参数中缺少 IV');
}
let lastError = null;
for (const aad of normalizedAadCandidates) {
for (const tagLength of tagLengthCandidates) {
try {
const decryptParams = {
name: 'AES-GCM',
iv: iv,
tagLength: tagLength
};
if (aad) {
decryptParams.additionalData = aad;
}
return await crypto.subtle.decrypt(
decryptParams,
key,
normalizedCipherText
);
} catch (error) {
lastError = error;
}
}
}
throw lastError || new Error('FreeOTP GCM 解密失败');
}
/**
* 解密 FreeOTP 备份中的密钥
* @param {Object} backupData - parseFreeOTPBackup 返回的数据
* @param {string} password - 用户密码
* @returns {Promise<Array<string>>} otpauth:// URL 数组
*/
async function decryptFreeOTPBackup(backupData, password) {
if (!backupData || !backupData.masterKey) {
throw new Error('未找到 masterKey 数据');
}
const masterKeyData = backupData.masterKey;
const encryptedMasterKey = masterKeyData.mEncryptedKey;
if (!encryptedMasterKey) {
throw new Error('备份数据中缺少加密的 masterKey');
}
const salt = normalizeFreeOTPByteArray(masterKeyData.mSalt);
const iterations = masterKeyData.mIterations || 100000;
const passwordBytes = new TextEncoder().encode(password);
const passwordKey = await crypto.subtle.importKey(
'raw',
passwordBytes,
'PBKDF2',
false,
['deriveKey']
);
const masterKeyCipherText = normalizeFreeOTPByteArray(encryptedMasterKey.mCipherText);
const masterKeyParameters = normalizeFreeOTPByteArray(encryptedMasterKey.mParameters);
const hashCandidates = buildFreeOTPPbkdf2HashCandidates(masterKeyData.mAlgorithm);
let decryptedMasterKeyBuffer = null;
let lastMasterKeyError = null;
for (const hashName of hashCandidates) {
try {
const derivedKey = await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: salt,
iterations: iterations,
hash: hashName
},
passwordKey,
{ name: 'AES-GCM', length: 256 },
false,
['decrypt']
);
decryptedMasterKeyBuffer = await decryptFreeOTPGcmWithFallback(
derivedKey,
masterKeyCipherText,
masterKeyParameters,
[encryptedMasterKey.mToken, 'AES', null]
);
break;
} catch (error) {
lastMasterKeyError = error;
}
}
if (!decryptedMasterKeyBuffer) {
throw lastMasterKeyError || new Error('FreeOTP masterKey 解密失败');
}
const masterKey = await crypto.subtle.importKey(
'raw',
decryptedMasterKeyBuffer,
{ name: 'AES-GCM' },
false,
['decrypt']
);
const otpauthUrls = [];
for (const [uuid, encryptedToken] of Object.entries(backupData.tokens || {})) {
const meta = backupData.tokenMeta[uuid];
if (!meta) {
continue;
}
try {
const decryptedSecretBuffer = await decryptFreeOTPGcmWithFallback(
masterKey,
encryptedToken.mCipherText,
encryptedToken.mParameters,
[
encryptedToken.mToken,
meta.algo ? 'Hmac' + String(meta.algo).toUpperCase() : null,
'HmacSHA1',
null
]
);
const secretBytes = new Uint8Array(decryptedSecretBuffer);
const secret = bytesToBase32(Array.from(secretBytes));
const issuer = meta.issuerExt || meta.issuerInt || '';
const account = meta.label || '';
const type = (meta.type || 'TOTP').toLowerCase();
let label = '';
if (issuer && account) {
label = encodeURIComponent(issuer) + ':' + encodeURIComponent(account);
} else if (issuer) {
label = encodeURIComponent(issuer);
} else if (account) {
label = encodeURIComponent(account);
} else {
label = 'Unknown';
}
const params = new URLSearchParams();
params.set('secret', secret);
if (issuer) params.set('issuer', issuer);
if (meta.digits && meta.digits !== 6) params.set('digits', meta.digits);
if (meta.period && meta.period !== 30) params.set('period', meta.period);
if (meta.algo && meta.algo !== 'SHA1') params.set('algorithm', meta.algo);
if (type === 'hotp' && meta.counter != null) params.set('counter', meta.counter);
const protocol = type === 'hotp' ? 'hotp' : 'totp';
otpauthUrls.push('otpauth://' + protocol + '/' + label + '?' + params.toString());
} catch (error) {
console.warn('FreeOTP token 解密失败,已跳过:', uuid, error);
}
}
return otpauthUrls;
}
/**
* 解密并预览 FreeOTP 备份
*/
@@ -289,6 +700,11 @@ export function getFreeOTPDecryptCode() {
return;
}
if (!freeotpBackupData) {
showCenterToast('❌', '未找到 FreeOTP 备份数据');
return;
}
try {
showCenterToast('⏳', '正在解密...');
@@ -299,19 +715,15 @@ export function getFreeOTPDecryptCode() {
return;
}
// 将解密后的 URL 填入输入框并触发预览
document.getElementById('importText').value = otpauthUrls.join('\\n');
// 隐藏密码输入区
document.getElementById('freeotpPasswordSection').style.display = 'none';
// 触发预览
document.getElementById('importText').value = otpauthUrls.join('\n');
previewImport();
showCenterToast('✅', '解密成功,共 ' + otpauthUrls.length + ' 条');
} catch (error) {
console.error('FreeOTP 解密失败:', error);
if (error.message && error.message.includes('decrypt')) {
if (error.name === 'OperationError') {
showCenterToast('❌', '解密失败:密码错误或备份格式不兼容');
} else if (error.message && error.message.includes('decrypt')) {
showCenterToast('❌', '解密失败:密码错误');
} else {
showCenterToast('❌', '解密失败:' + (error.message || '未知错误'));

View File

@@ -134,7 +134,7 @@ export function getImportUICode() {
const reader = new FileReader();
reader.onload = function(e) {
const content = e.target.result;
const content = decodeImportFileContent(file.name, e.target.result);
document.getElementById('importText').value = content;
// 自动预览
@@ -145,7 +145,7 @@ export function getImportUICode() {
reader.onerror = function() {
showCenterToast('❌', '读取文件失败');
};
reader.readAsText(file);
reader.readAsArrayBuffer(file);
}
// 更新导入统计信息(新的内联统计)

View File

@@ -8,7 +8,7 @@
* @returns {string} JavaScript 代码
*/
export function getImportUtilsCode() {
return `
return String.raw`
// ========== 导入工具函数 ==========
/**
@@ -98,5 +98,61 @@ export function getImportUtilsCode() {
return fields;
}
/**
* 将 Uint8Array 转成二进制字符串
* 保留 Java 序列化等二进制格式中的原始字节值
* @param {Uint8Array} bytes - 原始字节数组
* @returns {string} 二进制字符串
*/
function bytesToBinaryString(bytes) {
let result = '';
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.slice(i, i + chunkSize);
result += String.fromCharCode.apply(null, Array.from(chunk));
}
return result;
}
/**
* 解码导入文件内容
* 兼容普通 UTF-8/UTF-16 文本以及 FreeOTP 的 Java 序列化二进制备份
* @param {string} fileName - 文件名
* @param {ArrayBuffer|Uint8Array} arrayBuffer - 文件二进制内容
* @returns {string} 解码后的文本内容
*/
function decodeImportFileContent(fileName, arrayBuffer) {
const bytes = arrayBuffer instanceof Uint8Array ? arrayBuffer : new Uint8Array(arrayBuffer);
const lowerFileName = String(fileName || '').toLowerCase();
if (bytes.length >= 2) {
if (bytes[0] === 0xff && bytes[1] === 0xfe) {
return new TextDecoder('utf-16le').decode(bytes);
}
if (bytes[0] === 0xfe && bytes[1] === 0xff) {
return new TextDecoder('utf-16be').decode(bytes);
}
// Java Object Serialization Stream Magic: 0xACED
if (bytes[0] === 0xac && bytes[1] === 0xed) {
return bytesToBinaryString(bytes);
}
}
const utf8Text = new TextDecoder('utf-8').decode(bytes);
if (!utf8Text.includes('\uFFFD')) {
return utf8Text;
}
if (lowerFileName.endsWith('.xml') || lowerFileName.endsWith('.authpro') || lowerFileName.endsWith('.encrypt')) {
return bytesToBinaryString(bytes);
}
return utf8Text;
}
`;
}

View File

@@ -3,10 +3,10 @@
* 实现按需加载大型功能模块,优化首次加载性能
*
* 可延迟加载的模块:
* - import.js (34KB/896行) - 导入功能
* - export.js (16KB/375行) - 导出功能
* - backup.js (355行) - 备份管理
* - qrcode.js (29KB/786行) - 二维码生成
* - import 模块src/ui/scripts/import/index.js 入口)- 导入功能
* - export.js - 导出功能
* - backup.js - 备份管理
* - qrcode.js - 二维码生成
* - tools.js + 工具模块 - 工具集
*/