188 lines
5.5 KiB
JavaScript
188 lines
5.5 KiB
JavaScript
/**
|
||
* 与后端 EncryptUtil.aesEncryptToBase64(AES-GCM,12 字节 IV,16 字节 tag)一致。
|
||
* App 端 WebView 往往无 crypto.subtle,需用纯 JS 解密。
|
||
*/
|
||
import { gcm } from '@noble/ciphers/aes.js';
|
||
import { sha256 } from '@noble/hashes/sha2.js';
|
||
|
||
// Polyfill for TextEncoder/TextDecoder in Weex environment
|
||
class TextEncoderPolyfill {
|
||
encode(text) {
|
||
const arr = new Uint8Array(text.length);
|
||
for (let i = 0; i < text.length; i++) {
|
||
arr[i] = text.charCodeAt(i) & 0xff;
|
||
}
|
||
return arr;
|
||
}
|
||
}
|
||
|
||
class TextDecoderPolyfill {
|
||
decode(uint8Array) {
|
||
let str = '';
|
||
let i = 0;
|
||
while (i < uint8Array.length) {
|
||
const byte1 = uint8Array[i];
|
||
if (byte1 < 0x80) {
|
||
// 单字节 ASCII
|
||
str += String.fromCharCode(byte1);
|
||
i++;
|
||
} else if ((byte1 & 0xe0) === 0xc0) {
|
||
// 双字节 UTF-8
|
||
const byte2 = uint8Array[i + 1];
|
||
const code = ((byte1 & 0x1f) << 6) | (byte2 & 0x3f);
|
||
str += String.fromCharCode(code);
|
||
i += 2;
|
||
} else if ((byte1 & 0xf0) === 0xe0) {
|
||
// 三字节 UTF-8
|
||
const byte2 = uint8Array[i + 1];
|
||
const byte3 = uint8Array[i + 2];
|
||
const code = ((byte1 & 0x0f) << 12) | ((byte2 & 0x3f) << 6) | (byte3 & 0x3f);
|
||
str += String.fromCharCode(code);
|
||
i += 3;
|
||
} else if ((byte1 & 0xf8) === 0xf0) {
|
||
// 四字节 UTF-8(需要代理对)
|
||
const byte2 = uint8Array[i + 1];
|
||
const byte3 = uint8Array[i + 2];
|
||
const byte4 = uint8Array[i + 3];
|
||
let code = ((byte1 & 0x07) << 18) | ((byte2 & 0x3f) << 12) | ((byte3 & 0x3f) << 6) | (byte4 & 0x3f);
|
||
// 转换为 UTF-16 代理对
|
||
code -= 0x10000;
|
||
const high = 0xd800 + (code >> 10);
|
||
const low = 0xdc00 + (code & 0x3ff);
|
||
str += String.fromCharCode(high, low);
|
||
i += 4;
|
||
} else {
|
||
// 非法字节,跳过
|
||
str += String.fromCharCode(byte1);
|
||
i++;
|
||
}
|
||
}
|
||
return str;
|
||
}
|
||
}
|
||
|
||
const TextEncoder = typeof globalThis !== 'undefined' && globalThis.TextEncoder ? globalThis.TextEncoder : TextEncoderPolyfill;
|
||
const TextDecoder = typeof globalThis !== 'undefined' && globalThis.TextDecoder ? globalThis.TextDecoder : TextDecoderPolyfill;
|
||
|
||
/** 与后端 EncryptUtil 一致:去掉 BOM/空白,避免与 SHA-256(UTF-8) 密钥不一致 */
|
||
function normalizeSecret(secret) {
|
||
if (secret == null || typeof secret !== 'string') {
|
||
return secret;
|
||
}
|
||
let s = secret.trim();
|
||
if (s.length > 0 && s.charCodeAt(0) === 0xfeff) {
|
||
s = s.slice(1);
|
||
}
|
||
return s;
|
||
}
|
||
|
||
/**
|
||
* x-www-form-urlencoded /部分网关会把 Base64 里的 "+" 变成空格;JSON 一般不受影响。
|
||
*/
|
||
function normalizeBase64Segment(seg) {
|
||
if (seg == null || seg === '') {
|
||
return seg;
|
||
}
|
||
let s = String(seg).trim().replace(/\s/g, '+');
|
||
s = s.replace(/-/g, '+').replace(/_/g, '/');
|
||
const pad = s.length % 4;
|
||
if (pad !== 0) {
|
||
s += '='.repeat(4 - pad);
|
||
}
|
||
return s;
|
||
}
|
||
|
||
function base64ToUint8Array(b64) {
|
||
const normalized = normalizeBase64Segment(b64);
|
||
if (typeof atob === 'function') {
|
||
const bin = atob(normalized);
|
||
const out = new Uint8Array(bin.length);
|
||
for (let i = 0; i < bin.length; i++) {
|
||
out[i] = bin.charCodeAt(i) & 0xff;
|
||
}
|
||
return out;
|
||
}
|
||
if (typeof uni !== 'undefined' && uni.base64ToArrayBuffer) {
|
||
return new Uint8Array(uni.base64ToArrayBuffer(normalized));
|
||
}
|
||
throw new Error('cfgSettingCrypto: no base64 decoder');
|
||
}
|
||
|
||
function splitIvAndCipher(encryptedText) {
|
||
const idx = encryptedText.indexOf(':');
|
||
if (idx <= 0 || idx === encryptedText.length - 1) {
|
||
return null;
|
||
}
|
||
return {
|
||
ivB64: encryptedText.slice(0, idx),
|
||
cipherB64: encryptedText.slice(idx + 1),
|
||
};
|
||
}
|
||
|
||
async function decryptWithSubtle(encryptedText, secret) {
|
||
const parts = splitIvAndCipher(encryptedText);
|
||
if (!parts) {
|
||
throw new Error('cfgSettingCrypto: invalid iv:cipher format');
|
||
}
|
||
const ivBytes = base64ToUint8Array(parts.ivB64);
|
||
const cipherBytes = base64ToUint8Array(parts.cipherB64);
|
||
const keyRaw = new TextEncoder().encode(normalizeSecret(secret));
|
||
const keyHash = await globalThis.crypto.subtle.digest('SHA-256', keyRaw);
|
||
const aesKey = await globalThis.crypto.subtle.importKey(
|
||
'raw',
|
||
keyHash,
|
||
{ name: 'AES-GCM' },
|
||
false,
|
||
['decrypt']
|
||
);
|
||
const decrypted = await globalThis.crypto.subtle.decrypt(
|
||
{ name: 'AES-GCM', iv: ivBytes, tagLength: 128 },
|
||
aesKey,
|
||
cipherBytes
|
||
);
|
||
return new TextDecoder().decode(decrypted);
|
||
}
|
||
|
||
function decryptWithNoble(encryptedText, secret) {
|
||
const parts = splitIvAndCipher(encryptedText);
|
||
if (!parts) {
|
||
throw new Error('cfgSettingCrypto: invalid iv:cipher format');
|
||
}
|
||
const ivBytes = base64ToUint8Array(parts.ivB64);
|
||
const cipherBytes = base64ToUint8Array(parts.cipherB64);
|
||
const keyBytes = sha256(new TextEncoder().encode(normalizeSecret(secret)));
|
||
const decipher = gcm(keyBytes, ivBytes);
|
||
const plainBytes = decipher.decrypt(cipherBytes);
|
||
return new TextDecoder().decode(plainBytes);
|
||
}
|
||
|
||
/**
|
||
* @param {string} encryptedText 格式 base64(iv):base64(ciphertext||tag)
|
||
* @param {string} secret 与后端 allow_key 一致
|
||
* @returns {Promise<string>}
|
||
*/
|
||
export async function decryptCfgSettingValue(encryptedText, secret) {
|
||
if (!encryptedText || !secret || encryptedText.indexOf(':') === -1) {
|
||
return encryptedText;
|
||
}
|
||
if (!splitIvAndCipher(encryptedText)) {
|
||
return encryptedText;
|
||
}
|
||
try {
|
||
if (globalThis.crypto && globalThis.crypto.subtle) {
|
||
const plain = await decryptWithSubtle(encryptedText, secret);
|
||
if (plain) {
|
||
return plain;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('[cfgSettingCrypto] WebCrypto 解密失败,改用 @noble/ciphers', e);
|
||
}
|
||
try {
|
||
return decryptWithNoble(encryptedText, secret);
|
||
} catch (e) {
|
||
console.error('[cfgSettingCrypto]解密失败', e);
|
||
return encryptedText;
|
||
}
|
||
}
|