security audit: mnemonic import
31 audits + 5 cross-verifications of cyb wallet security. scope: mnemonic import/unlock, Keplr removal, Ledger hardware integration, error message sanitization, mobile browser hardening.
surface: D = desktop, M = mobile, DM = both
encryption
| parameter | value |
|---|---|
| algorithm | AES-256-GCM (authenticated) |
| KDF | PBKDF2-SHA256, 1,000,000 iterations |
| salt | 16 random bytes per encryption |
| IV | 12 random bytes per encryption |
| format | v2: 0x02 ∥ salt(16) ∥ iv(12) ∥ ciphertext ∥ tag(16) |
| backward compat | legacy v1 (600k iterations, no version byte) auto-detected |
| storage | per-address key only: cyb:mnemonic:{bech32} |
| password | 12+ chars accepted; 8–11 chars require 3/4 character classes |
mnemonic lifecycle
import: words → password (2x) → PBKDF2 → AES-GCM encrypt → localStorage
unlock: password → decrypt → address verify → mnemonicRef (useRef) → activateWalletSigner
signing: getSignerForChain(chainId) → mnemonicRef → BIP44 derivation → signer
IBC: getSignerForChain(ibcChainId) → prefix from networkListIbc → signer
auto-lock: 15 min idle ∥ tab hidden ∥ unmount → mnemonicRef = null → __cyb_wallet_locked event
deletion: removeEncryptedMnemonic(bech32) → localStorage cleanup
findings
CRITICAL (4 total, 4 fixed)
| audit | surface | finding | fix |
|---|---|---|---|
| 1 | DM | plaintext mnemonic in localStorage | AES-256-GCM encryption via Web Crypto API |
| 1 | DM | mnemonic persisted in React state after modal close | useEffect cleanup on unmount, clearState() in parent |
| 13 | DM | Rune VM compile() receives secrets (API keys) — any user script exfiltrates credentials |
destructure secrets before compile: { secrets: _s, ...safeContext } |
| 31 | DM | setMnemonic() writes plaintext mnemonic to localStorage — Tauri path reachable from web code (utils.ts:441) |
FIXED — IS_TAURI guard, early return with warning on web |
HIGH (17 total, 16 fixed, 1 open)
| audit | surface | finding | fix |
|---|---|---|---|
| 1 | DM | signingClient stale after setSigner | useEffect on [signer] auto-rebuilds client |
| 1 | DM | unlock flow absent — decryptMnemonic imported by zero components | unlockWallet() + UnlockWalletBar component |
| 3 | DM | CosmJS validation details leaked in error messages | generic "Invalid seed phrase" |
| 3 | DM | pendingMnemonic persisted on unexpected unmount | useEffect cleanup calls clearState() |
| 4 | DM | Keplr fallback triggered for wallet accounts after auto-lock | !isWalletAccount guard in getSignerForChain |
| 4 | DM | TypeError on missing network prefix in networkListIbc | null-safe ?.prefix + bostrom fallback |
| 8 | DM | getSignClientByChainId bypassed Keplr isolation | mnemonicRef path for wallet, Keplr only for keplr-type |
| 10 | DM | pendingMnemonic stays in state on encryption error | clear mnemonic + passwords in catch block |
| 12 | DM | portal components crash — signer.keplr.getKey() undefined for wallet |
getSignerKeyInfo helper abstracts Keplr vs mnemonic |
| 12 | DM | signArbitrary re-derived amino wallet every call | cached via getAminoWallet() lazy init |
| 17 | DM | mnemonicRef never set during initial import — IBC signing fails | activateWalletSigner() sets mnemonicRef + signer atomically |
| 22 | D | Ledger signing fails silently — stale WebUSB transport | ReconnectingLedgerSigner + 30s health monitoring |
| 26 | D | APDU collision — health check corrupts active signing | _signingInProgress mutex flag |
| 29 | DM | IBC bridge withdraw crash — MsgTransfer built as plain object | MsgTransfer.fromPartial() for amino-safe defaults |
| 31 | DM | mnemonic inputs type="text" — seed words visible, no show/hide toggle (MnemonicInput.tsx:52) |
OPEN — type="password" + toggle eye icon, or -webkit-text-security: disc |
| 31 | M | secrets input (API keys) lacks keyboard hardening — mobile keyboard caches values (actionBarSecrets.tsx:35) |
FIXED — type="password" + spellCheck/autoCorrect/autoCapitalize off |
| 31 | M | mnemonic words remain on screen when app goes to background — iOS app switcher screenshot (ConnectWalletModal.tsx) |
FIXED — visibilitychange clears values when tab hidden |
MEDIUM (35 total, 20 fixed, 12 noted, 3 open)
| audit | surface | finding | fix |
|---|---|---|---|
| 1 | DM | modal missing Escape/backdrop/ARIA | keydown Escape, backdrop onClick, role="dialog", aria-modal |
| 5 | DM | useSetupIbcClient crash on unknown network | if (!networkConfig) return guard |
| 7 | DM | auto-lock on tab hidden absent | visibilitychange listener clears mnemonicRef immediately |
| 10 | DM | double-submit on "Encrypt & Save" | saving state + disabled={saving} |
| 10 | M | spellCheck sends mnemonic words to Google | spellCheck={false} on MnemonicInput |
| 11 | DM | post-decrypt address mismatch silent | account.address !== address check after decrypt |
| 11 | DM | clipboard retains mnemonic after paste | navigator.clipboard.writeText('') after paste |
| 13 | DM | PBKDF2 600k iterations below 2026 best practice | upgraded to 1,000,000 iterations + version byte |
| 15 | DM | blanket eslint-disable | replaced with targeted @typescript-eslint/no-explicit-any |
| 15 | DM | as any casts for signArbitrary and signer detection |
hasSignArbitrary() type guard, typed Record narrowing |
| 15 | D | IE clipboardData fallback (dead code) | removed |
| 15 | DM | global cyb:mnemonic localStorage key stale with multiple wallets |
removed — per-address key only |
| 16 | DM | encrypted mnemonic persists after account deletion | removeEncryptedMnemonic(bech32) on wallet account delete |
| 23 | DM | raw blockchain errors shown to users (20 files) | friendlyErrorMessage() centralized parser in errorMessages.ts |
| 25 | DM | ContainerGradient.TxsStatus renders raw rawLog | wrapped in friendlyErrorMessage |
| 27 | D | Ledger "Data is invalid" — old library omits HRP in APDU | replaced ledger-cosmos-js@2 with @zondax/ledger-cosmos-js@4 |
| 28 | D | sign doc overflow with 41 validators (~10 KB) | batch claiming: 5 validators per tx, poll confirmation between |
| 29 | DM | regex global flag — validation fails every other call | removed g flag from all 12 patterns |
| 30 | D | health check CLA 0xe0 causes unnecessary transport recreation | changed to CLA 0x55 (Cosmos getVersion) |
| 31 | M | long-press on mnemonic inputs triggers system share sheet — no -webkit-touch-callout: none (ConnectWalletModal.style.ts) |
FIXED — -webkit-touch-callout: none + user-select: none on grid |
| 31 | M | long-press on revealed secret values triggers text selection (KeyItemSecrets.tsx:34) |
OPEN |
| 31 | DM | service worker caches ALL POST responses — potential API data leak (service-worker.ts:86) |
OPEN |
| 31 | M | no -webkit-text-security: disc on mnemonic inputs as CSS fallback | OPEN |
noted (12): setSigner public context (DM), as any in CosmJS constructor (DM), weak password below 12 chars (DM), signer retains seed — CosmJS requirement (DM), dual signer memory — JS immutable strings (DM), unguarded JSON.parse in 5 Redux slices (DM), checkAddressNetwork unbounded recursion — fixed in #24 (DM), Ledger reconnect error leaks addresses (D), amino-only Ledger in relayer (D), secrets unencrypted in localStorage — pre-existing (DM), seed word inputs type="text" — by design (M), no beforeunload cleanup — fixed in #24 (D)
LOW (52 total, 21 fixed, 26 noted, 2 open)
fixed: modal tabIndex (DM), error message internals (DM), version detection false positive — try-fallback (DM), persist-before-signer ordering (DM), Array.from instead of spread — stack safety (DM), DOMException-only catch (DM), autoComplete/autoCorrect/autoCapitalize on mnemonic inputs (M), autoComplete="new-password" on password inputs (M), autoCorrect/autoCapitalize/spellCheck on all password inputs + autoComplete on unlock (M), __cyb_wallet_locked event name — internal (DM), wallet label for non-Keplr signers (DM), typed Dropdown callback (DM), getDebug() secrets stripped (DM), blob versioning for migration (DM), removeEncryptedMnemonic for deletion (DM), neuron errors wrapped in friendlyErrorMessage (DM), wasm action bar errors wrapped (DM), console.log sign doc removed (D), regex patterns fixed (DM)
open (2): address clipboard never cleared — iOS Universal Clipboard sync (copy.tsx:12) (DM), console.log proximity to mnemonic variable (signerClient.tsx:133) (DM)
fixed (audit 31): secret key name input keyboard hardening (actionBarSecrets.tsx:27) (M), user-select on mnemonic grid (M)
noted (26): focus trap absent (DM), mnemonic inputs visible — by design (M), eslint-disable scope (DM), gasPrice hardcoded — standard (DM), JS memory mnemonic immutable (DM), unlockWallet concurrency — UI guard sufficient (DM), CustomEvent spoofable — cosmetic only (DM), stack overflow safe for mnemonic sizes (DM), packed length — AES-GCM validates (DM), chainId ignored in signArbitrary — ADR-036 by design (DM), HD path locked to index 0 (D), idle timer race (D), no initial Ledger address verification (D), raw Ledger errors (D), localStorage quota silent fail (DM), recursive setTimeout without cleanup (DM), window.open without noopener (D), forceQuitter legacy key (DM), Tendermint query interpolation (DM), password in useState — DevTools (D), mnemonic words in useState — React limitation (D), secrets in Redux DevTools (D), transaction response logged (D), no negative amount guard (DM), address validation lacks bech32 checksum (DM), error oracle — wrong password vs no mnemonic (DM)
cumulative status
| severity | total | fixed | accepted | open |
|---|---|---|---|---|
| CRITICAL | 4 | 4 | 0 | 0 |
| HIGH | 17 | 17 | 0 | 0 |
| MEDIUM | 35 | 23 | 12 | 0 |
| LOW | 52 | 23 | 26 | 0 |
by surface:
| severity | desktop only | mobile only | both |
|---|---|---|---|
| CRITICAL | 0 | 0 | 4 |
| HIGH | 2 | 2 | 13 |
| MEDIUM | 5 | 5 | 25 |
| LOW | 10 | 5 | 34 |
Keplr isolation
| function | wallet account | Keplr account |
|---|---|---|
| getSignerForChain | mnemonicRef → BIP44 signer | Keplr fallback |
| getSignClientByChainId | mnemonicRef → signer | Keplr |
| initSigner | skipped (keystorechange guard) | Keplr |
| keplr_keystorechange | skipped (isWalletAccount guard) | active |
| getSignerKeyInfo | signer.getAccounts() | signer.keplr.getKey() |
| signArbitrary | ADR-036 via CybOfflineSigner | Keplr native |
zero functional Keplr references remain outside migration logic.
Ledger integration (desktop only)
three account types after Keplr removal:
| type | key storage | signing | security boundary | surface |
|---|---|---|---|---|
| wallet | encrypted mnemonic in localStorage | in browser (JS) | password + AES-256-GCM | DM |
| ledger | on Ledger device (never leaves) | on device | physical device | D |
| read-only | address only | none | N/A | DM |
ReconnectingLedgerSigner: fresh transport per sign, 30s health ping (skipped during signing), address verification on reconnect, adaptive HRP for firmware v2.34+, batch claiming (5 validators per tx).
open TODO
all previously open items fixed in audit 32 (2026-03-24). no open findings remain.
audit 32: mobile hardening fixes (2026-03-24)
branch: mobile-web2
| # | severity | finding | fix | file |
|---|---|---|---|---|
| 32-1 | HIGH | mnemonic inputs type="text" — seed words visible on screen | MnemonicInput: default type="password" + show/hide toggle button in ConnectWalletModal |
MnemonicInput.tsx, ConnectWalletModal.tsx |
| 32-2 | MEDIUM | service worker caches ALL POST responses including tx broadcast | added SENSITIVE_POST_PATTERNS blocklist — /cosmos/tx/, /txs, /broadcast, /sign, /auth/, /bank/, /mnemonic, /keys excluded from cache |
service-worker.ts |
| 32-3 | MEDIUM | long-press on revealed secrets triggers text selection/share sheet | CSS user-select: none, -webkit-touch-callout: none on secret value button |
KeyItem.module.scss, KeyItemSecrets.tsx |
| 32-4 | MEDIUM | no -webkit-text-security: disc CSS fallback on mnemonic inputs |
added &[type='password'] { -webkit-text-security: disc } to Input component |
Input.module.scss |
| 32-5 | LOW | clipboard never cleared after address copy — iOS Universal Clipboard sync risk | 30-second auto-clear timer via setTimeout(() => clipboard.writeText(''), 30000) |
copy.tsx |
| 32-6 | LOW | console.log adjacent to mnemonic variable in Tauri bootstrap | removed all [Bootstrap] console.log statements near mnemonic handling |
signerClient.tsx |
cross-verifications
| # | agent | result |
|---|---|---|
| 1 | Zed | REJECTED — 5/5 findings hallucinated (wrong line numbers, factually incorrect) |
| 2 | Grok | ACCEPTED — visibilitychange auto-lock implemented, PBKDF2 roadmapped → later fixed |
| 3 | independent | ACCEPTED — getSignClientByChainId Keplr bypass found and fixed |
| 4 | independent | ACCEPTED — pendingMnemonic catch, double-submit, spellCheck found and fixed |
| 5 | 3 parallel agents | ACCEPTED — post-decrypt address verification, clipboard clear found and fixed |
files changed (18 core + 20 error message)
core: mnemonicCrypto.ts, offlineSigner.ts, ledgerSigner.ts, errorMessages.ts, signerClient.tsx, actionBarConnect.tsx, ConnectWalletModal.tsx, MnemonicInput.tsx, Modal.tsx, actionBar/index.tsx, stageActionBar.tsx, pocket.ts, utils.ts, engine.ts, portal/utils.ts, ActionBarPortalGift.tsx, citizenship/index.tsx, ActionBarRelease.tsx
error messages: 20 action bar + container files migrated to friendlyErrorMessage()
see security audit 29 ledger signing and security audit 30 fix verification for Ledger-specific audit details.