This covers the app password, lock/unlock, and what protects what. Read
this before changing anything in src/wdk/passwordVault.ts,
src/wdk/hooks/AutoLockOnBackground.tsx, or src/wdk/hooks/WdkSessionGate.tsx
— each of these has a non-obvious reason for being shaped the way it is.
The wallet seed/keys are stored via WDK's own secure-storage layer
(wdk-react-native-secure-storage), backed by the OS's real secure storage
— iOS Keychain / Android Keystore — with biometric/passcode gating. This
happens automatically on unlock()/getMnemonic(). This app does not
implement its own seed encryption — it relies on WDK for that layer
entirely.
On top of WDK's own protection, this app adds a required app password: the wallet will not unlock without it, even if the device's biometrics would otherwise satisfy WDK.
src/wdk/passwordVault.ts uses envelope encryption:
- A random 256-bit "vault key" is generated once (
expo-crypto) and stored inexpo-secure-store(OS Keychain/Keystore). - The actual password is encrypted with that vault key using
@tetherto/wdk-utils(AES-256-GCM), and the resulting payload is stored — meaningless without the vault key. - To recover the password (
getAppPassword()): read the vault key, decrypt the payload.
This is a genuine, stated security tradeoff, not an oversight: an earlier version used a one-way verifier (encrypt a fixed known string with the password; a correct guess is the only thing that can decrypt it back to that string) — which made it structurally impossible to ever recover the plaintext password. This version can. The tradeoff was made deliberately, because the cloud-backup feature needs the actual password to encrypt the backup payload without re-prompting the person mid-flow. If you're extending this app and don't need that, consider whether the one-way verifier is a better fit for your use case.
Performance note, also load-bearing for correctness: the vault-key
wrapping step (setAppPassword/getAppPassword) uses deliberately weak
scrypt parameters (VAULT_WRAP_SCRYPT_PARAMS, N: 2^4 vs. the library
default N: 2^16). This is safe specifically because the thing being
encrypted-with is already a cryptographically random 256-bit key, not a
human-memorable secret — there's nothing to "stretch" against brute force
gains. Do not reuse VAULT_WRAP_SCRYPT_PARAMS anywhere that encrypts
directly with the person's real typed password (e.g. the cloud-backup
payload, which uses the library's real default strength deliberately,
since that data leaves the device and a real password genuinely needs
brute-force resistance there).
src/state/passwordSession.ts holds the password in a plain Zustand store,
never persisted to disk in any form. Its entire purpose is letting the
screen immediately after password entry (cloud-backup) reuse it without a
second prompt. It's cleared the instant the wallet locks
(AutoLockOnBackground) and is simply empty again after any cold start.
Three components work together: AutoLockOnBackground (calls lock() on
real backgrounding), WdkSessionGate (watches for a lock and redirects to
/unlock from anywhere in the app), and unlock.tsx (verifies the
password before WDK's own unlock() ever runs — a wrong password never
reaches WDK).
Real bugs found and fixed here, worth knowing before you touch this code:
lock()doesn't produce a distinctLOCKEDstatus — seeWDK_INTEGRATION.md.useWdkSession.tsdisambiguates using the persisted wallets list.WdkSessionGatemust only redirect on a genuineunlocked → lockedtransition, not any arrival atlocked. Wallet creation itself briefly passes through a locked-looking state as a normal part of its own sequence (the wallet is registered in storage a moment before it's unlocked in memory) — reacting to anylockedvalue bounced the user to/unlockmid-onboarding. Requiring the specific previous status to have been'unlocked'fixes this without needing to know why the status changed.- iOS and Android report backgrounding differently. iOS always passes
through an intermediate
inactivestate (active → inactive → background); Android goes directlyactive → background. A check written asprev === 'active' && next === 'background'can never match on iOS, because by the timenextis'background',previs always'inactive'there. Fixed by triggering on reaching'background'regardless of what preceded it. - Some in-app flows cause a real, but false, backgrounding signal.
Google Sign-In's native Android picker launches a separate Activity,
pausing the app's own Activity — indistinguishable from a real
backgrounding via
AppStatealone.src/state/lockSuppression.tsexists specifically for this: calling code (cloud-provider.tsx) sets an explicitsuppress()/release()around flows it knows will trigger this, soAutoLockOnBackgroundskips locking during them. CloudKit's WebView-based sign-in does not need this — it's a React Native<Modal>inside the app's own Activity, so it never causes a realAppStatetransition in the first place.
Deleting and reinstalling this app on iOS does not clear Keychain
entries — that's documented Apple behavior (Keychain is designed to
persist across reinstalls). This means WDK's own wallet registration can
survive a reinstall, causing "A wallet with the ID 'primary' already exists" on a device that looks freshly installed.
Fix in place: useWalletActions.importWallet() catches this specific
error and self-heals: it calls wm.deleteWallet(DEFAULT_WALLET_ID) and
retries once. It does not retry on any other error — only this
specific, identifiable condition — so a genuine unrelated failure still
surfaces normally instead of being silently swallowed.
- Wallet ID is a fixed constant (
'primary') — seeARCHITECTURE.md. Testing repeatedly under the same Apple ID/Google account will overwrite the same cloud-backup slot each time. - The password vault's envelope design (above) trades some security for the cloud-backup convenience — know this before extending it elsewhere.
- No rate-limiting or lockout on repeated wrong-password attempts at the unlock screen.