What changed, and why it matters
This commit fixes two real security gaps in a mobile cryptocurrency wallet. First, when the app was sent to the background and later reopened, it could return to an already-unlocked wallet with the password still in memory, letting anyone with the phone reach the seed phrase. The patch now locks the app on resume. Second, simply viewing the seed/secret keys only showed a warning sheet; now it also requires a fingerprint or face scan first. The commit message frames this as 'unifying lock requests between apps,' but the code comments and changes clearly describe the underlying security problems.
Treat this as a security fix and ensure it is included in the next release. Verify that armAppLockRelock() in the shared wallet-core correctly clears sensitive in-memory state and that the biometric prompt cannot be trivially skipped or bypassed on rooted/jailbroken devices. Consider adding tests for background/resume lifecycle and for the seed-reveal biometric gate.
Security signals we found
Background-resume bypass of app lock with password remaining in memory
Seed phrase / secret spend key exposure without additional authentication
Addition of biometric gating before seed reveal
Addition of re-lock on app backgrounding for mobile
Route observer to prevent duplicate unlock screens
Shared wallet-core helper to keep lock behavior consistent across apps
Evidence from the diff
The patch hardens app-lock behavior and seed-key access. In lib/main.dart, lifecycle handling is changed so that on mobile AppLifecycleState.paused calls a new armAppLockRelock() helper (via wallet_core_glue.dart) to drop the in-memory password and set a _relockPending flag. On AppLifecycleState.resumed, if a re-lock is pending and the current route is not already /unlock, it pushes the unlock route. A _CurrentRouteObserver tracks the top route to avoid stacking duplicate unlock screens. In lib/screens/settings.dart, _showViewSecretKeysDialog() now wraps the secret-keys confirmation sheet with BiometricAuth.authenticate() on Android/iOS, using a new localized reason string ‘Confirm it’s you to view your seed phrase’ (added across English and Portuguese localization files).
Changed components
lib/main.dartlib/screens/settings.dartlib/wallet_core_glue.dartlib/l10n/app_en.arblib/l10n/app_localizations.dartlib/l10n/app_localizations_en.dartlib/l10n/app_localizations_pt.dartlib/l10n/app_pt.arbInspect captured patch +86 / −7
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index 2d35090..b12db5b 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -243,6 +243,7 @@
"settingsFiatApiSettingsLabel": "Price Display Settings",
"settingsLwsViewKeysLabel": "LWS View Keys",
"settingsLwsViewKeysButton": "View",
+ "revealSeedAuthReason": "Confirm it's you to view your seed phrase",
"settingsSecretKeysLabel": "Secret Restore Keys",
"settingsSecretKeysButton": "View",
"settingsViewLwsKeysDialogText": "Only share this information with your light-wallet server. These keys allow the holder to permanently see all transactions related to your wallets. Sharing these with an untrusted person will significantly harm your privacy.",
diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart
index 63aae65..cab14fb 100644
--- a/lib/l10n/app_localizations.dart
+++ b/lib/l10n/app_localizations.dart
@@ -1299,6 +1299,12 @@ abstract class AppLocalizations {
/// **'View'**
String get settingsLwsViewKeysButton;
+ /// No description provided for @revealSeedAuthReason.
+ ///
+ /// In en, this message translates to:
+ /// **'Confirm it\'s you to view your seed phrase'**
+ String get revealSeedAuthReason;
+
/// No description provided for @settingsSecretKeysLabel.
///
/// In en, this message translates to:
diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart
index ad012c5..efec65b 100644
--- a/lib/l10n/app_localizations_en.dart
+++ b/lib/l10n/app_localizations_en.dart
@@ -651,6 +651,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settingsLwsViewKeysButton => 'View';
+ @override
+ String get revealSeedAuthReason => 'Confirm it\'s you to view your seed phrase';
+
@override
String get settingsSecretKeysLabel => 'Secret Restore Keys';
diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart
index d7da9a9..31aa8d9 100644
--- a/lib/l10n/app_localizations_pt.dart
+++ b/lib/l10n/app_localizations_pt.dart
@@ -651,6 +651,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get settingsLwsViewKeysButton => 'Ver';
+ @override
+ String get revealSeedAuthReason => 'Confirme sua identidade para ver a frase seed';
+
@override
String get settingsSecretKeysLabel => 'Chaves Privadas de Restauração';
diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb
index c9161ad..34d0ecb 100644
--- a/lib/l10n/app_pt.arb
+++ b/lib/l10n/app_pt.arb
@@ -243,6 +243,7 @@
"settingsFiatApiSettingsLabel": "Configurações de Exibição de Preços",
"settingsLwsViewKeysLabel": "Chaves de Visualização do LWS",
"settingsLwsViewKeysButton": "Ver",
+ "revealSeedAuthReason": "Confirme sua identidade para ver a frase seed",
"settingsSecretKeysLabel": "Chaves Privadas de Restauração",
"settingsSecretKeysButton": "Ver",
"settingsViewLwsKeysDialogText": "Somente compartilhe estas informações com o seu servidor light-wallet. Essas chaves permitem que o portador veja permanentemente todas as transações relacionadas às suas carteiras. Compartilhá-las com uma pessoa não confiável prejudicará significativamente sua privacidade.",
diff --git a/lib/main.dart b/lib/main.dart
index 7d5aa70..c846a23 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -147,6 +147,8 @@ class _AppRootState extends State<_AppRoot> with WidgetsBindingObserver {
// Theme.of), so a theme change doesn't dirty cached routes on its own. On an
// actual flip we force an in-place rebuild of the navigator subtree.
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
+ final _CurrentRouteObserver _routeObserver = _CurrentRouteObserver();
+ bool _relockPending = false;
Brightness? _lastBrightness;
static void _markSubtreeDirty(Element element) {
@@ -205,16 +207,37 @@ class _AppRootState extends State<_AppRoot> with WidgetsBindingObserver {
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
- // Mobile only: leaving the app marks everything on screen as seen so a
- // background isolate won't re-notify a tx the user just watched arrive.
- // Desktop has no background isolate — and doing this would pre-empt its
- // foreground announce. Marks only synced history (hash-based), so an
- // unsynced receipt is still announced later.
- if (state == AppLifecycleState.paused && isMobile) {
+ if (!isMobile) return;
+
+ if (state == AppLifecycleState.paused) {
+ // App Lock has to cover backgrounding, not just a cold start. Without
+ // this, resuming walked straight back into an unlocked wallet with the
+ // password still in memory, and the seed was reachable from there.
+ unawaited(_maybeArmRelock());
+
+ // Leaving the app marks everything on screen as seen so a background
+ // isolate won't re-notify a tx the user just watched arrive. Desktop has
+ // no background isolate — and doing this would pre-empt its foreground
+ // announce. Marks only synced history (hash-based), so an unsynced
+ // receipt is still announced later.
unawaited(appWalletOf(context, listen: false).notifyNewIncomingTxs(announce: false));
+ } else if (state == AppLifecycleState.resumed && _relockPending) {
+ _relockPending = false;
+ // Pushed ON TOP of the current stack rather than replacing it, so
+ // unlocking pops straight back to the screen the user left. Skipped when
+ // one is already showing, which would stack duplicates.
+ if (_routeObserver.currentName != '/unlock') {
+ _navigatorKey.currentState?.pushNamed('/unlock');
+ }
}
}
+ /// On background: with App Lock on and a wallet present, drop the in-memory
+ /// password and arm a re-lock so the next resume returns to the lock screen.
+ Future<void> _maybeArmRelock() async {
+ _relockPending = await armAppLockRelock(context);
+ }
+
// Desktop has no background isolate to announce incoming txs, so the
// foreground announces when the wallet's history grows. notifyNewIncomingTxs
// is the decider (hash-based, net-receipt only, respects the notifications
@@ -284,6 +307,7 @@ class _AppRootState extends State<_AppRoot> with WidgetsBindingObserver {
return MaterialApp(
navigatorKey: _navigatorKey,
+ navigatorObservers: [_routeObserver],
title: 'Skylight Monero Wallet',
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
@@ -348,3 +372,21 @@ class _NoTransitionPageRoute<T> extends MaterialPageRoute<T> {
@override
Duration get reverseTransitionDuration => Duration.zero;
}
+
+/// Tracks the name of the route currently on top, so the re-lock does not stack
+/// a second `/unlock` on one that is already showing.
+class _CurrentRouteObserver extends NavigatorObserver {
+ String? currentName;
+
+ @override
+ void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) =>
+ currentName = route.settings.name;
+
+ @override
+ void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) =>
+ currentName = previousRoute?.settings.name;
+
+ @override
+ void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) =>
+ currentName = newRoute?.settings.name;
+}
diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart
index 7355ce5..1dcd88d 100644
--- a/lib/screens/settings.dart
+++ b/lib/screens/settings.dart
@@ -197,8 +197,24 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
- void _showViewSecretKeysDialog() {
+ /// Gate the seed behind a device auth even though the app is already
+ /// unlocked. Reaching this screen hands over the seed and secret spend key,
+ /// which is total, irreversible control of the funds; the warning sheet alone
+ /// stops nobody holding the phone.
+ Future<void> _showViewSecretKeysDialog() async {
final i18n = AppLocalizations.of(context)!;
+
+ if (Platform.isAndroid || Platform.isIOS) {
+ final result = await BiometricAuth.authenticate(reason: i18n.revealSeedAuthReason);
+ if (result != BiometricAuthResult.authenticated) {
+ if (mounted) {
+ showBrandToast(context, i18n.settingsAppLockUnableToAuthError);
+ }
+ return;
+ }
+ if (!mounted) return;
+ }
+
showConfirmSheet(
context: context,
icon: Icons.warning_amber_rounded,
diff --git a/lib/wallet_core_glue.dart b/lib/wallet_core_glue.dart
index f1516ba..8452dce 100644
--- a/lib/wallet_core_glue.dart
+++ b/lib/wallet_core_glue.dart
@@ -205,6 +205,13 @@ Future<int> commitGeneratedWallet(
return manager.getWallet('XMR')!.getRestoreHeight();
}
+/// Arms the app-lock re-lock when the app goes to the background; see
+/// [WalletManager.armAppLockRelock], which both apps share so the behaviour
+/// cannot drift. Wrapped here only because Skylight reaches wallet-core through
+/// this layer rather than from screens.
+Future<bool> armAppLockRelock(BuildContext context) =>
+ Provider.of<WalletManager>(context, listen: false).armAppLockRelock();
+
/// Opens an already-existing wallet (used by the welcome safety-net). Returns
/// false when there is none. Mobile only — desktop unlocks with a password.
Future<bool> openExistingWallet(BuildContext context) async {
Why this scored 74/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.