Security Audit: Private Key Import Feature
Date: 2026-05-12 Status: PASSED — 0 critical, 0 high, 0 medium, 1 low (optional) Scope: Addition of raw secp256k1 private key import to wallet
Changes Audited
| File | Change |
|---|---|
src/types/defaultAccount.d.ts |
Added 'private-key' to keys union type |
src/utils/offlineSigner.ts |
CybPrivateKeySigner class + getOfflineSignerFromPrivateKey() |
src/pages/Keys/ActionBar/ConnectWalletModal/ConnectWalletModal.tsx |
UI tabs, private key input field |
src/pages/Keys/ActionBar/actionBarConnect.tsx |
Import routing, encryption, account registration |
src/contexts/signerClient.tsx |
Unlock and auto-switch for private-key accounts |
src/redux/features/pocket.ts |
Deletion cleanup for private-key accounts |
Threat Model
| Threat | Mitigation | Status |
|---|---|---|
| Private key in React state (DevTools exposure) | Stored in useRef, not useState |
FIXED |
| Key leak on unmount | Ref cleared in cleanup effect | FIXED |
| Key leak on app background (iOS screenshot) | Ref + display state cleared on visibilitychange |
FIXED |
| Clipboard leak on paste | navigator.clipboard.writeText('') after paste |
FIXED |
| Pending ref not cleared after success | clearState() zeroes pendingMnemonicRef and pendingImportModeRef |
FIXED |
| Invalid key accepted | 3-layer validation: regex (64 hex), fromHex(), DirectSecp256k1Wallet.fromKey() |
SECURE |
| Error messages leak key material | Generic errors only ("Invalid private key") | SECURE |
| Encryption at rest | Same AES-256-GCM + PBKDF2 (1M iterations) as mnemonic | SECURE |
| Password brute force | 8+ chars, 3/4 character classes if < 12 chars | ADEQUATE |
| Key type disclosure in Redux | keys: 'private-key' visible in Redux — accepted (no key material) |
ACCEPTED |
| Tauri device key in localStorage | Pre-existing design trade-off, not changed | ACCEPTED |
| Auto-lock timer disabled | Pre-existing, not changed (MNEMONIC_AUTO_CLEAR_MS unused) |
ACCEPTED |
Encryption Format
Private key hex string is encrypted with identical format as mnemonic:
version(1 byte) + salt(16 bytes) + iv(12 bytes) + AES-GCM-256(plaintext)
→ base64 encoded → localStorage['cyb:mnemonic:{address}']
The decryptMnemonic() function is format-agnostic — it returns any stored plaintext. The account type (keys field in Redux) determines whether to call getOfflineSignerFromMnemonic() or getOfflineSignerFromPrivateKey().
CosmJS Validation Chain
fromHex(privkeyHex)— validates hex format, throws on non-hex or odd-lengthDirectSecp256k1Wallet.fromKey(privkey)— validates 32-byte length, validates against secp256k1 curve orderSecp256k1Wallet.fromKey(privkey)— same validation for Amino signer
All three layers throw before any storage occurs.
signArbitrary (ADR-036)
CybPrivateKeySigner.signArbitrary() composes Secp256k1Wallet (Amino) for ADR-036 signing — same MsgSignData format as CybOfflineSigner. The hasSignArbitrary() type guard works via duck-typing (typeof signer.signArbitrary === 'function').
Findings Fixed Before Commit
- CRITICAL: Moved
privateKeyHexfromuseStatetouseRef— prevents React DevTools exposure - CRITICAL: Added ref cleanup on unmount
- HIGH: Added ref + display cleanup on
visibilitychange(background) - HIGH: Added clipboard clearing on private key paste
- HIGH: Added
pendingImportModeRefreset inclearState()
Accepted Risks
- Device key for Tauri auto-unlock stored in localStorage (pre-existing, not introduced by this change)
- No auto-lock timer (pre-existing design decision)
- Account type visible in Redux state (information disclosure only, no key material)