|
| 1 | +--- |
| 2 | +tags: cyber, cyb, article |
| 3 | +crystal-type: entity |
| 4 | +crystal-domain: cyber |
| 5 | +--- |
| 6 | +# security audit: private key import |
| 7 | + |
| 8 | +date: 2026-05-12 |
| 9 | +status: passed — 0 critical, 0 high, 0 medium, 1 low (optional) |
| 10 | +scope: addition of raw [[secp256k1]] [[private key]] import to [[cyb]] wallet |
| 11 | + |
| 12 | +## changes audited |
| 13 | + |
| 14 | +| file | change | |
| 15 | +|---|---| |
| 16 | +| `defaultAccount.d.ts` | added `private-key` to keys union type | |
| 17 | +| `offlineSigner.ts` | `CybPrivateKeySigner` class, `getOfflineSignerFromPrivateKey()` | |
| 18 | +| `ConnectWalletModal.tsx` | UI tabs, private key input field | |
| 19 | +| `actionBarConnect.tsx` | import routing, encryption, account registration | |
| 20 | +| `signerClient.tsx` | unlock and auto-switch for private-key accounts | |
| 21 | +| `pocket.ts` | deletion cleanup for private-key accounts | |
| 22 | + |
| 23 | +## threat model |
| 24 | + |
| 25 | +| threat | mitigation | status | |
| 26 | +|---|---|---| |
| 27 | +| private key in React state | stored in `useRef` | fixed | |
| 28 | +| key leak on unmount | ref cleared in cleanup effect | fixed | |
| 29 | +| key leak on background | ref + display cleared on `visibilitychange` | fixed | |
| 30 | +| clipboard leak on paste | `navigator.clipboard.writeText('')` after paste | fixed | |
| 31 | +| pending ref retained after success | `clearState()` zeroes all refs | fixed | |
| 32 | +| invalid key accepted | 3-layer validation: regex, `fromHex()`, `fromKey()` | secure | |
| 33 | +| error messages expose key material | generic errors only | secure | |
| 34 | +| encryption at rest | [[AES-256-GCM]] + [[PBKDF2]] (1M iterations), same as [[mnemonic]] | secure | |
| 35 | +| password brute force | 8+ chars, 3/4 character classes if under 12 chars | adequate | |
| 36 | +| key type disclosure in Redux | `keys: 'private-key'` visible, contains no key material | accepted | |
| 37 | +| Tauri device key in localStorage | pre-existing trade-off | accepted | |
| 38 | +| auto-lock timer disabled | pre-existing design decision | accepted | |
| 39 | + |
| 40 | +## encryption format |
| 41 | + |
| 42 | +private key hex encrypted with identical format as mnemonic: |
| 43 | + |
| 44 | +``` |
| 45 | +version(1 byte) + salt(16 bytes) + iv(12 bytes) + AES-GCM-256(plaintext) |
| 46 | +→ base64 → localStorage['cyb:mnemonic:{address}'] |
| 47 | +``` |
| 48 | + |
| 49 | +`decryptMnemonic()` returns any stored plaintext. the account type (`keys` field in Redux) determines whether to call `getOfflineSignerFromMnemonic()` or `getOfflineSignerFromPrivateKey()`. |
| 50 | + |
| 51 | +## [[CosmJS]] validation chain |
| 52 | + |
| 53 | +1. `fromHex(privkeyHex)` — validates hex format, throws on non-hex or odd-length |
| 54 | +2. `DirectSecp256k1Wallet.fromKey(privkey)` — validates 32-byte length, validates against [[secp256k1]] curve order |
| 55 | +3. `Secp256k1Wallet.fromKey(privkey)` — same validation for Amino signer |
| 56 | + |
| 57 | +all three layers throw before any storage occurs. |
| 58 | + |
| 59 | +## signArbitrary (ADR-036) |
| 60 | + |
| 61 | +`CybPrivateKeySigner.signArbitrary()` composes `Secp256k1Wallet` (Amino) for ADR-036 signing — same MsgSignData format as `CybOfflineSigner`. `hasSignArbitrary()` type guard works via duck-typing. |
| 62 | + |
| 63 | +## findings fixed before commit |
| 64 | + |
| 65 | +1. moved `privateKeyHex` from `useState` to `useRef` — prevents React DevTools exposure |
| 66 | +2. added ref cleanup on unmount |
| 67 | +3. added ref + display cleanup on `visibilitychange` (background) |
| 68 | +4. added clipboard clearing on private key paste |
| 69 | +5. added `pendingImportModeRef` reset in `clearState()` |
| 70 | + |
| 71 | +## accepted risks |
| 72 | + |
| 73 | +- device key for Tauri auto-unlock stored in localStorage (pre-existing) |
| 74 | +- auto-lock timer disabled (pre-existing design decision) |
| 75 | +- account type visible in Redux state (information disclosure only) |
0 commit comments