From d8281b9c93395f15d55091b131bb2747a0a3da8a Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sat, 15 Aug 2026 18:34:02 -0700 Subject: [PATCH] feat(mobile): require device authentication for identity export (#5116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Category:** new-feature **User Impact:** Mobile users must confirm with Face ID, biometrics, or their device passcode before sending their Buzz identity to Desktop. **Problem:** A signed-in phone could send its full identity, including the `nsec`, to a desktop without fresh local verification. **Solution:** Require OS device authentication before opening the identity-recovery scanner, retain that authorization only for the active pairing session and short pairing window, and require fresh authentication again if it expires before the identity payload is sent. Normal app opening, identity import, and community removal remain unchanged. ## Screencasts | Enable Face ID | Use Face ID | | --- | --- | | ![Enabling Face ID during identity import](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5116/enable-face-id.gif) | ![Using Face ID for identity export](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5116/use-face-id.gif) |
File changes **Android and iOS integration** - `mobile/android/app/build.gradle.kts` declares the AppCompat dependency required by the biometric activity theme. - `mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt` uses the activity type required by the system authentication prompt. - `mobile/android/app/src/main/res/values/styles.xml` and `mobile/android/app/src/main/res/values-night/styles.xml` use the compatible launch theme. - `mobile/ios/Podfile.lock` records the native local-authentication dependency. - `mobile/ios/Runner/Info.plist` explains why Buzz requests Face ID access. **Identity policy and pairing flow** - `mobile/lib/shared/security/sensitive_action_authorizer.dart` wraps OS authentication and maps platform errors to stable app-level outcomes. - `mobile/lib/shared/community/community.dart` and `mobile/lib/shared/community/community_storage.dart` persist the sensitive-action policy. - `mobile/lib/features/invites/invite_join_provider.dart` assigns the explicit policy for invite-created communities. - `mobile/lib/features/pairing/pairing_provider.dart` gates export, binds grants to the active community/session, reauthenticates expired grants, and clears grants on every terminal path. - `mobile/lib/features/pairing/pairing_page.dart` lets users choose biometric protection while importing an identity. - `mobile/lib/features/settings/settings_page.dart` wires pairing into settings. - `mobile/lib/features/settings/settings_page/connection_section.dart` authenticates before opening export recovery and bounds the foreground-resume wait. - `mobile/pubspec.yaml` and `mobile/pubspec.lock` add and lock `local_auth`. **Coverage** - `mobile/test/shared/security/sensitive_action_authorizer_test.dart` covers native result mapping, unsupported devices, and single-flight behavior. - `mobile/test/shared/community/community_test.dart` and `mobile/test/shared/community/community_storage_test.dart` cover policy defaults and persistence. - `mobile/test/features/invites/invite_join_provider_test.dart` covers the invite policy. - `mobile/test/features/pairing/pairing_page_test.dart` covers import protection controls. - `mobile/test/features/pairing/pairing_provider_test.dart` covers export/import authorization, stale/reset/concurrent guards, malformed payload cleanup, and no-export failure paths. - `mobile/test/features/settings/connection_section_test.dart` covers the tap gate, lifecycle resume, and timeout behavior.
## Reproduction steps 1. Pair an identity into the mobile app. 2. Open Settings and choose “Send identity to desktop.” 3. Verify Face ID, biometrics, or the device passcode is required before the recovery scanner opens. 4. Cancel device authentication and verify the scanner does not open and no identity transfer begins. 5. Authenticate, scan a Desktop recovery code, confirm the SAS, and verify the identity transfer completes. ## Validation At `be5620f5f10aa6cc16e86a4f01f102f3d9aeef9b`: - `cd mobile && ../bin/flutter analyze` — no issues - `cd mobile && ../bin/flutter test` — 1,368 tests passed - `cd mobile/android && JAVA_HOME=$(/usr/libexec/java_home -v 21) ./gradlew app:assembleDebug` — debug APK assembled successfully --------- Signed-off-by: Taylor Ho --- mobile/android/app/build.gradle.kts | 2 + .../xyz/block/buzz/mobile/MainActivity.kt | 4 +- .../app/src/main/res/values-night/styles.xml | 2 +- .../app/src/main/res/values/styles.xml | 2 +- mobile/ios/Podfile.lock | 7 + mobile/ios/Runner/Info.plist | 2 + .../invites/invite_join_provider.dart | 1 + mobile/lib/features/pairing/pairing_page.dart | 47 ++ .../features/pairing/pairing_provider.dart | 288 ++++++-- .../lib/features/settings/settings_page.dart | 1 + .../settings_page/connection_section.dart | 69 +- mobile/lib/shared/community/community.dart | 15 + .../shared/community/community_storage.dart | 1 + .../security/sensitive_action_authorizer.dart | 158 +++++ mobile/pubspec.lock | 40 ++ mobile/pubspec.yaml | 1 + .../invites/invite_join_provider_test.dart | 4 + .../features/pairing/pairing_page_test.dart | 130 ++++ .../pairing/pairing_provider_test.dart | 666 +++++++++++++++++- .../settings/connection_section_test.dart | 258 +++++++ .../community/community_storage_test.dart | 4 + .../test/shared/community/community_test.dart | 33 + .../sensitive_action_authorizer_test.dart | 197 ++++++ 23 files changed, 1881 insertions(+), 51 deletions(-) create mode 100644 mobile/lib/shared/security/sensitive_action_authorizer.dart create mode 100644 mobile/test/features/settings/connection_section_test.dart create mode 100644 mobile/test/shared/community/community_test.dart create mode 100644 mobile/test/shared/security/sensitive_action_authorizer_test.dart diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts index 501a31c46f..ca40ee16c6 100644 --- a/mobile/android/app/build.gradle.kts +++ b/mobile/android/app/build.gradle.kts @@ -125,6 +125,8 @@ android { } dependencies { + implementation("androidx.appcompat:appcompat:1.6.1") + testImplementation(kotlin("test")) androidTestImplementation(kotlin("test")) diff --git a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt index d9b456b270..f199f79823 100644 --- a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt @@ -10,7 +10,7 @@ import android.media.MediaMetadataRetriever import android.media.MediaMuxer import android.os.Build import androidx.annotation.RequiresApi -import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel import java.io.ByteArrayOutputStream @@ -77,7 +77,7 @@ internal object AndroidImageProcessor { } } -class MainActivity : FlutterActivity() { +class MainActivity : FlutterFragmentActivity() { private var mediaUploadChannel: MethodChannel? = null override fun configureFlutterEngine(flutterEngine: FlutterEngine) { diff --git a/mobile/android/app/src/main/res/values-night/styles.xml b/mobile/android/app/src/main/res/values-night/styles.xml index 06952be745..36d5d7c89c 100644 --- a/mobile/android/app/src/main/res/values-night/styles.xml +++ b/mobile/android/app/src/main/res/values-night/styles.xml @@ -1,7 +1,7 @@ -