mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-07 04:50:57 +08:00
Merge branch 'origin/beta' into nofxos/test
# Conflicts: # config/database_pg.go
This commit is contained in:
@@ -13,6 +13,7 @@ import { useAuth } from '../contexts/AuthContext'
|
||||
import { getExchangeIcon } from './ExchangeIcons'
|
||||
import { getModelIcon } from './ModelIcons'
|
||||
import { TraderConfigModal } from './TraderConfigModal'
|
||||
import { TwoStageKeyModal } from './TwoStageKeyModal'
|
||||
import {
|
||||
Bot,
|
||||
Brain,
|
||||
@@ -46,6 +47,12 @@ function getShortName(fullName: string): string {
|
||||
return parts.length > 1 ? parts[parts.length - 1] : fullName
|
||||
}
|
||||
|
||||
function maskSecret(value: string): string {
|
||||
if (!value) return ''
|
||||
const length = Math.min(value.length, 16)
|
||||
return '•'.repeat(length)
|
||||
}
|
||||
|
||||
interface AITradersPageProps {
|
||||
onTraderSelect?: (traderId: string) => void
|
||||
}
|
||||
@@ -143,30 +150,9 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
allExchanges?.filter((e) => {
|
||||
if (!e.enabled) return false
|
||||
|
||||
// Aster 交易所需要特殊字段
|
||||
if (e.id === 'aster') {
|
||||
return (
|
||||
e.asterUser &&
|
||||
e.asterUser.trim() !== '' &&
|
||||
e.asterSigner &&
|
||||
e.asterSigner.trim() !== '' &&
|
||||
e.asterPrivateKey &&
|
||||
e.asterPrivateKey.trim() !== ''
|
||||
)
|
||||
}
|
||||
|
||||
// Hyperliquid 只需要私钥(作为apiKey),钱包地址会自动从私钥生成
|
||||
if (e.id === 'hyperliquid') {
|
||||
return e.apiKey && e.apiKey.trim() !== ''
|
||||
}
|
||||
|
||||
// Binance 等其他交易所需要 apiKey 和 secretKey
|
||||
return (
|
||||
e.apiKey &&
|
||||
e.apiKey.trim() !== '' &&
|
||||
e.secretKey &&
|
||||
e.secretKey.trim() !== ''
|
||||
)
|
||||
// 由于API不再返回敏感字段信息,只能基于enabled状态判断
|
||||
// 实际的配置验证将在后端进行
|
||||
return true
|
||||
}) || []
|
||||
|
||||
// 检查模型是否正在被运行中的交易员使用
|
||||
@@ -445,7 +431,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
},
|
||||
}
|
||||
|
||||
await api.updateExchangeConfigs(request)
|
||||
await api.updateExchangeConfigsEncrypted(request)
|
||||
|
||||
const refreshed = await api.getExchangeConfigs()
|
||||
setAllExchanges(refreshed)
|
||||
@@ -494,7 +480,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
},
|
||||
}
|
||||
|
||||
await api.updateExchangeConfigs(request)
|
||||
await api.updateExchangeConfigsEncrypted(request)
|
||||
|
||||
const refreshedExchanges = await api.getExchangeConfigs()
|
||||
setAllExchanges(refreshedExchanges)
|
||||
@@ -811,7 +797,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`w-2.5 h-2.5 md:w-3 md:h-3 rounded-full flex-shrink-0 ${exchange.enabled && exchange.apiKey ? 'bg-green-400' : 'bg-gray-500'}`}
|
||||
className={`w-2.5 h-2.5 md:w-3 md:h-3 rounded-full flex-shrink-0 ${exchange.enabled ? 'bg-green-400' : 'bg-gray-500'}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -1666,6 +1652,9 @@ function ExchangeConfigModal({
|
||||
const [asterUser, setAsterUser] = useState('')
|
||||
const [asterSigner, setAsterSigner] = useState('')
|
||||
const [asterPrivateKey, setAsterPrivateKey] = useState('')
|
||||
const [secureInputTarget, setSecureInputTarget] = useState<
|
||||
null | 'hyperliquid' | 'aster'
|
||||
>(null)
|
||||
|
||||
// 获取当前选择的交易所信息
|
||||
// 编辑模式:从 configuredExchanges 查找(包含用户配置的 apiKey、secretKey 等)
|
||||
@@ -1674,24 +1663,50 @@ function ExchangeConfigModal({
|
||||
? configuredExchanges?.find(e => e.id === selectedExchangeId)
|
||||
: supportedExchanges?.find(e => e.id === selectedExchangeId);
|
||||
|
||||
// 如果是编辑现有交易所,初始化表单数据
|
||||
const secureInputContextLabel =
|
||||
secureInputTarget === 'aster'
|
||||
? t('asterExchangeName', language)
|
||||
: secureInputTarget === 'hyperliquid'
|
||||
? t('hyperliquidExchangeName', language)
|
||||
: undefined
|
||||
|
||||
// 如果是编辑现有交易所,清空所有敏感字段以保证安全
|
||||
useEffect(() => {
|
||||
if (editingExchangeId && selectedExchange) {
|
||||
setApiKey(selectedExchange.apiKey || '')
|
||||
setSecretKey(selectedExchange.secretKey || '')
|
||||
setPassphrase('') // Don't load existing passphrase for security
|
||||
// 编辑模式下清空所有敏感字段,用户需要重新输入
|
||||
setApiKey('')
|
||||
setSecretKey('')
|
||||
setPassphrase('')
|
||||
setTestnet(selectedExchange.testnet || false)
|
||||
|
||||
// Hyperliquid 字段
|
||||
setHyperliquidWalletAddr(selectedExchange.hyperliquidWalletAddr || '')
|
||||
|
||||
// Aster 字段
|
||||
setAsterUser(selectedExchange.asterUser || '')
|
||||
setAsterSigner(selectedExchange.asterSigner || '')
|
||||
setAsterPrivateKey('') // Don't load existing private key for security
|
||||
setAsterSigner('')
|
||||
setAsterPrivateKey('')
|
||||
}
|
||||
}, [editingExchangeId, selectedExchange])
|
||||
|
||||
const handleSecureInputComplete = ({
|
||||
value,
|
||||
obfuscationLog,
|
||||
}: {
|
||||
value: string
|
||||
obfuscationLog: string[]
|
||||
}) => {
|
||||
const trimmed = value.trim()
|
||||
if (secureInputTarget === 'hyperliquid') {
|
||||
setApiKey(trimmed)
|
||||
}
|
||||
if (secureInputTarget === 'aster') {
|
||||
setAsterPrivateKey(trimmed)
|
||||
}
|
||||
console.log('Secure input obfuscation log:', obfuscationLog)
|
||||
setSecureInputTarget(null)
|
||||
}
|
||||
|
||||
const handleSecureInputCancel = () => {
|
||||
setSecureInputTarget(null)
|
||||
}
|
||||
|
||||
// 加载服务器IP(当选择binance时)
|
||||
useEffect(() => {
|
||||
if (selectedExchangeId === 'binance' && !serverIP) {
|
||||
@@ -1755,11 +1770,12 @@ function ExchangeConfigModal({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div
|
||||
className="bg-gray-800 rounded-lg p-6 w-full max-w-lg relative"
|
||||
style={{ background: '#1E2329' }}
|
||||
>
|
||||
<>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div
|
||||
className="bg-gray-800 rounded-lg p-6 w-full max-w-lg relative"
|
||||
style={{ background: '#1E2329' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
|
||||
{editingExchangeId
|
||||
@@ -2094,19 +2110,55 @@ function ExchangeConfigModal({
|
||||
>
|
||||
{t('privateKey', language)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={t('enterPrivateKey', language)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={maskSecret(apiKey)}
|
||||
readOnly
|
||||
placeholder={t('enterPrivateKey', language)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSecureInputTarget('hyperliquid')}
|
||||
className="px-3 py-2 rounded text-xs font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: '#F0B90B',
|
||||
color: '#000',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{apiKey
|
||||
? t('secureInputReenter', language)
|
||||
: t('secureInputButton', language)}
|
||||
</button>
|
||||
{apiKey && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApiKey('')}
|
||||
className="px-3 py-2 rounded text-xs font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: '#1B1F2B',
|
||||
color: '#848E9C',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{t('secureInputClear', language)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{apiKey && (
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('secureInputHint', language)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||
{t('hyperliquidPrivateKeyDesc', language)}
|
||||
</div>
|
||||
@@ -2209,19 +2261,55 @@ function ExchangeConfigModal({
|
||||
/>
|
||||
</Tooltip>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={asterPrivateKey}
|
||||
onChange={(e) => setAsterPrivateKey(e.target.value)}
|
||||
placeholder={t('enterPrivateKey', language)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={maskSecret(asterPrivateKey)}
|
||||
readOnly
|
||||
placeholder={t('enterPrivateKey', language)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSecureInputTarget('aster')}
|
||||
className="px-3 py-2 rounded text-xs font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: '#F0B90B',
|
||||
color: '#000',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{asterPrivateKey
|
||||
? t('secureInputReenter', language)
|
||||
: t('secureInputButton', language)}
|
||||
</button>
|
||||
{asterPrivateKey && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAsterPrivateKey('')}
|
||||
className="px-3 py-2 rounded text-xs font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: '#1B1F2B',
|
||||
color: '#848E9C',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{t('secureInputClear', language)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{asterPrivateKey && (
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('secureInputHint', language)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -2349,6 +2437,16 @@ function ExchangeConfigModal({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TwoStageKeyModal
|
||||
isOpen={secureInputTarget !== null}
|
||||
language={language}
|
||||
contextLabel={secureInputContextLabel}
|
||||
expectedLength={64}
|
||||
onCancel={handleSecureInputCancel}
|
||||
onComplete={handleSecureInputComplete}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
320
web/src/components/TwoStageKeyModal.tsx
Normal file
320
web/src/components/TwoStageKeyModal.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { t, type Language } from '../i18n/translations'
|
||||
|
||||
const DEFAULT_LENGTH = 64
|
||||
|
||||
function generateObfuscation(): string {
|
||||
const bytes = new Uint8Array(32)
|
||||
crypto.getRandomValues(bytes)
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
function validatePrivateKeyFormat(value: string, expectedLength: number): boolean {
|
||||
const normalized = value.startsWith('0x') ? value.slice(2) : value
|
||||
if (normalized.length !== expectedLength) {
|
||||
return false
|
||||
}
|
||||
return /^[0-9a-fA-F]+$/.test(normalized)
|
||||
}
|
||||
|
||||
export interface TwoStageKeyModalResult {
|
||||
value: string
|
||||
obfuscationLog: string[]
|
||||
}
|
||||
|
||||
interface TwoStageKeyModalProps {
|
||||
isOpen: boolean
|
||||
language: Language
|
||||
onCancel: () => void
|
||||
onComplete: (result: TwoStageKeyModalResult) => void
|
||||
expectedLength?: number
|
||||
contextLabel?: string
|
||||
}
|
||||
|
||||
export function TwoStageKeyModal({
|
||||
isOpen,
|
||||
language,
|
||||
onCancel,
|
||||
onComplete,
|
||||
expectedLength = DEFAULT_LENGTH,
|
||||
contextLabel,
|
||||
}: TwoStageKeyModalProps) {
|
||||
const [stage, setStage] = useState<1 | 2>(1)
|
||||
const [part1, setPart1] = useState('')
|
||||
const [part2, setPart2] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [clipboardStatus, setClipboardStatus] = useState<'idle' | 'copied' | 'failed'>('idle')
|
||||
const [obfuscationLog, setObfuscationLog] = useState<string[]>([])
|
||||
const [processing, setProcessing] = useState(false)
|
||||
const [manualObfuscationValue, setManualObfuscationValue] = useState<string | null>(null)
|
||||
const stage1InputRef = useRef<HTMLInputElement | null>(null)
|
||||
const stage2InputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [isOpen, onCancel])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setStage(1)
|
||||
setPart1('')
|
||||
setPart2('')
|
||||
setError(null)
|
||||
setClipboardStatus('idle')
|
||||
setObfuscationLog([])
|
||||
setProcessing(false)
|
||||
setManualObfuscationValue(null)
|
||||
return
|
||||
}
|
||||
|
||||
const focusTimer = setTimeout(() => {
|
||||
if (stage === 1) {
|
||||
stage1InputRef.current?.focus()
|
||||
} else {
|
||||
stage2InputRef.current?.focus()
|
||||
}
|
||||
}, 10)
|
||||
|
||||
return () => clearTimeout(focusTimer)
|
||||
}, [isOpen, stage])
|
||||
|
||||
const heading = useMemo(() => {
|
||||
if (!contextLabel) {
|
||||
return t('twoStageModalTitle', language)
|
||||
}
|
||||
return `${t('twoStageModalTitle', language)} · ${contextLabel}`
|
||||
}, [contextLabel, language])
|
||||
|
||||
if (!isOpen) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleOverlayClick = () => {
|
||||
if (!processing) {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
|
||||
const handleStage1Next = async () => {
|
||||
if (!part1.trim()) {
|
||||
setError(t('twoStageStage1Error', language))
|
||||
return
|
||||
}
|
||||
setProcessing(true)
|
||||
const obfuscation = generateObfuscation()
|
||||
let copied = false
|
||||
try {
|
||||
await navigator.clipboard.writeText(obfuscation)
|
||||
copied = true
|
||||
setClipboardStatus('copied')
|
||||
setManualObfuscationValue(null)
|
||||
} catch (err) {
|
||||
console.warn('Clipboard write failed', err)
|
||||
setClipboardStatus('failed')
|
||||
setManualObfuscationValue(obfuscation)
|
||||
}
|
||||
setObfuscationLog((prev) => [...prev, `stage1:${new Date().toISOString()}`])
|
||||
setProcessing(false)
|
||||
setError(null)
|
||||
setStage(2)
|
||||
if (copied) {
|
||||
setManualObfuscationValue(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
const cleanedPart1 = part1.trim()
|
||||
const cleanedPart2 = part2.trim()
|
||||
const combined = (cleanedPart1 + cleanedPart2).replace(/\s+/g, '')
|
||||
|
||||
if (!validatePrivateKeyFormat(combined, expectedLength)) {
|
||||
setError(t('twoStageInvalidFormat', language, { length: expectedLength }))
|
||||
return
|
||||
}
|
||||
|
||||
setObfuscationLog((prev) => [...prev, `stage2:${new Date().toISOString()}`])
|
||||
const result: TwoStageKeyModalResult = {
|
||||
value: combined,
|
||||
obfuscationLog: [...obfuscationLog, `stage2:${new Date().toISOString()}`],
|
||||
}
|
||||
onComplete(result)
|
||||
}
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4"
|
||||
onClick={handleOverlayClick}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-xl border border-[#2B3139] bg-[#0B0E11] p-6 shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<h2 className="text-lg font-semibold" style={{ color: '#EAECEF' }}>
|
||||
{heading}
|
||||
</h2>
|
||||
<p className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||
{t('twoStageModalDescription', language, { length: expectedLength })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{stage === 1 ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('twoStageStage1Title', language)}
|
||||
</label>
|
||||
<input
|
||||
ref={stage1InputRef}
|
||||
type="password"
|
||||
value={part1}
|
||||
onChange={(event) => setPart1(event.target.value)}
|
||||
placeholder={t('twoStageStage1Placeholder', language)}
|
||||
className="w-full rounded border border-[#2B3139] bg-[#0F111C] px-3 py-2 text-sm text-[#EAECEF] outline-none focus:ring-2 focus:ring-[#F0B90B]/40"
|
||||
disabled={processing}
|
||||
/>
|
||||
<p className="mt-2 text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('twoStageStage1Hint', language)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{clipboardStatus === 'failed' && (
|
||||
<div
|
||||
className="rounded border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs"
|
||||
style={{ color: '#F6465D' }}
|
||||
>
|
||||
<div>{t('twoStageClipboardManual', language)}</div>
|
||||
{manualObfuscationValue && (
|
||||
<code className="mt-2 block select-all rounded bg-black/40 px-2 py-1 text-[11px] text-[#F0B90B]">
|
||||
{manualObfuscationValue}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className="rounded border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs"
|
||||
style={{ color: '#F6465D' }}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex-1 rounded px-3 py-2 text-sm font-semibold transition-all hover:scale-[1.01]"
|
||||
style={{ background: '#1B1F2B', color: '#848E9C' }}
|
||||
disabled={processing}
|
||||
>
|
||||
{t('twoStageCancel', language)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStage1Next}
|
||||
className="flex-1 rounded px-3 py-2 text-sm font-semibold transition-all hover:scale-[1.01]"
|
||||
style={{
|
||||
background: processing ? '#3d2e0d' : '#F0B90B',
|
||||
color: processing ? '#a18a43' : '#000',
|
||||
opacity: part1.trim().length === 0 ? 0.7 : 1,
|
||||
}}
|
||||
disabled={processing || part1.trim().length === 0}
|
||||
>
|
||||
{processing ? t('twoStageProcessing', language) : t('twoStageNext', language)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('twoStageStage2Title', language)}
|
||||
</label>
|
||||
<input
|
||||
ref={stage2InputRef}
|
||||
type="password"
|
||||
value={part2}
|
||||
onChange={(event) => setPart2(event.target.value)}
|
||||
placeholder={t('twoStageStage2Placeholder', language)}
|
||||
className="w-full rounded border border-[#2B3139] bg-[#0F111C] px-3 py-2 text-sm text-[#EAECEF] outline-none focus:ring-2 focus:ring-[#F0B90B]/40"
|
||||
/>
|
||||
<p className="mt-2 text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('twoStageStage2Hint', language)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{clipboardStatus === 'copied' && (
|
||||
<div
|
||||
className="rounded border border-[#F0B90B]/40 bg-[#F0B90B]/10 px-3 py-2 text-xs"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
{t('twoStageClipboardSuccess', language)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{clipboardStatus === 'failed' && manualObfuscationValue && (
|
||||
<div
|
||||
className="rounded border border-[#2B3139] bg-[#141821] px-3 py-2 text-xs"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('twoStageClipboardReminder', language)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className="rounded border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs"
|
||||
style={{ color: '#F6465D' }}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStage(1)
|
||||
setPart2('')
|
||||
setError(null)
|
||||
setClipboardStatus('idle')
|
||||
}}
|
||||
className="rounded px-3 py-2 text-sm font-semibold transition-all hover:scale-[1.01]"
|
||||
style={{ background: '#1B1F2B', color: '#848E9C' }}
|
||||
>
|
||||
{t('twoStageBack', language)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
className="flex-1 rounded px-3 py-2 text-sm font-semibold transition-all hover:scale-[1.01]"
|
||||
style={{ background: '#F0B90B', color: '#000' }}
|
||||
>
|
||||
{t('twoStageSubmit', language)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return createPortal(modalContent, document.body)
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react'
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { getSystemConfig } from '../lib/config';
|
||||
import { CryptoService } from '../lib/crypto';
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
@@ -61,12 +63,33 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
try {
|
||||
const systemConfig = await getSystemConfig()
|
||||
if (!systemConfig.rsa_public_key) {
|
||||
throw new Error('系统未配置登录所需的RSA公钥')
|
||||
}
|
||||
|
||||
await CryptoService.initialize(systemConfig.rsa_public_key)
|
||||
const sessionId = sessionStorage.getItem('session_id') || ''
|
||||
|
||||
const requestBody = {
|
||||
email_encrypted: await CryptoService.encryptSensitiveData(
|
||||
email,
|
||||
email,
|
||||
sessionId
|
||||
),
|
||||
password_encrypted: await CryptoService.encryptSensitiveData(
|
||||
password,
|
||||
email,
|
||||
sessionId
|
||||
),
|
||||
}
|
||||
|
||||
const response = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, password }),
|
||||
body: JSON.stringify(requestBody),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
@@ -84,6 +107,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
return { success: false, message: data.error }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login request failed:', error)
|
||||
return { success: false, message: '登录失败,请重试' }
|
||||
}
|
||||
|
||||
|
||||
@@ -204,6 +204,42 @@ export const translations = {
|
||||
'API wallet private key - Get from https://www.asterdex.com/en/api-wallet (only used locally for signing, never transmitted)',
|
||||
asterUsdtWarning:
|
||||
'Important: Aster only tracks USDT balance. Please ensure you use USDT as margin currency to avoid P&L calculation errors caused by price fluctuations of other assets (BNB, ETH, etc.)',
|
||||
hyperliquidExchangeName: 'Hyperliquid',
|
||||
asterExchangeName: 'Aster DEX',
|
||||
secureInputButton: 'Secure Input',
|
||||
secureInputReenter: 'Re-enter Securely',
|
||||
secureInputClear: 'Clear',
|
||||
secureInputHint:
|
||||
'Captured via secure two-step input. Use “Re-enter Securely” to update this value.',
|
||||
twoStageModalTitle: 'Secure Key Input',
|
||||
twoStageModalDescription:
|
||||
'Use a two-step flow to enter your {length}-character private key safely.',
|
||||
twoStageStage1Title: 'Step 1 · Enter the first half',
|
||||
twoStageStage1Placeholder: 'First 32 characters (include 0x if present)',
|
||||
twoStageStage1Hint:
|
||||
'Continuing copies an obfuscation string to your clipboard as a diversion.',
|
||||
twoStageStage1Error: 'Please enter the first part before continuing.',
|
||||
twoStageNext: 'Next',
|
||||
twoStageProcessing: 'Processing…',
|
||||
twoStageCancel: 'Cancel',
|
||||
twoStageStage2Title: 'Step 2 · Enter the rest',
|
||||
twoStageStage2Placeholder: 'Remaining characters of your private key',
|
||||
twoStageStage2Hint:
|
||||
'Paste the obfuscation string somewhere neutral, then finish entering your key.',
|
||||
twoStageClipboardSuccess:
|
||||
'Obfuscation string copied. Paste it into any text field once before completing.',
|
||||
twoStageClipboardReminder:
|
||||
'Remember to paste the obfuscation string before submitting to avoid clipboard leaks.',
|
||||
twoStageClipboardManual:
|
||||
'Automatic copy failed. Copy the obfuscation string below manually.',
|
||||
twoStageClipboardFailed:
|
||||
'Automatic clipboard copy failed. Please copy the obfuscation string manually.',
|
||||
twoStageClipboardInstruction:
|
||||
'Obfuscation string copied. Paste it once before finishing the input.',
|
||||
twoStageBack: 'Back',
|
||||
twoStageSubmit: 'Confirm',
|
||||
twoStageInvalidFormat:
|
||||
'Invalid private key format. Expected {length} hexadecimal characters (optional 0x prefix).',
|
||||
testnetDescription:
|
||||
'Enable to connect to exchange test environment for simulated trading',
|
||||
securityWarning: 'Security Warning',
|
||||
@@ -700,6 +736,34 @@ export const translations = {
|
||||
'API 钱包私钥 - 从 https://www.asterdex.com/zh-CN/api-wallet 获取(仅在本地用于签名,不会被传输)',
|
||||
asterUsdtWarning:
|
||||
'重要提示:Aster 仅统计 USDT 余额。请确保您使用 USDT 作为保证金币种,避免其他资产(BNB、ETH等)的价格波动导致盈亏统计错误',
|
||||
hyperliquidExchangeName: 'Hyperliquid',
|
||||
asterExchangeName: 'Aster DEX',
|
||||
secureInputButton: '安全输入',
|
||||
secureInputReenter: '重新安全输入',
|
||||
secureInputClear: '清除',
|
||||
secureInputHint: '已通过安全双阶段输入设置。若需修改,请点击“重新安全输入”。',
|
||||
twoStageModalTitle: '安全私钥输入',
|
||||
twoStageModalDescription: '使用双阶段流程安全输入长度为 {length} 的私钥。',
|
||||
twoStageStage1Title: '步骤一 · 输入前半段',
|
||||
twoStageStage1Placeholder: '前 32 位字符(若有 0x 前缀请保留)',
|
||||
twoStageStage1Hint: '继续后会将扰动字符串复制到剪贴板,用于迷惑剪贴板监控。',
|
||||
twoStageStage1Error: '请先输入第一段私钥。',
|
||||
twoStageNext: '下一步',
|
||||
twoStageProcessing: '处理中…',
|
||||
twoStageCancel: '取消',
|
||||
twoStageStage2Title: '步骤二 · 输入剩余部分',
|
||||
twoStageStage2Placeholder: '剩余的私钥字符',
|
||||
twoStageStage2Hint: '将扰动字符串粘贴到任意位置后,再完成私钥输入。',
|
||||
twoStageClipboardSuccess:
|
||||
'扰动字符串已复制。请在完成前在任意文本处粘贴一次以迷惑剪贴板记录。',
|
||||
twoStageClipboardReminder:
|
||||
'记得在提交前粘贴一次扰动字符串,降低剪贴板泄漏风险。',
|
||||
twoStageClipboardManual: '自动复制失败,请手动复制下面的扰动字符串。',
|
||||
twoStageClipboardFailed: '自动写入剪贴板失败,请手动复制扰动字符串。',
|
||||
twoStageClipboardInstruction: '扰动字符串已复制,请在完成输入前粘贴一次。',
|
||||
twoStageBack: '返回',
|
||||
twoStageSubmit: '确认',
|
||||
twoStageInvalidFormat: '私钥格式不正确,应为 {length} 位十六进制字符(可选 0x 前缀)。',
|
||||
testnetDescription: '启用后将连接到交易所测试环境,用于模拟交易',
|
||||
securityWarning: '安全提示',
|
||||
saveConfiguration: '保存配置',
|
||||
|
||||
@@ -11,7 +11,8 @@ import type {
|
||||
UpdateModelConfigRequest,
|
||||
UpdateExchangeConfigRequest,
|
||||
CompetitionData,
|
||||
} from '../types'
|
||||
} from '../types';
|
||||
import { CryptoService } from './crypto';
|
||||
|
||||
const API_BASE = '/api'
|
||||
|
||||
@@ -165,6 +166,40 @@ export const api = {
|
||||
if (!res.ok) throw new Error('更新交易所配置失败')
|
||||
},
|
||||
|
||||
// 使用加密传输更新交易所配置
|
||||
async updateExchangeConfigsEncrypted(request: UpdateExchangeConfigRequest): Promise<void> {
|
||||
// 从系统配置获取公钥
|
||||
const configRes = await fetch(`${API_BASE}/config`);
|
||||
if (!configRes.ok) throw new Error('获取系统配置失败');
|
||||
const config = await configRes.json();
|
||||
|
||||
if (!config.rsa_public_key) {
|
||||
throw new Error('系统未配置RSA公钥,无法使用加密传输');
|
||||
}
|
||||
|
||||
// 初始化加密服务
|
||||
await CryptoService.initialize(config.rsa_public_key);
|
||||
|
||||
// 获取用户信息(从localStorage或其他地方)
|
||||
const userId = localStorage.getItem('user_id') || '';
|
||||
const sessionId = sessionStorage.getItem('session_id') || '';
|
||||
|
||||
// 加密敏感数据
|
||||
const encryptedPayload = await CryptoService.encryptSensitiveData(
|
||||
JSON.stringify(request),
|
||||
userId,
|
||||
sessionId
|
||||
);
|
||||
|
||||
// 发送加密数据
|
||||
const res = await fetch(`${API_BASE}/exchanges/encrypted`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(encryptedPayload),
|
||||
});
|
||||
if (!res.ok) throw new Error('更新交易所配置失败');
|
||||
},
|
||||
|
||||
// 获取系统状态(支持trader_id)
|
||||
async getStatus(traderId?: string): Promise<SystemStatus> {
|
||||
const url = traderId
|
||||
|
||||
@@ -3,6 +3,8 @@ export interface SystemConfig {
|
||||
default_coins?: string[]
|
||||
btc_eth_leverage?: number
|
||||
altcoin_leverage?: number
|
||||
rsa_public_key?: string
|
||||
rsa_key_id?: string
|
||||
}
|
||||
|
||||
let configPromise: Promise<SystemConfig> | null = null
|
||||
|
||||
147
web/src/lib/crypto.ts
Normal file
147
web/src/lib/crypto.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
export interface EncryptedPayload {
|
||||
wrappedKey: string; // RSA-OAEP(K)
|
||||
iv: string; // 12 bytes
|
||||
ciphertext: string; // AES-GCM 输出(含 tag)
|
||||
aad?: string; // 可选:额外认证数据
|
||||
kid?: string; // 可选:服务端公钥标识
|
||||
ts?: number; // 可选:unix 秒,用于重放保护
|
||||
}
|
||||
|
||||
export class CryptoService {
|
||||
private static publicKey: CryptoKey | null = null;
|
||||
private static publicKeyPEM: string | null = null;
|
||||
|
||||
static async initialize(publicKeyPEM: string) {
|
||||
// 检查 Web Crypto API 是否可用
|
||||
if (!window.crypto || !window.crypto.subtle) {
|
||||
throw new Error('Web Crypto API is not available. Please use HTTPS or localhost to access the application.');
|
||||
}
|
||||
|
||||
if (this.publicKey && this.publicKeyPEM === publicKeyPEM) {
|
||||
return;
|
||||
}
|
||||
this.publicKeyPEM = publicKeyPEM;
|
||||
this.publicKey = await this.importPublicKey(publicKeyPEM);
|
||||
}
|
||||
|
||||
private static async importPublicKey(pem: string): Promise<CryptoKey> {
|
||||
const pemHeader = '-----BEGIN PUBLIC KEY-----';
|
||||
const pemFooter = '-----END PUBLIC KEY-----';
|
||||
const headerIndex = pem.indexOf(pemHeader);
|
||||
const footerIndex = pem.indexOf(pemFooter);
|
||||
|
||||
if (headerIndex === -1 || footerIndex === -1 || headerIndex >= footerIndex) {
|
||||
throw new Error('Invalid PEM formatted public key');
|
||||
}
|
||||
|
||||
const pemContents = pem
|
||||
.substring(headerIndex + pemHeader.length, footerIndex)
|
||||
.replace(/\s+/g, ''); // 移除所有空白字符(包括换行符、空格等)
|
||||
|
||||
const binaryDerString = atob(pemContents);
|
||||
const binaryDer = new Uint8Array(binaryDerString.length);
|
||||
for (let i = 0; i < binaryDerString.length; i++) {
|
||||
binaryDer[i] = binaryDerString.charCodeAt(i);
|
||||
}
|
||||
|
||||
return crypto.subtle.importKey(
|
||||
'spki',
|
||||
binaryDer,
|
||||
{
|
||||
name: 'RSA-OAEP',
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
false,
|
||||
['encrypt']
|
||||
);
|
||||
}
|
||||
|
||||
static async encryptSensitiveData(
|
||||
plaintext: string,
|
||||
userId?: string,
|
||||
sessionId?: string
|
||||
): Promise<EncryptedPayload> {
|
||||
if (!this.publicKey) {
|
||||
throw new Error('Crypto service not initialized. Call initialize() first.');
|
||||
}
|
||||
|
||||
// 1. 生成 256-bit AES 密钥
|
||||
const aesKey = await crypto.subtle.generateKey(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
length: 256,
|
||||
},
|
||||
true,
|
||||
['encrypt']
|
||||
);
|
||||
|
||||
// 2. 生成 12 字节随机 IV
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
|
||||
// 3. 准备 AAD (额外认证数据)
|
||||
const ts = Math.floor(Date.now() / 1000);
|
||||
const aadObject = {
|
||||
userId: userId || '',
|
||||
sessionId: sessionId || '',
|
||||
ts: ts,
|
||||
purpose: 'sensitive_data_encryption'
|
||||
};
|
||||
const aadString = JSON.stringify(aadObject);
|
||||
const aadBytes = new TextEncoder().encode(aadString);
|
||||
|
||||
// 4. 使用 AES-GCM 加密数据
|
||||
const plaintextBytes = new TextEncoder().encode(plaintext);
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: iv,
|
||||
additionalData: aadBytes,
|
||||
tagLength: 128, // 16 bytes tag
|
||||
},
|
||||
aesKey,
|
||||
plaintextBytes
|
||||
);
|
||||
|
||||
// 5. 导出 AES 密钥
|
||||
const aesKeyRaw = await crypto.subtle.exportKey('raw', aesKey);
|
||||
|
||||
// 6. 使用 RSA-OAEP 加密 AES 密钥
|
||||
const wrappedKey = await crypto.subtle.encrypt(
|
||||
{
|
||||
name: 'RSA-OAEP',
|
||||
},
|
||||
this.publicKey,
|
||||
aesKeyRaw
|
||||
);
|
||||
|
||||
// 7. 转换为 base64url 格式
|
||||
return {
|
||||
wrappedKey: this.arrayBufferToBase64Url(wrappedKey),
|
||||
iv: this.arrayBufferToBase64Url(iv),
|
||||
ciphertext: this.arrayBufferToBase64Url(ciphertext),
|
||||
aad: this.arrayBufferToBase64Url(aadBytes),
|
||||
kid: 'rsa-key-2025-11-05',
|
||||
ts: ts,
|
||||
};
|
||||
}
|
||||
|
||||
private static arrayBufferToBase64Url(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)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
}
|
||||
|
||||
static async encryptWalletPrivateKey(privateKey: string, userId?: string, sessionId?: string): Promise<EncryptedPayload> {
|
||||
return this.encryptSensitiveData(privateKey, userId, sessionId);
|
||||
}
|
||||
|
||||
static async encryptExchangeSecret(secretKey: string, userId?: string, sessionId?: string): Promise<EncryptedPayload> {
|
||||
return this.encryptSensitiveData(secretKey, userId, sessionId);
|
||||
}
|
||||
}
|
||||
@@ -108,19 +108,16 @@ export interface AIModel {
|
||||
|
||||
export interface Exchange {
|
||||
id: string
|
||||
user_id: string
|
||||
name: string
|
||||
type: 'cex' | 'dex'
|
||||
enabled: boolean
|
||||
apiKey?: string
|
||||
secretKey?: string
|
||||
testnet?: boolean
|
||||
// Hyperliquid 特定字段
|
||||
hyperliquidWalletAddr?: string
|
||||
// Aster 特定字段
|
||||
asterUser?: string
|
||||
asterSigner?: string
|
||||
asterPrivateKey?: string
|
||||
deleted?: boolean
|
||||
hyperliquidWalletAddr?: string // 钱包地址,非敏感信息
|
||||
asterUser?: string // Aster用户名,非敏感信息
|
||||
deleted: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CreateTraderRequest {
|
||||
|
||||
Reference in New Issue
Block a user