mirror of
https://github.com/wuzf/2fa.git
synced 2026-09-03 07:17:09 +08:00
fix(import): remove legacy import bundle and restore decrypt flow
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -259,6 +259,7 @@ export function getPreviewImportCode() {
|
||||
|
||||
updateImportStats(validCount, invalidCount, skippedCount);
|
||||
previewDiv.style.display = 'block';
|
||||
executeBtn.textContent = '📥 导入';
|
||||
executeBtn.disabled = validCount === 0;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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 || '未知错误'));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// 更新导入统计信息(新的内联统计)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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 + 工具模块 - 工具集
|
||||
*/
|
||||
|
||||
|
||||
Reference in New Issue
Block a user