What changed, and why it matters
This commit adds Face ID and Touch ID support to unlock the Skylight Wallet app on iOS. It is a feature addition, not a security fix. There is no evidence in the commit of a vulnerability, bug, or security-relevant change beyond normal biometric authentication integration.
No security action required. As part of normal review, verify that local_auth is configured with strong security options (e.g., biometricOnly/stickyAuth as appropriate) and that fallback to device passcode aligns with the wallet's threat model.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The patch wires the existing local_auth Flutter package into the unlock screen, adds iOS Info.plist usage description NSFaceIDUsageDescription, and adds localized labels for Face ID/Touch ID. The unlock flow already existed; this commit only exposes a biometric button label and resolves the correct iOS biometric type. No cryptographic, authentication-bypass, or permission changes are visible in the diff.
Changed components
lib/screens/unlock.dartios/Runner/Info.plistlocalization filesInspect captured patch +58 / −1
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 882a2f3..2b50fb8 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -82,5 +82,7 @@
</array>
<key>NSCameraUsageDescription</key>
<string>We need access to your camera to scan QR codes for connection and wallet addresses.</string>
+ <key>NSFaceIDUsageDescription</key>
+ <string>We use Face ID to unlock your wallet without typing your password.</string>
</dict>
</plist>
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index 127f9d5..2d35090 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -157,6 +157,8 @@
"unlockButton": "Unlock",
"unlockReason": "Unlock Wallet",
"unlockUnableToAuthError": "Unable to authenticate.",
+ "unlockWithFaceId": "Unlock with Face ID",
+ "unlockWithTouchId": "Unlock with Touch ID",
"unlockTitle": "Unlock Wallet",
"unlockDescription": "Enter your wallet password to unlock",
"unlockPasswordLabel": "Password",
diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart
index 0852711..63aae65 100644
--- a/lib/l10n/app_localizations.dart
+++ b/lib/l10n/app_localizations.dart
@@ -867,6 +867,18 @@ abstract class AppLocalizations {
/// **'Unable to authenticate.'**
String get unlockUnableToAuthError;
+ /// No description provided for @unlockWithFaceId.
+ ///
+ /// In en, this message translates to:
+ /// **'Unlock with Face ID'**
+ String get unlockWithFaceId;
+
+ /// No description provided for @unlockWithTouchId.
+ ///
+ /// In en, this message translates to:
+ /// **'Unlock with Touch ID'**
+ String get unlockWithTouchId;
+
/// No description provided for @unlockTitle.
///
/// In en, this message translates to:
diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart
index 0a3a522..ad012c5 100644
--- a/lib/l10n/app_localizations_en.dart
+++ b/lib/l10n/app_localizations_en.dart
@@ -422,6 +422,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get unlockUnableToAuthError => 'Unable to authenticate.';
+ @override
+ String get unlockWithFaceId => 'Unlock with Face ID';
+
+ @override
+ String get unlockWithTouchId => 'Unlock with Touch ID';
+
@override
String get unlockTitle => 'Unlock Wallet';
diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart
index 5502e79..d7da9a9 100644
--- a/lib/l10n/app_localizations_pt.dart
+++ b/lib/l10n/app_localizations_pt.dart
@@ -422,6 +422,12 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get unlockUnableToAuthError => 'Não foi possível autenticar.';
+ @override
+ String get unlockWithFaceId => 'Desbloquear com Face ID';
+
+ @override
+ String get unlockWithTouchId => 'Desbloquear com Touch ID';
+
@override
String get unlockTitle => 'Desbloquear Carteira';
diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb
index 1a8a4e3..c9161ad 100644
--- a/lib/l10n/app_pt.arb
+++ b/lib/l10n/app_pt.arb
@@ -157,6 +157,8 @@
"unlockButton": "Desbloquear",
"unlockReason": "Desbloquear carteira",
"unlockUnableToAuthError": "Não foi possível autenticar.",
+ "unlockWithFaceId": "Desbloquear com Face ID",
+ "unlockWithTouchId": "Desbloquear com Touch ID",
"unlockTitle": "Desbloquear Carteira",
"unlockDescription": "Digite a senha da sua carteira para desbloquear",
"unlockPasswordLabel": "Senha",
diff --git a/lib/screens/unlock.dart b/lib/screens/unlock.dart
index a37bd05..5998201 100644
--- a/lib/screens/unlock.dart
+++ b/lib/screens/unlock.dart
@@ -1,6 +1,7 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
+import 'package:local_auth/local_auth.dart';
import 'package:skylight_wallet/l10n/app_localizations.dart';
import 'package:skylight_wallet/wallet_core_glue.dart';
@@ -22,11 +23,36 @@ class _UnlockScreenState extends State<UnlockScreen> {
bool _obscure = true;
bool _isLoading = false;
String? _error;
+ String? _biometricLabel; // resolved per device on iOS (Face ID vs Touch ID)
+ bool _started = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
- if (!_isDesktop) _promptUnlock();
+ // Guarded: didChangeDependencies re-fires whenever an inherited dependency
+ // changes -- a locale or theme flip, for instance -- and _promptUnlock
+ // reads Localizations. Without this the biometric sheet is raised a second
+ // time on top of the first, which iOS resolves by cancelling both.
+ if (_started || _isDesktop) return;
+ _started = true;
+ _resolveBiometricLabel();
+ _promptUnlock();
+ }
+
+ /// iOS labels the affordance by the device's biometric (Face ID / Touch ID);
+ /// Android and desktop keep the generic "Unlock".
+ Future<void> _resolveBiometricLabel() async {
+ if (!Platform.isIOS) return;
+ final i18n = AppLocalizations.of(context)!;
+ try {
+ final types = await LocalAuthentication().getAvailableBiometrics();
+ final label = types.contains(BiometricType.face)
+ ? i18n.unlockWithFaceId
+ : i18n.unlockWithTouchId;
+ if (mounted) setState(() => _biometricLabel = label);
+ } catch (_) {
+ // Leave the generic label.
+ }
}
@override
@@ -93,6 +119,7 @@ class _UnlockScreenState extends State<UnlockScreen> {
onToggleObscure: () => setState(() => _obscure = !_obscure),
error: _error,
loading: _isLoading,
+ biometricLabel: _biometricLabel,
onUnlockPassword: _unlockWithPassword,
onUnlockBiometric: _promptUnlock,
);
Why this scored 15/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.