Skip to content

[audit] Seed & key material: privacy + lifecycle at rest #612

@joshuakrueger-dfx

Description

@joshuakrueger-dfx

Context

Findings from a read-only source-level audit of develop (2026-06-01). Note: develop HEAD is fully green on dfx01 (Analyze & Test, Coverage Floor, Visual Regression, BitBox quirks audit) — these are issues the automated gates do not catch. flutter was not run in the audit environment; items are code-evidenced with file:line and marked by confidence. Bundled because they all concern seed/key material at rest and lifecycle. Split into PRs as appropriate; track via checklist.

Suggested labels: bug.

Findings

S1 — Seed phrase leaks to OS app-switcher snapshot / screen recording (no FLAG_SECURE) — HIGH

  • Fix
  • android/app/src/main/kotlin/.../MainActivity.kt sets no FLAG_SECURE; seed screens rely only on the no_screenshot package (lib/screens/create_wallet/create_wallet_view.dart:24, lib/screens/settings_seed/settings_seed_view.dart:21). grep FLAG_SECURE android/ → none.
  • Impact: no_screenshot blocks manual screenshots but not the Android recents/app-switcher thumbnail or OS/3rd-party screen recording. With the seed revealed, the 12 words can be cached in recents or captured by recording.
  • Confidence: HIGH. Test-gap: golden tests render widgets only.

S2 — "Delete Wallet" leaves the encrypted seed AND its decryption key on the device — HIGH

  • Fix
  • lib/packages/storage/wallet_storage.dart:28-29 deleteWallet deletes only from walletAccountInfos, never walletInfos (holds the AES-GCM-encrypted mnemonic); _mnemonicEncryptionKey in flutter_secure_storage is never removed. Path: settings → home_bloc._onDeleteCurrentWalletdeleteCurrentWalletwallet_repositorydeleteWallet.
  • Impact: after the user taps "Delete Wallet", the full mnemonic remains recoverable (ciphertext + key both persist; only the current-wallet pointer is cleared, masking the residue). Resale/right-to-erasure concern.
  • Confidence: HIGH (storage layer has no primitive to delete the seed row). Note: account-only delete is deliberate for onboarding-regenerate, but it is also the only deletion primitive used by user-facing delete. Please confirm the intended delete chain.

S3 — VerifySeedPage has no screenshot protection at all — MEDIUM

  • Fix
  • lib/screens/verify_seed/verify_seed_page.dart never calls screenshotOff() (unlike the sibling seed screens), yet it handles the user's actual seed words (debug pre-fills them: verify_seed_cubit.dart:48-49).

S4 — Auto-lock + mnemonic-drop keyed solely on AppLifecycleState.hidden — MEDIUM

  • Fix
  • lib/setup/lifecycle_initializer.dart:54-62: only _onHidden calls WalletService.lockCurrentWallet() and PinAuthCubit.onAppHidden(); neither inactive nor paused arm the lock. If hidden is skipped/coalesced on a platform transition, on resume _lastBackgroundTime == null → the 5-min force-lock never fires and the in-memory mnemonic is not dropped.

S5 — flutter_secure_storage constructed without hardened options — MEDIUM

  • Fix
  • lib/packages/storage/secure_storage.dart:23 const FlutterSecureStorage() (Android: no encryptedSharedPreferences: true; iOS: no KeychainAccessibility). This store holds the SQLCipher DB key, the mnemonic AES-GCM key, the PIN hash + lockout. These keys are not PIN-derived, so extracting them bypasses the PIN.

S6 — decryptSeed throws uncaught on malformed/truncated ciphertext — LOW

  • Fix
  • lib/packages/storage/secure_storage.dart:176-183: encoded.indexOf(':') then substring(0, colonIndex)RangeError when no colon; GCM tag-mismatch throws uncaught. A corrupted walletInfos.seed row crashes the unlock path rather than surfacing a controlled error. (AES-GCM rejecting tampering is correct; this is robustness only.)

S7 — Seed-quiz word selection uses non-secure Random() — LOW

  • Fix
  • lib/screens/verify_seed/cubit/verify_seed_cubit.dart:32 Random().nextInt(...). Not key material (mnemonic itself uses bip39 secure RNG); only predicts which 4 of 12 words are challenged. Prefer Random.secure() for hygiene.

Checked & clean: mnemonic generation (bip39 secure RNG) + restore checksum; PIN PBKDF2-250k with persisted lockout (survives restart, biometric can't bypass); seed double-encrypted (AES-GCM in SQLCipher).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions