修改Web Crypto API 在 HTTP 环境下不可用的问题,自动降级

- HTTPS/localhost :正常启用加密
- HTTP(非 localhost) :自动禁用加密,以明文方式通信
This commit is contained in:
2026-06-26 14:56:03 +08:00
parent 157e7e5bba
commit e21ba57597
8 changed files with 202 additions and 166 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
} }
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-BLuHlLoF.js"></script> <script type="module" crossorigin src="/assets/index-CVmZGX2n.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css"> <link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
</head> </head>
<body> <body>
+2 -2
View File
@@ -3,10 +3,10 @@
* Handles auth tokens, request/response encryption, snake_case→camelCase conversion. * Handles auth tokens, request/response encryption, snake_case→camelCase conversion.
*/ */
import { encrypt, decrypt } from './crypto'; import { encrypt, decrypt, isCryptoAvailable } from './crypto';
const BASE_URL = import.meta.env.VITE_API_BASE || 'http://localhost:8000'; const BASE_URL = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const USE_ENCRYPTION = !!import.meta.env.VITE_ENCRYPTION_KEY; const USE_ENCRYPTION = !!import.meta.env.VITE_ENCRYPTION_KEY && isCryptoAvailable();
interface RequestOptions { interface RequestOptions {
method?: string; method?: string;
+19 -1
View File
@@ -1,6 +1,7 @@
/** /**
* AES-256-GCM encryption/decryption for API request/response. * AES-256-GCM encryption/decryption for API request/response.
* Uses Web Crypto API with a shared symmetric key. * Uses Web Crypto API with a shared symmetric key.
* Note: Web Crypto API is only available in secure contexts (HTTPS or localhost).
*/ */
const ALGO = 'AES-GCM'; const ALGO = 'AES-GCM';
@@ -9,10 +10,21 @@ const TAG_LENGTH = 128;
let cryptoKey: CryptoKey | null = null; let cryptoKey: CryptoKey | null = null;
export function isCryptoAvailable(): boolean {
return typeof window !== 'undefined' &&
typeof crypto !== 'undefined' &&
typeof crypto.subtle !== 'undefined';
}
async function getCryptoKey(): Promise<CryptoKey> { async function getCryptoKey(): Promise<CryptoKey> {
if (cryptoKey) return cryptoKey; if (cryptoKey) return cryptoKey;
const keyB64 = import.meta.env.VITE_ENCRYPTION_KEY || ''; const keyB64 = import.meta.env.VITE_ENCRYPTION_KEY || '';
if (!keyB64) throw new Error('VITE_ENCRYPTION_KEY not configured'); if (!keyB64) throw new Error('VITE_ENCRYPTION_KEY not configured');
if (!isCryptoAvailable()) {
throw new Error('Web Crypto API not available (requires HTTPS or localhost)');
}
let keyBytes = Uint8Array.from(atob(keyB64), c => c.charCodeAt(0)); let keyBytes = Uint8Array.from(atob(keyB64), c => c.charCodeAt(0));
// AES-256 requires exactly 32 bytes — pad or truncate to match backend // AES-256 requires exactly 32 bytes — pad or truncate to match backend
if (keyBytes.length !== 32) { if (keyBytes.length !== 32) {
@@ -25,6 +37,9 @@ async function getCryptoKey(): Promise<CryptoKey> {
} }
export async function encrypt(plaintext: string): Promise<string> { export async function encrypt(plaintext: string): Promise<string> {
if (!isCryptoAvailable()) {
throw new Error('Encryption not available in non-secure context');
}
const key = await getCryptoKey(); const key = await getCryptoKey();
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
const encoded = new TextEncoder().encode(plaintext); const encoded = new TextEncoder().encode(plaintext);
@@ -38,10 +53,13 @@ export async function encrypt(plaintext: string): Promise<string> {
} }
export async function decrypt(cipherB64: string): Promise<string> { export async function decrypt(cipherB64: string): Promise<string> {
if (!isCryptoAvailable()) {
throw new Error('Decryption not available in non-secure context');
}
const key = await getCryptoKey(); const key = await getCryptoKey();
const combined = Uint8Array.from(atob(cipherB64), c => c.charCodeAt(0)); const combined = Uint8Array.from(atob(cipherB64), c => c.charCodeAt(0));
const iv = combined.slice(0, IV_LENGTH); const iv = combined.slice(0, IV_LENGTH);
const cipherBytes = combined.slice(IV_LENGTH); const cipherBytes = combined.slice(IV_LENGTH);
const plainBuf = await crypto.subtle.decrypt({ name: ALGO, iv, tagLength: TAG_LENGTH }, key, cipherBytes); const plainBuf = await crypto.subtle.decrypt({ name: ALGO, iv, tagLength: TAG_LENGTH }, key, cipherBytes);
return new TextDecoder().decode(plainBuf); return new TextDecoder().decode(plainBuf);
} }
File diff suppressed because one or more lines are too long
+36 -36
View File
@@ -1,37 +1,37 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>民众智创</title> <title>民众智创</title>
<script> <script>
(function() { (function() {
var cached = localStorage.getItem('siteInfo'); var cached = localStorage.getItem('siteInfo');
if (cached) { if (cached) {
try { try {
var info = JSON.parse(cached); var info = JSON.parse(cached);
if (info.siteName) { if (info.siteName) {
document.title = info.siteName; document.title = info.siteName;
} }
if (info.siteLogo) { if (info.siteLogo) {
var link = document.querySelector('link[rel="icon"]'); var link = document.querySelector('link[rel="icon"]');
if (link) { if (link) {
link.href = info.siteLogo; link.href = info.siteLogo;
link.type = 'image/png'; link.type = 'image/png';
} }
} }
} catch (e) {} } catch (e) {}
} }
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-b7j6q-om.js"></script> <script type="module" crossorigin src="/assets/index-a4Sy3akl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-eLRg4pQk.css"> <link rel="stylesheet" crossorigin href="/assets/index-eLRg4pQk.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,10 +3,10 @@
* Handles auth tokens, request/response encryption, snake_case→camelCase conversion. * Handles auth tokens, request/response encryption, snake_case→camelCase conversion.
*/ */
import { encrypt, decrypt } from './crypto'; import { encrypt, decrypt, isCryptoAvailable } from './crypto';
const BASE_URL = import.meta.env.VITE_API_BASE || 'http://localhost:8000'; const BASE_URL = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const USE_ENCRYPTION = !!import.meta.env.VITE_ENCRYPTION_KEY; const USE_ENCRYPTION = !!import.meta.env.VITE_ENCRYPTION_KEY && isCryptoAvailable();
interface RequestOptions { interface RequestOptions {
method?: string; method?: string;
+19 -1
View File
@@ -1,6 +1,7 @@
/** /**
* AES-256-GCM encryption/decryption for API request/response. * AES-256-GCM encryption/decryption for API request/response.
* Uses Web Crypto API with a shared symmetric key. * Uses Web Crypto API with a shared symmetric key.
* Note: Web Crypto API is only available in secure contexts (HTTPS or localhost).
*/ */
const ALGO = 'AES-GCM'; const ALGO = 'AES-GCM';
@@ -9,10 +10,21 @@ const TAG_LENGTH = 128;
let cryptoKey: CryptoKey | null = null; let cryptoKey: CryptoKey | null = null;
export function isCryptoAvailable(): boolean {
return typeof window !== 'undefined' &&
typeof crypto !== 'undefined' &&
typeof crypto.subtle !== 'undefined';
}
async function getCryptoKey(): Promise<CryptoKey> { async function getCryptoKey(): Promise<CryptoKey> {
if (cryptoKey) return cryptoKey; if (cryptoKey) return cryptoKey;
const keyB64 = import.meta.env.VITE_ENCRYPTION_KEY || ''; const keyB64 = import.meta.env.VITE_ENCRYPTION_KEY || '';
if (!keyB64) throw new Error('VITE_ENCRYPTION_KEY not configured'); if (!keyB64) throw new Error('VITE_ENCRYPTION_KEY not configured');
if (!isCryptoAvailable()) {
throw new Error('Web Crypto API not available (requires HTTPS or localhost)');
}
let keyBytes = Uint8Array.from(atob(keyB64), c => c.charCodeAt(0)); let keyBytes = Uint8Array.from(atob(keyB64), c => c.charCodeAt(0));
// AES-256 requires exactly 32 bytes — pad or truncate to match backend // AES-256 requires exactly 32 bytes — pad or truncate to match backend
if (keyBytes.length !== 32) { if (keyBytes.length !== 32) {
@@ -25,6 +37,9 @@ async function getCryptoKey(): Promise<CryptoKey> {
} }
export async function encrypt(plaintext: string): Promise<string> { export async function encrypt(plaintext: string): Promise<string> {
if (!isCryptoAvailable()) {
throw new Error('Encryption not available in non-secure context');
}
const key = await getCryptoKey(); const key = await getCryptoKey();
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
const encoded = new TextEncoder().encode(plaintext); const encoded = new TextEncoder().encode(plaintext);
@@ -38,10 +53,13 @@ export async function encrypt(plaintext: string): Promise<string> {
} }
export async function decrypt(cipherB64: string): Promise<string> { export async function decrypt(cipherB64: string): Promise<string> {
if (!isCryptoAvailable()) {
throw new Error('Decryption not available in non-secure context');
}
const key = await getCryptoKey(); const key = await getCryptoKey();
const combined = Uint8Array.from(atob(cipherB64), c => c.charCodeAt(0)); const combined = Uint8Array.from(atob(cipherB64), c => c.charCodeAt(0));
const iv = combined.slice(0, IV_LENGTH); const iv = combined.slice(0, IV_LENGTH);
const cipherBytes = combined.slice(IV_LENGTH); const cipherBytes = combined.slice(IV_LENGTH);
const plainBuf = await crypto.subtle.decrypt({ name: ALGO, iv, tagLength: TAG_LENGTH }, key, cipherBytes); const plainBuf = await crypto.subtle.decrypt({ name: ALGO, iv, tagLength: TAG_LENGTH }, key, cipherBytes);
return new TextDecoder().decode(plainBuf); return new TextDecoder().decode(plainBuf);
} }