From dbb05f7fde01985521db9a9854361cd278efefcb Mon Sep 17 00:00:00 2001 From: Ember <15190419+0xEmberZz@users.noreply.github.com> Date: Wed, 12 Nov 2025 10:22:55 +0800 Subject: [PATCH] feat(ui): Add an automated Web Crypto environment check (#908) * feat: add web crypto environment check * fix: auto check env * refactor: WebCryptoEnvironmentCheck swtich to map --- web/src/components/AITradersPage.tsx | 77 ++++++---- web/src/components/TwoStageKeyModal.tsx | 5 + .../components/WebCryptoEnvironmentCheck.tsx | 138 ++++++++++++++++++ web/src/i18n/translations.ts | 57 ++++++++ web/src/lib/crypto.ts | 47 ++++++ 5 files changed, 297 insertions(+), 27 deletions(-) create mode 100644 web/src/components/WebCryptoEnvironmentCheck.tsx diff --git a/web/src/components/AITradersPage.tsx b/web/src/components/AITradersPage.tsx index cf1e1db1..5e066661 100644 --- a/web/src/components/AITradersPage.tsx +++ b/web/src/components/AITradersPage.tsx @@ -18,6 +18,10 @@ import { TwoStageKeyModal, type TwoStageKeyModalResult, } from './TwoStageKeyModal' +import { + WebCryptoEnvironmentCheck, + type WebCryptoCheckStatus, +} from './WebCryptoEnvironmentCheck' import { Bot, Brain, @@ -1772,6 +1776,8 @@ function ExchangeConfigModal({ } | null>(null) const [loadingIP, setLoadingIP] = useState(false) const [copiedIP, setCopiedIP] = useState(false) + const [webCryptoStatus, setWebCryptoStatus] = + useState('idle') // 币安配置指南展开状态 const [showBinanceGuide, setShowBinanceGuide] = useState(false) @@ -2008,34 +2014,51 @@ function ExchangeConfigModal({ style={{ maxHeight: 'calc(100vh - 16rem)' }} > {!editingExchangeId && ( -
- - setSelectedExchangeId(e.target.value)} + className="w-full px-3 py-2 rounded" + style={{ + background: '#0B0E11', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + aria-label={t('selectExchange', language)} + disabled={webCryptoStatus !== 'secure'} + required + > + - ))} - + {availableExchanges.map((exchange) => ( + + ))} + +
)} diff --git a/web/src/components/TwoStageKeyModal.tsx b/web/src/components/TwoStageKeyModal.tsx index 5de79886..0e261fb4 100644 --- a/web/src/components/TwoStageKeyModal.tsx +++ b/web/src/components/TwoStageKeyModal.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { t, type Language } from '../i18n/translations' import { toast } from 'sonner' +import { WebCryptoEnvironmentCheck } from './WebCryptoEnvironmentCheck' const DEFAULT_LENGTH = 64 @@ -197,6 +198,10 @@ export function TwoStageKeyModal({

+
+ +
+ {/* Stage 1 */} {stage === 1 && (
diff --git a/web/src/components/WebCryptoEnvironmentCheck.tsx b/web/src/components/WebCryptoEnvironmentCheck.tsx new file mode 100644 index 00000000..acef7b1b --- /dev/null +++ b/web/src/components/WebCryptoEnvironmentCheck.tsx @@ -0,0 +1,138 @@ +import { useCallback, useEffect, useState, type ReactNode } from 'react' +import { Loader2, ShieldAlert, ShieldCheck } from 'lucide-react' +import { diagnoseWebCryptoEnvironment } from '../lib/crypto' +import { t, type Language } from '../i18n/translations' + +export type WebCryptoCheckStatus = + | 'idle' + | 'checking' + | 'secure' + | 'insecure' + | 'unsupported' + +interface WebCryptoEnvironmentCheckProps { + language: Language + variant?: 'card' | 'compact' + onStatusChange?: (status: WebCryptoCheckStatus) => void +} + +export function WebCryptoEnvironmentCheck({ + language, + variant = 'card', + onStatusChange, +}: WebCryptoEnvironmentCheckProps) { + const [status, setStatus] = useState('idle') + const [summary, setSummary] = useState(null) + + useEffect(() => { + onStatusChange?.(status) + }, [onStatusChange, status]) + + const runCheck = useCallback(() => { + setStatus('checking') + setSummary(null) + + setTimeout(() => { + const result = diagnoseWebCryptoEnvironment() + setSummary( + t('environmentCheck.summary', language, { + origin: result.origin || 'N/A', + protocol: result.protocol || 'unknown', + }) + ) + + if (!result.isBrowser || !result.hasSubtleCrypto) { + setStatus('unsupported') + return + } + + if (!result.isSecureContext) { + setStatus('insecure') + return + } + + setStatus('secure') + }, 0) + }, [language, t]) + + useEffect(() => { + runCheck() + }, [runCheck]) + + const isCompact = variant === 'compact' + const containerClass = isCompact + ? 'p-3 rounded border border-gray-700 bg-gray-900 space-y-3' + : 'p-4 rounded border border-[#2B3139] bg-[#0B0E11] space-y-4' + + const descriptionColor = isCompact ? '#CBD5F5' : '#A1AEC8' + const showInfo = status !== 'idle' + + const statusRendererMap: Record ReactNode> = { + secure: () => ( +
+ +
+
+ {t('environmentCheck.secureTitle', language)} +
+
{t('environmentCheck.secureDesc', language)}
+
+
+ ), + insecure: () => ( +
+
+ +
+ {t('environmentCheck.insecureTitle', language)} +
+
+
{t('environmentCheck.insecureDesc', language)}
+
+ {t('environmentCheck.tipsTitle', language)} +
+
    +
  • {t('environmentCheck.tipHTTPS', language)}
  • +
  • {t('environmentCheck.tipLocalhost', language)}
  • +
  • {t('environmentCheck.tipIframe', language)}
  • +
+
+ ), + unsupported: () => ( +
+
+ +
+ {t('environmentCheck.unsupportedTitle', language)} +
+
+
{t('environmentCheck.unsupportedDesc', language)}
+
+ ), + checking: () => ( +
+ + {t('environmentCheck.checking', language)} +
+ ), + idle: () => null, + } + + const renderStatus = () => statusRendererMap[status]() + + return ( +
+
+ {showInfo && ( +
+ {summary ?? t('environmentCheck.description', language)} +
+ )} +
+ {showInfo &&
{renderStatus()}
} +
+ ) +} diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index 2b1e4519..c59164c9 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -786,6 +786,36 @@ export const translations = { faqGetHelpAnswer: 'Check GitHub Discussions, join our Telegram Community, or open an issue on GitHub.', + // Web Crypto Environment Check + environmentCheck: { + button: 'Check Secure Environment', + checking: 'Checking...', + description: + 'Automatically verifying whether this browser context allows Web Crypto before entering sensitive keys.', + secureTitle: 'Secure context detected', + secureDesc: + 'Web Crypto API is available. You can continue entering secrets with encryption enabled.', + insecureTitle: 'Insecure context detected', + insecureDesc: + 'This page is not running over HTTPS or a trusted localhost origin, so browsers block Web Crypto calls.', + tipsTitle: 'How to fix:', + tipHTTPS: + 'Serve the dashboard over HTTPS with a valid certificate (IP origins also need TLS).', + tipLocalhost: + 'During development, open the app via http://localhost or 127.0.0.1.', + tipIframe: + 'Avoid embedding the app in insecure HTTP iframes or reverse proxies that strip HTTPS.', + unsupportedTitle: 'Browser does not expose Web Crypto', + unsupportedDesc: + 'Open NOFX over HTTPS (or http://localhost during development) and avoid insecure iframes/reverse proxies so the browser can enable Web Crypto.', + summary: 'Current origin: {origin} • Protocol: {protocol}', + }, + + environmentSteps: { + checkTitle: '1. Environment check', + selectTitle: '2. Select exchange', + }, + // Two-Stage Key Modal twoStageKey: { title: 'Two-Stage Private Key Input', @@ -1550,6 +1580,33 @@ export const translations = { faqGetHelpAnswer: '查看 GitHub Discussions、加入 Telegram 社区或在 GitHub 上提出 issue。', + // Web Crypto Environment Check + environmentCheck: { + button: '一键检测环境', + checking: '正在检测...', + description: '系统将自动检测当前浏览器是否允许使用 Web Crypto。', + secureTitle: '环境安全,已启用 Web Crypto', + secureDesc: '页面处于安全上下文,可继续输入敏感信息并使用加密传输。', + insecureTitle: '检测到非安全环境', + insecureDesc: + '当前访问未通过 HTTPS 或可信 localhost,浏览器会阻止 Web Crypto 调用。', + tipsTitle: '修改建议:', + tipHTTPS: + '通过 HTTPS 访问(即使是 IP 也需证书),或部署到支持 TLS 的域名。', + tipLocalhost: '开发阶段请使用 http://localhost 或 127.0.0.1。', + tipIframe: + '避免把应用嵌入在不安全的 HTTP iframe 或会降级协议的反向代理中。', + unsupportedTitle: '浏览器未提供 Web Crypto', + unsupportedDesc: + '请通过 HTTPS 或本机 localhost 访问 NOFX,并避免嵌入不安全 iframe/反向代理,以符合浏览器的 Web Crypto 规则。', + summary: '当前来源:{origin} · 协议:{protocol}', + }, + + environmentSteps: { + checkTitle: '1. 环境检测', + selectTitle: '2. 选择交易所', + }, + // Two-Stage Key Modal twoStageKey: { title: '两阶段私钥输入', diff --git a/web/src/lib/crypto.ts b/web/src/lib/crypto.ts index 46660c83..2f2659ae 100644 --- a/web/src/lib/crypto.ts +++ b/web/src/lib/crypto.ts @@ -7,6 +7,16 @@ export interface EncryptedPayload { ts?: number // 可选:unix 秒,用于重放保护 } +export interface WebCryptoEnvironmentInfo { + isBrowser: boolean + isSecureContext: boolean + hasSubtleCrypto: boolean + origin?: string + protocol?: string + hostname?: string + isLocalhost?: boolean +} + export class CryptoService { private static publicKey: CryptoKey | null = null private static publicKeyPEM: string | null = null @@ -186,3 +196,40 @@ export function validatePrivateKeyFormat( } return /^[0-9a-fA-F]+$/.test(normalized) } + +export function diagnoseWebCryptoEnvironment(): WebCryptoEnvironmentInfo { + if (typeof window === 'undefined') { + return { + isBrowser: false, + isSecureContext: false, + hasSubtleCrypto: false, + } + } + + const { location } = window + const hostname = location?.hostname + const protocol = location?.protocol + const origin = location?.origin + const isLocalhost = hostname + ? ['localhost', '127.0.0.1', '::1'].includes(hostname) + : false + + const secureContext = + typeof window.isSecureContext === 'boolean' + ? window.isSecureContext + : protocol === 'https:' || (protocol === 'http:' && isLocalhost) + + const hasSubtleCrypto = + typeof window.crypto !== 'undefined' && + typeof window.crypto.subtle !== 'undefined' + + return { + isBrowser: true, + isSecureContext: secureContext, + hasSubtleCrypto, + origin: origin || undefined, + protocol: protocol || undefined, + hostname, + isLocalhost, + } +}