mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
Address multiple vulnerabilities found during security review: - Remove unauthenticated POST /api/crypto/decrypt decryption oracle (route, handler, dead frontend helper) + regression test. Transport encryption is one-directional; the server never needs to decrypt arbitrary client payloads. - Redact secrets in config-update logs: handler_ai_model/handler_exchange logged %+v of decrypted requests, leaking API keys / secret keys / passphrases / private keys. Use named types shared with the log sanitizer so the masking can never drift again; extend masking to passphrase + lighter_api_key_private_key. - crypto: require a valid timestamp in DecryptPayload (a missing ts previously skipped replay protection entirely). - crypto: EncryptedString.Value() now fails closed instead of silently persisting plaintext secrets when encryption errors. - auth: per-IP token-bucket rate limiting on /login and /register against online brute-force; raise registration password minimum 6 -> 8; add dummy bcrypt compare on unknown-email login to close the user-enumeration timing channel. - IDOR: getTraderFromQuery no longer falls back to the global in-memory trader map; trader access is strictly scoped to the authenticated caller. - Bump Go 1.25.10 -> 1.25.11 to resolve reachable net/textproto and crypto/x509 stdlib advisories (govulncheck now reports 0 affecting vulnerabilities).
247 lines
6.9 KiB
TypeScript
247 lines
6.9 KiB
TypeScript
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 interface CryptoConfig {
|
||
transport_encryption: boolean
|
||
}
|
||
|
||
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
|
||
private static _transportEncryption: boolean | null = null
|
||
|
||
static get transportEncryption(): boolean {
|
||
return this._transportEncryption === true
|
||
}
|
||
|
||
static async initialize(publicKeyPEM: string) {
|
||
if (this.publicKey && this.publicKeyPEM === publicKeyPEM) {
|
||
return
|
||
}
|
||
this.publicKeyPEM = publicKeyPEM
|
||
this.publicKey = await this.importPublicKey(publicKeyPEM)
|
||
}
|
||
|
||
static async fetchCryptoConfig(): Promise<CryptoConfig> {
|
||
const response = await fetch('/api/crypto/config')
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to fetch crypto config: ${response.statusText}`)
|
||
}
|
||
const data = await response.json()
|
||
this._transportEncryption = data.transport_encryption
|
||
return data
|
||
}
|
||
|
||
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 rawAesKey = await crypto.subtle.exportKey('raw', aesKey)
|
||
|
||
// 6. 使用 RSA-OAEP 加密 AES 密钥
|
||
const wrappedKey = await crypto.subtle.encrypt(
|
||
{
|
||
name: 'RSA-OAEP',
|
||
},
|
||
this.publicKey,
|
||
rawAesKey
|
||
)
|
||
|
||
// 7. 编码为 base64url
|
||
return {
|
||
wrappedKey: this.arrayBufferToBase64Url(wrappedKey),
|
||
iv: this.arrayBufferToBase64Url(iv.buffer),
|
||
ciphertext: this.arrayBufferToBase64Url(ciphertext),
|
||
aad: this.arrayBufferToBase64Url(aadBytes.buffer),
|
||
ts: ts,
|
||
}
|
||
}
|
||
|
||
private static arrayBufferToBase64Url(buffer: ArrayBuffer): string {
|
||
const bytes = new Uint8Array(buffer)
|
||
let binary = ''
|
||
for (let i = 0; i < bytes.length; i++) {
|
||
binary += String.fromCharCode(bytes[i])
|
||
}
|
||
return btoa(binary)
|
||
.replace(/\+/g, '-')
|
||
.replace(/\//g, '_')
|
||
.replace(/=/g, '')
|
||
}
|
||
|
||
static async fetchPublicKey(): Promise<string> {
|
||
const response = await fetch('/api/crypto/public-key')
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to fetch public key: ${response.statusText}`)
|
||
}
|
||
const data = await response.json()
|
||
// Update transport encryption flag from server response
|
||
if (typeof data.transport_encryption === 'boolean') {
|
||
this._transportEncryption = data.transport_encryption
|
||
}
|
||
return data.public_key || ''
|
||
}
|
||
|
||
// NOTE: there is intentionally no decryptSensitiveData() here. Transport
|
||
// encryption is one-directional: the client encrypts sensitive fields to the
|
||
// server's public key and the server decrypts them internally on the
|
||
// authenticated config endpoints. The server exposes no public decrypt route,
|
||
// so a client-side decrypt helper would be both useless and a security
|
||
// anti-pattern (it implied an unauthenticated decryption oracle existed).
|
||
}
|
||
|
||
// 生成混淆字符串(用于剪贴板混淆)
|
||
export function generateObfuscation(): string {
|
||
const bytes = new Uint8Array(32)
|
||
crypto.getRandomValues(bytes)
|
||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(
|
||
''
|
||
)
|
||
}
|
||
|
||
// 验证私钥格式
|
||
export function validatePrivateKeyFormat(
|
||
value: string,
|
||
expectedLength: number = 64
|
||
): boolean {
|
||
const normalized = value.startsWith('0x') ? value.slice(2) : value
|
||
if (normalized.length !== expectedLength) {
|
||
return false
|
||
}
|
||
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,
|
||
}
|
||
}
|