mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
**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 | | --- | --- | |  |  | <details> <summary>File changes</summary> **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. </details> ## 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 <taylorkmho@gmail.com>
179 lines
7.0 KiB
Kotlin
179 lines
7.0 KiB
Kotlin
import java.util.Properties
|
|
|
|
plugins {
|
|
id("com.android.application")
|
|
id("kotlin-android")
|
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
|
id("dev.flutter.flutter-gradle-plugin")
|
|
}
|
|
|
|
val uploadKeystorePath = providers.environmentVariable("BUZZ_ANDROID_UPLOAD_KEYSTORE_PATH").orNull
|
|
val uploadKeystorePassword = providers.environmentVariable("BUZZ_ANDROID_UPLOAD_KEYSTORE_PASSWORD").orNull
|
|
val uploadKeyAlias = providers.environmentVariable("BUZZ_ANDROID_UPLOAD_KEY_ALIAS").orNull
|
|
val uploadKeyPassword = providers.environmentVariable("BUZZ_ANDROID_UPLOAD_KEY_PASSWORD").orNull
|
|
val uploadSigningValues =
|
|
mapOf(
|
|
"BUZZ_ANDROID_UPLOAD_KEYSTORE_PATH" to uploadKeystorePath,
|
|
"BUZZ_ANDROID_UPLOAD_KEYSTORE_PASSWORD" to uploadKeystorePassword,
|
|
"BUZZ_ANDROID_UPLOAD_KEY_ALIAS" to uploadKeyAlias,
|
|
"BUZZ_ANDROID_UPLOAD_KEY_PASSWORD" to uploadKeyPassword,
|
|
)
|
|
val missingUploadSigningValues = uploadSigningValues.filterValues { it.isNullOrBlank() }.keys
|
|
val hasUploadSigning = missingUploadSigningValues.isEmpty()
|
|
|
|
// Worktree-aware debug identity (gitignored, written by
|
|
// scripts/mobile-worktree-overrides.sh): debug builds from a git worktree get a
|
|
// branch-labelled app name and a unique applicationId suffix so builds from
|
|
// multiple worktrees install side by side. Release builds never read this.
|
|
val worktreePropsFile = rootProject.file("worktree.properties")
|
|
val worktreeProps =
|
|
Properties().apply {
|
|
if (worktreePropsFile.isFile) worktreePropsFile.inputStream().use { load(it) }
|
|
}
|
|
val worktreeLabel = worktreeProps.getProperty("label")?.takeIf { it.isNotBlank() }
|
|
if (worktreeLabel != null && !worktreeLabel.matches(Regex("""[A-Za-z0-9._-]+"""))) {
|
|
throw GradleException(
|
|
"worktree.properties label must match [A-Za-z0-9._-]+ (safe for string " +
|
|
"resources), got: " + worktreeLabel,
|
|
)
|
|
}
|
|
val worktreeIdSuffix =
|
|
worktreeProps.getProperty("applicationIdSuffix")?.takeIf { it.isNotBlank() }
|
|
if (worktreeIdSuffix != null && !worktreeIdSuffix.matches(Regex("""\.[a-z][a-z0-9_]*"""))) {
|
|
throw GradleException(
|
|
"worktree.properties applicationIdSuffix must match \\.[a-z][a-z0-9_]*, got: " +
|
|
worktreeIdSuffix,
|
|
)
|
|
}
|
|
|
|
// Release signing modes:
|
|
// - "upload-keystore" (default): sign with the CI-vended upload keystore;
|
|
// release builds fail loudly when any credential is missing.
|
|
// - "external": deliberately produce an UNSIGNED release bundle for a
|
|
// pipeline that signs through the central APK Signer service (Cashkite,
|
|
// BOT-1234). No keystore material may be present in this mode.
|
|
val releaseSigningMode =
|
|
providers.environmentVariable("BUZZ_ANDROID_RELEASE_SIGNING").orNull ?: "upload-keystore"
|
|
val externalReleaseSigning = releaseSigningMode == "external"
|
|
if (releaseSigningMode !in setOf("upload-keystore", "external")) {
|
|
throw GradleException(
|
|
"BUZZ_ANDROID_RELEASE_SIGNING must be \"upload-keystore\" or \"external\", got: " +
|
|
releaseSigningMode,
|
|
)
|
|
}
|
|
if (externalReleaseSigning && uploadSigningValues.values.any { !it.isNullOrBlank() }) {
|
|
throw GradleException(
|
|
"BUZZ_ANDROID_RELEASE_SIGNING=external must not be combined with " +
|
|
"BUZZ_ANDROID_UPLOAD_* credentials; unset one of them.",
|
|
)
|
|
}
|
|
|
|
android {
|
|
namespace = "xyz.block.buzz.mobile"
|
|
compileSdk = flutter.compileSdkVersion
|
|
ndkVersion = flutter.ndkVersion
|
|
|
|
compileOptions {
|
|
sourceCompatibility = JavaVersion.VERSION_17
|
|
targetCompatibility = JavaVersion.VERSION_17
|
|
}
|
|
|
|
kotlinOptions {
|
|
jvmTarget = JavaVersion.VERSION_17.toString()
|
|
}
|
|
|
|
defaultConfig {
|
|
applicationId = "xyz.block.buzz.mobile"
|
|
// You can update the following values to match your application needs.
|
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
|
minSdk = flutter.minSdkVersion
|
|
targetSdk = flutter.targetSdkVersion
|
|
versionCode = flutter.versionCode
|
|
versionName = flutter.versionName
|
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
|
resValue("string", "app_name", "Buzz")
|
|
}
|
|
|
|
signingConfigs {
|
|
if (hasUploadSigning) {
|
|
create("upload") {
|
|
storeFile = file(requireNotNull(uploadKeystorePath))
|
|
storePassword = uploadKeystorePassword
|
|
keyAlias = uploadKeyAlias
|
|
keyPassword = uploadKeyPassword
|
|
}
|
|
}
|
|
}
|
|
|
|
buildTypes {
|
|
debug {
|
|
// Only debug builds take the worktree identity; release/profile
|
|
// keep the production applicationId and label.
|
|
if (worktreeIdSuffix != null) {
|
|
applicationIdSuffix = worktreeIdSuffix
|
|
}
|
|
if (worktreeLabel != null) {
|
|
resValue("string", "app_name", "Buzz ($worktreeLabel)")
|
|
}
|
|
}
|
|
release {
|
|
if (hasUploadSigning) {
|
|
signingConfig = signingConfigs.getByName("upload")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
dependencies {
|
|
implementation("androidx.appcompat:appcompat:1.6.1")
|
|
|
|
testImplementation(kotlin("test"))
|
|
|
|
androidTestImplementation(kotlin("test"))
|
|
androidTestImplementation("androidx.test.ext:junit:1.3.0")
|
|
androidTestImplementation("androidx.test:runner:1.7.0")
|
|
}
|
|
|
|
gradle.taskGraph.whenReady {
|
|
val buildsRelease = allTasks.any { task ->
|
|
task.project == project && task.name in setOf("assembleRelease", "bundleRelease")
|
|
}
|
|
if (buildsRelease && externalReleaseSigning) {
|
|
// External signing: the unsigned bundle goes to the central APK
|
|
// Signer. All keystore checks are intentionally skipped; the
|
|
// guard above already rejected any BUZZ_ANDROID_UPLOAD_* values.
|
|
return@whenReady
|
|
}
|
|
if (buildsRelease && !hasUploadSigning) {
|
|
throw GradleException(
|
|
"Release builds require Android upload signing credentials. Missing: " +
|
|
missingUploadSigningValues.sorted().joinToString(", ") +
|
|
". For central APK Signer pipelines set BUZZ_ANDROID_RELEASE_SIGNING=external.",
|
|
)
|
|
}
|
|
if (buildsRelease) {
|
|
val configuredKeystore = File(requireNotNull(uploadKeystorePath))
|
|
if (!configuredKeystore.isAbsolute) {
|
|
throw GradleException(
|
|
"BUZZ_ANDROID_UPLOAD_KEYSTORE_PATH must be absolute: $configuredKeystore",
|
|
)
|
|
}
|
|
val keystore = file(configuredKeystore)
|
|
val repositoryRoot = rootProject.projectDir.parentFile.parentFile.canonicalFile
|
|
if (keystore.canonicalFile.toPath().startsWith(repositoryRoot.toPath())) {
|
|
throw GradleException(
|
|
"BUZZ_ANDROID_UPLOAD_KEYSTORE_PATH must be outside the repository: $keystore",
|
|
)
|
|
}
|
|
if (!keystore.isFile || !keystore.canRead()) {
|
|
throw GradleException(
|
|
"BUZZ_ANDROID_UPLOAD_KEYSTORE_PATH is not a readable file: $keystore",
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
flutter {
|
|
source = "../.."
|
|
}
|