Skip to content

feat(rn_cli_wallet): encrypt MMKV secret storage with keychain-backed key#566

Draft
ignaciosantise wants to merge 10 commits into
mainfrom
fix/encrypt-mnemonic-storage
Draft

feat(rn_cli_wallet): encrypt MMKV secret storage with keychain-backed key#566
ignaciosantise wants to merge 10 commits into
mainfrom
fix/encrypt-mnemonic-storage

Conversation

@ignaciosantise

Copy link
Copy Markdown
Collaborator

Problem

wallets/rn_cli_wallet persisted every chain's mnemonic / private key / secret key as plaintext in MMKV (new MMKV() with no encryptionKey). Anyone with filesystem or backup access to the device could read the seed phrases. The code itself warned about this ([SECURITY] … stored unencrypted).

Fix

Encrypt the whole MMKV store at rest on native using MMKV's built-in encryptionKey, with a random key generated once and kept in the iOS Keychain / Android Keystore via expo-secure-store. The key that protects the secrets never sits in plaintext.

Chosen deliberately over moving secrets into SecureStore per-chain: all secret access is funneled through a single chokepoint (getEncryptionKey), so every chain is protected at once, the 7 copy-pasted-from-web chain utils stay untouched, and it leaves a clean seam for optional biometric gating later.

