Files
nofx/web/src/lib/crypto.ts
0xYYBB | ZYY | Bobo d4f1e6a4d4 fix(web): resolve TypeScript type error in crypto.ts and add missing test dependencies (#647)
```
src/lib/crypto.ts(66,32): error TS2345: Argument of type 'Uint8Array<ArrayBuffer>'
is not assignable to parameter of type 'ArrayBuffer'.
```

`arrayBufferToBase64` function expected `ArrayBuffer` but received `Uint8Array.buffer`.
TypeScript strict type checking flagged the mismatch.

1. Update `arrayBufferToBase64` signature to accept `ArrayBuffer | Uint8Array`
2. Pass `result` directly instead of `result.buffer` (more accurate)
3. Add runtime type check with instanceof

```
error TS2307: Cannot find module 'vitest'
error TS2307: Cannot find module '@testing-library/react'
```

Install missing devDependencies:
- vitest
- @testing-library/react
- @testing-library/jest-dom

 Frontend builds successfully
 TypeScript compilation passes
 No type errors

Related-To: Docker frontend build failures
2025-11-07 01:19:48 +08:00

315 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 端到端加密模組
* 使用混合加密: RSA-OAEP (密鑰交換) + AES-256-GCM (數據加密)
*/
// ==================== 核心加密函數 ====================
/**
* 生成隨機混淆字串 (用於剪貼簿混淆)
*/
export function generateObfuscation(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
}
/**
* 使用伺服器公鑰加密私鑰
* @param plaintext 明文私鑰
* @param serverPublicKeyPEM 伺服器 RSA 公鑰 (PEM 格式)
* @returns Base64 編碼的加密數據
*/
export async function encryptWithServerPublicKey(
plaintext: string,
serverPublicKeyPEM: string
): Promise<string> {
try {
// 1. 導入伺服器公鑰
const publicKey = await importRSAPublicKey(serverPublicKeyPEM);
// 2. 生成隨機 AES 密鑰 (256-bit)
const aesKey = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true,
['encrypt']
);
// 3. 使用 AES-GCM 加密數據
const iv = crypto.getRandomValues(new Uint8Array(12)); // 96-bit nonce
const encodedText = new TextEncoder().encode(plaintext);
const encryptedData = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
aesKey,
encodedText
);
// 4. 導出 AES 密鑰並用 RSA 加密
const exportedAESKey = await crypto.subtle.exportKey('raw', aesKey);
const encryptedAESKey = await crypto.subtle.encrypt(
{ name: 'RSA-OAEP' },
publicKey,
exportedAESKey
);
// 5. 組合: [加密的 AES 密鑰長度(4字節)] + [加密的 AES 密鑰] + [IV] + [加密數據]
const result = new Uint8Array(
4 + encryptedAESKey.byteLength + iv.length + encryptedData.byteLength
);
const view = new DataView(result.buffer);
view.setUint32(0, encryptedAESKey.byteLength, false); // 大端序
result.set(new Uint8Array(encryptedAESKey), 4);
result.set(iv, 4 + encryptedAESKey.byteLength);
result.set(new Uint8Array(encryptedData), 4 + encryptedAESKey.byteLength + iv.length);
// 6. Base64 編碼
return arrayBufferToBase64(result);
} catch (error) {
console.error('加密失敗:', error);
throw new Error('加密過程中發生錯誤,請檢查伺服器公鑰是否有效');
}
}
/**
* 導入 PEM 格式的 RSA 公鑰
*/
async function importRSAPublicKey(pem: string): Promise<CryptoKey> {
// 移除 PEM header/footer 和換行符
const pemContents = pem
.replace(/-----BEGIN PUBLIC KEY-----/, '')
.replace(/-----END PUBLIC KEY-----/, '')
.replace(/\s/g, '');
// Base64 解碼
const binaryDer = base64ToArrayBuffer(pemContents);
// 導入為 CryptoKey
return crypto.subtle.importKey(
'spki',
binaryDer,
{
name: 'RSA-OAEP',
hash: 'SHA-256',
},
true,
['encrypt']
);
}
// ==================== 二階段輸入 UI ====================
export interface TwoStageInputResult {
encryptedKey: string;
obfuscationLog: string[]; // 混淆記錄(用於審計)
}
/**
* 二階段私鑰輸入流程
* @param serverPublicKey 伺服器公鑰
* @returns 加密後的私鑰 + 混淆記錄
*/
export async function twoStagePrivateKeyInput(
serverPublicKey: string
): Promise<TwoStageInputResult> {
const obfuscationLog: string[] = [];
return new Promise((resolve, reject) => {
// 創建自定義 Modal
const modal = createTwoStageModal(async (part1: string, part2: string) => {
try {
const fullKey = part1 + part2;
// 驗證私鑰格式
if (!validatePrivateKeyFormat(fullKey)) {
throw new Error('私鑰格式不正確(應為 64 位十六進制或 0x 開頭)');
}
// 加密
const encrypted = await encryptWithServerPublicKey(fullKey, serverPublicKey);
// 清除敏感數據
part1 = '';
part2 = '';
resolve({ encryptedKey: encrypted, obfuscationLog });
} catch (error) {
reject(error);
}
}, obfuscationLog);
document.body.appendChild(modal);
});
}
/**
* 創建二階段輸入 Modal
*/
function createTwoStageModal(
onSubmit: (part1: string, part2: string) => void,
obfuscationLog: string[]
): HTMLElement {
const modal = document.createElement('div');
modal.style.cssText = `
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.8); z-index: 10000;
display: flex; align-items: center; justify-content: center;
`;
const content = document.createElement('div');
content.style.cssText = `
background: #1a1a2e; padding: 2rem; border-radius: 8px;
max-width: 500px; width: 90%; color: white;
`;
let stage = 1;
let part1 = '';
const render = () => {
if (stage === 1) {
content.innerHTML = `
<h2 style="margin-bottom: 1rem;">🔐 安全輸入 - 第一階段</h2>
<p style="margin-bottom: 1rem; color: #888;">請輸入私鑰的<strong>前 32 位</strong>字符</p>
<input
id="stage1-input"
type="password"
placeholder="0x1234..."
style="width: 100%; padding: 0.75rem; border-radius: 4px;
background: #0f0f1e; border: 1px solid #333; color: white;
font-family: monospace; font-size: 14px;"
maxlength="34"
/>
<button
id="stage1-next"
style="margin-top: 1rem; width: 100%; padding: 0.75rem;
background: #4CAF50; border: none; border-radius: 4px;
color: white; font-weight: bold; cursor: pointer;"
>下一步 →</button>
<button
id="cancel"
style="margin-top: 0.5rem; width: 100%; padding: 0.5rem;
background: transparent; border: 1px solid #555; border-radius: 4px;
color: #888; cursor: pointer;"
>取消</button>
`;
const input = content.querySelector('#stage1-input') as HTMLInputElement;
const nextBtn = content.querySelector('#stage1-next') as HTMLButtonElement;
const cancelBtn = content.querySelector('#cancel') as HTMLButtonElement;
input.focus();
input.addEventListener('input', () => {
nextBtn.disabled = input.value.length < 10;
});
nextBtn.addEventListener('click', async () => {
part1 = input.value;
input.value = ''; // 立即清除
// 生成混淆字串並強制複製
const obfuscation = generateObfuscation();
await navigator.clipboard.writeText(obfuscation);
obfuscationLog.push(`Stage1: ${new Date().toISOString()}`);
alert('⚠️ 已複製混淆字串到剪貼簿\n\n請在任意地方貼上一次避免監控然後點擊確定繼續');
stage = 2;
render();
});
cancelBtn.addEventListener('click', () => {
modal.remove();
});
} else if (stage === 2) {
content.innerHTML = `
<h2 style="margin-bottom: 1rem;">🔐 安全輸入 - 第二階段</h2>
<p style="margin-bottom: 1rem; color: #888;">請輸入私鑰的<strong>剩餘字符</strong></p>
<input
id="stage2-input"
type="password"
placeholder="...5678"
style="width: 100%; padding: 0.75rem; border-radius: 4px;
background: #0f0f1e; border: 1px solid #333; color: white;
font-family: monospace; font-size: 14px;"
maxlength="34"
/>
<button
id="stage2-submit"
style="margin-top: 1rem; width: 100%; padding: 0.75rem;
background: #2196F3; border: none; border-radius: 4px;
color: white; font-weight: bold; cursor: pointer;"
>🔒 加密並提交</button>
<button
id="back"
style="margin-top: 0.5rem; width: 100%; padding: 0.5rem;
background: transparent; border: 1px solid #555; border-radius: 4px;
color: #888; cursor: pointer;"
>← 返回上一步</button>
`;
const input = content.querySelector('#stage2-input') as HTMLInputElement;
const submitBtn = content.querySelector('#stage2-submit') as HTMLButtonElement;
const backBtn = content.querySelector('#back') as HTMLButtonElement;
input.focus();
submitBtn.addEventListener('click', async () => {
const part2 = input.value;
input.value = ''; // 立即清除
obfuscationLog.push(`Stage2: ${new Date().toISOString()}`);
modal.remove();
onSubmit(part1, part2);
});
backBtn.addEventListener('click', () => {
stage = 1;
render();
});
}
};
render();
modal.appendChild(content);
return modal;
}
/**
* 驗證私鑰格式
*/
function validatePrivateKeyFormat(key: string): boolean {
// EVM 私鑰: 64 位十六進制 (可選 0x 前綴)
const evmPattern = /^(0x)?[0-9a-fA-F]{64}$/;
return evmPattern.test(key);
}
// ==================== 工具函數 ====================
function arrayBufferToBase64(buffer: ArrayBuffer | Uint8Array): string {
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
/**
* 從伺服器獲取公鑰
*/
export async function fetchServerPublicKey(): Promise<string> {
const response = await fetch('/api/crypto/public-key');
if (!response.ok) {
throw new Error('無法獲取伺服器公鑰');
}
const data = await response.json();
return data.public_key;
}