Changes

  • package.json / yarn.lock — add expo-secure-store@~56.0.4 (matches the SDK-56 unified versioning track).
  • src/utils/secureEncryptionKey.ts (new) — owns the encryption-key lifecycle. Native-only; generates a 16-char key (8 random bytes hex, respecting MMKV's ≤16-byte limit) via the global crypto.getRandomValues, stores it in SecureStore, and memoizes. Web returns undefined (no native secure storage in a browser).
  • src/utils/storage.ts — MMKV is now opened lazily (the key fetch is async; the storage.* methods were already async so signatures are unchanged). On first run it recrypts the existing plaintext store in place so wallets aren't reset on upgrade; afterwards it opens with encryptionKey. Dev-only console.logs report which path ran.
  • 5 chain utils (Solana/Sui/Ton/Tron/Canton) — reworded the now-inaccurate stored unencrypted dev warnings.

Platform behavior

  • iOS / Android: encrypted at rest, key in Keychain/Keystore.
  • Web: unchanged — localStorage shim, unencrypted by design (documented limitation).

How to verify

  1. cd wallets/rn_cli_wallet && yarn install, rebuild, clear Metro/gradle caches (encryption changes the on-disk format).
  2. Console: on launch you'll see MMKV recrypted in place … (first run) then MMKV opened ENCRYPTED … on subsequent launches.
  3. Definitive — grep the on-disk file (MMKV encrypts key names too, so plaintext key names disappear):
    adb exec-out run-as com.walletconnect.web3wallet.rnsample.debug \
      cat files/mmkv/mmkv.default | strings | grep -E 'EIP155_MNEMONIC|SOLANA_'
    Present before the fix, empty after — while the wallet still loads the same address in-app.

Notes

  • No biometric gating in this PR (silent access on startup). The architecture is ready for it: add requireAuthentication: true in secureEncryptionKey.ts + the expo-secure-store config plugin for NSFaceIDUsageDescription.
  • Not yet built/tested on a device in this workspace (node_modules not installed here) — verification above is the intended path.

🤖 Generated with Claude Code

… key

Wallet mnemonics/private keys were persisted as plaintext in MMKV. Encrypt
the MMKV store at rest on native using MMKV's built-in encryptionKey, with a
random key generated once and kept in the iOS Keychain / Android Keystore via
expo-secure-store. All secret access is funneled through a single chokepoint
(getEncryptionKey), so every chain is protected at once and the design is ready
for optional biometric gating later.

- add expo-secure-store dependency
- new src/utils/secureEncryptionKey.ts owns the key lifecycle (native-only;
  web returns undefined and stays best-effort/unencrypted by design)
- storage.ts lazily opens an encrypted MMKV; on first run it recrypts the
  existing plaintext store in place so wallets aren't reset on upgrade
- reword now-inaccurate "stored unencrypted" dev warnings in the chain utils

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
deposit-app-demo Error Error Jul 13, 2026 6:52pm
pos-demo Ready Ready Preview, Comment Jul 13, 2026 6:52pm

Request Review

@claude

claude Bot commented Jul 8, 2026

Copy link
Copy Markdown

Claude finished @ignaciosantise's task in 2m 33s —— View job


Code Review in Progress

  • Gather context and read changed files
  • Review secureEncryptionKey.ts (new file)
  • Review storage.ts (encryption integration)
  • Review chain utils warning updates
  • License compliance subagent (running)
  • Breaking changes subagent (running)
  • Data classification subagent (running)
  • Consolidate and post findings

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces at-rest encryption for wallets/rn_cli_wallet secret persistence by generating an MMKV encryption key once and storing it in OS-backed secure storage, then opening MMKV with that key (with an in-place migration path for existing plaintext installs).

Changes:

  • Add expo-secure-store dependency to persist an MMKV encryption key in iOS Keychain / Android Keystore.
  • Introduce getEncryptionKey() helper and update storage.ts to lazily open MMKV with encryption + recrypt existing plaintext data on first run.
  • Update dev-only security warning strings in several chain wallet utils to reflect the new storage behavior.

Reviewed changes

Copilot reviewed 8 out of 9 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
wallets/rn_cli_wallet/package.json Adds expo-secure-store dependency for key persistence.
wallets/rn_cli_wallet/yarn.lock Lockfile updates for expo-secure-store.
wallets/rn_cli_wallet/src/utils/secureEncryptionKey.ts New helper to generate/store/memoize the MMKV encryption key.
wallets/rn_cli_wallet/src/utils/storage.ts Lazily initializes MMKV with encryption and performs first-run recrypt migration.
wallets/rn_cli_wallet/src/utils/SolanaWalletUtil.ts Updates dev warning text to reflect encrypted MMKV on native.
wallets/rn_cli_wallet/src/utils/SuiWalletUtil.ts Updates dev warning text to reflect encrypted MMKV on native.
wallets/rn_cli_wallet/src/utils/TonWalletUtil.ts Updates dev warning text to reflect encrypted MMKV on native.
wallets/rn_cli_wallet/src/utils/TronWalletUtil.ts Updates dev warning text to reflect encrypted MMKV on native.
wallets/rn_cli_wallet/src/utils/CantonWalletUtil.ts Updates dev warning text to reflect encrypted MMKV on native.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +16 to +20
if (!key) {
if (__DEV__) {
console.log('[storage] MMKV opened UNENCRYPTED (web / no key available)');
}
return new MMKV();
Comment on lines +14 to +21
// MMKV's encryption key cannot exceed 16 bytes, so we generate 8 random bytes
// and hex-encode them into a 16-character key.
function generateKey(): string {
const bytes = crypto.getRandomValues(new Uint8Array(8));
return Array.from(bytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
@ignaciosantise ignaciosantise marked this pull request as draft July 13, 2026 14:37
ignaciosantise and others added 2 commits July 13, 2026 11:53
…tance

Encrypting the DEFAULT MMKV store broke the app: SettingsStore, WalletStore
and LogStore also open `new MMKV()` (default, no key), so once the default
store was encrypted their reads returned garbage and WalletKit init threw
"Value is undefined, expected a String" — the app hung on the splash screen
(flagged by Copilot on the PR).

- storage.ts now uses a dedicated MMKV id ('wallet-secure') with the
  encryptionKey, so wallet secrets + WalletConnect Core data are encrypted in
  isolation and never touch the default store other modules rely on
- drop the recrypt-in-place migration (no longer encrypting the default store)
- bump key entropy to 96 bits (12 random bytes → 16-char base64) within MMKV's
  16-byte key limit, per PR review feedback

Verified on Android (debug + internal): fresh install, cold restart, and
already-encrypted persisted state all initialize WalletKit successfully.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… unavailable

Unsigned iOS builds (our E2E simulator archive is built with
CODE_SIGNING_ALLOWED=NO) have no application-identifier entitlement, so
expo-secure-store's Keychain access throws (`getValueWithKeyAsync ... failed`).
getEncryptionKey didn't catch it, so the rejected promise propagated through
storage into WalletKit init and the app hung on the splash screen (iOS E2E
only; Android Keystore works unsigned).

Wrap the SecureStore calls in try/catch and fall back to an unencrypted store
so the app always initializes. Encryption stays active on real signed builds
(TestFlight/App Store); only unsigned test builds degrade to unencrypted.

Verified by reproducing CI's exact unsigned Release archive locally: the app
now loads the wallet home instead of hanging, and logs the fallback warning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Keychain fallback stopped the deterministic splash hang, but iOS E2E was
still flaky: on the slower CI simulator the unsigned build's SecureStore call
intermittently stalls on the securityd/KeychainMigrator path. Because that call
sits on the app-init critical path (storage awaits the key before opening),
init sometimes didn't finish within Maestro's wait and the app stayed on the
splash screen — failing ~most pay flows with "input-paste-url not found".

Race the SecureStore get/set against a 4s timeout; on timeout, fall back to
unencrypted storage so init never blocks on a slow/hanging Keychain. On real
signed builds SecureStore resolves in milliseconds, so the timeout never fires
and encryption stays active.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This PR encrypts wallet secrets via expo-secure-store, which needs the iOS
Keychain. PR #563 made the Maestro iOS build unsigned (no application-identifier
entitlement), so SecureStore fails/stalls there — leaving the app on the splash
screen and failing the pay suite (the app-level fallback + timeout reduced but
didn't eliminate the flakiness, since the stalled Keychain call sits on the
init critical path).

Re-wire the match Development signing secrets into the e2e-ios job (the exact
6 lines #563 dropped). The Fastfile build_for_simulator lane already gates
signing on MATCH_GIT_URL, so this flips it from unsigned back to signed and the
Keychain becomes available — init behaves like main again and the encrypted
storage path is actually exercised in CI. Trade-off: reintroduces the match
clone + keychain setup on every iOS E2E run (the build speed #563 reclaimed,
which was only needed for the since-removed Universal Link test).

The app-level SecureStore fallback/timeout stays as defensive code; it's inert
on signed builds where the Keychain resolves immediately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants