Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -77,7 +77,7 @@ internal object AndroidImageProcessor {
}
}

class MainActivity : FlutterActivity() {
class MainActivity : FlutterFragmentActivity() {
private var mediaUploadChannel: MethodChannel? = null

override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
Expand Down
7 changes: 7 additions & 0 deletions mobile/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ PODS:
- FlutterMacOS
- image_picker_ios (0.0.1):
- Flutter
- local_auth_darwin (0.0.1):
- Flutter
- FlutterMacOS
- mobile_scanner (7.0.0):
- Flutter
- FlutterMacOS
Expand Down Expand Up @@ -45,6 +48,7 @@ DEPENDENCIES:
- Flutter (from `Flutter`)
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`)
- open_filex (from `.symlinks/plugins/open_filex/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
Expand All @@ -71,6 +75,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
local_auth_darwin:
:path: ".symlinks/plugins/local_auth_darwin/darwin"
mobile_scanner:
:path: ".symlinks/plugins/mobile_scanner/darwin"
open_filex:
Expand All @@ -97,6 +103,7 @@ SPEC CHECKSUMS:
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb
mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
Expand Down
2 changes: 2 additions & 0 deletions mobile/ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSFaceIDUsageDescription</key>
<string>Buzz uses Face ID to confirm sensitive identity transfers.</string>
<key>NSCameraUsageDescription</key>
<string>Buzz needs camera access so you can take photos to attach to messages and scan QR codes for device pairing.</string>
<key>NSPhotoLibraryUsageDescription</key>
Expand Down
46 changes: 46 additions & 0 deletions mobile/lib/features/pairing/pairing_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';

import '../../shared/security/sensitive_action_authorizer.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/tappable_flapping_bee.dart';
Expand Down Expand Up @@ -127,6 +128,12 @@ class PairingPage extends HookConsumerWidget {
sasCode: pairingState.sasCode ?? '------',
confirmed: pairingState.userConfirmedSas,
sendsIdentityToDesktop: pairingState.sendsIdentityToDesktop,
protectImportedIdentity:
pairingState.protectImportedIdentity,
errorMessage: pairingState.errorMessage,
onProtectionChanged: (value) => ref
.read(pairingProvider.notifier)
.setProtectImportedIdentity(value),
onConfirm: () =>
ref.read(pairingProvider.notifier).confirmSas(),
onDeny: () => ref.read(pairingProvider.notifier).denySas(),
Expand Down Expand Up @@ -196,19 +203,28 @@ class _SasVerificationView extends StatelessWidget {
final String sasCode;
final bool confirmed;
final bool sendsIdentityToDesktop;
final bool protectImportedIdentity;
final String? errorMessage;
final ValueChanged<bool> onProtectionChanged;
final VoidCallback onConfirm;
final VoidCallback onDeny;

const _SasVerificationView({
required this.sasCode,
required this.confirmed,
required this.sendsIdentityToDesktop,
required this.protectImportedIdentity,
required this.errorMessage,
required this.onProtectionChanged,
required this.onConfirm,
required this.onDeny,
});

@override
Widget build(BuildContext context) {
final authenticationName = sensitiveActionAuthenticationName(
Theme.of(context).platform,
);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expand Down Expand Up @@ -266,6 +282,36 @@ class _SasVerificationView extends StatelessWidget {
),
),

const SizedBox(height: Grid.sm),

if (!sendsIdentityToDesktop)
CheckboxListTile(
key: const Key('protect-imported-identity-checkbox'),
value: protectImportedIdentity,
onChanged: confirmed
? null
: (value) => onProtectionChanged(value ?? false),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
title: Text(
'Use $authenticationName to confirm sensitive identity actions',
),
subtitle: const Text(
'Routine Buzz use will not prompt. This protects identity transfer and reveal actions.',
),
),

if (errorMessage != null) ...[
const SizedBox(height: Grid.xs),
Text(
errorMessage!,
textAlign: TextAlign.center,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
],

const SizedBox(height: Grid.lg),

// Confirm / Deny buttons
Expand Down
118 changes: 89 additions & 29 deletions mobile/lib/features/pairing/pairing_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import '../../shared/auth/auth.dart';
import '../../shared/crypto/ecdh.dart';
import '../../shared/crypto/nip44.dart';
import '../../shared/relay/relay.dart';
import '../../shared/security/sensitive_action_authorizer.dart';
import 'pairing_crypto.dart';
import 'pairing_socket.dart';

Expand All @@ -37,13 +38,17 @@ class PairingState {
final String? sasCode;
final bool userConfirmedSas;
final bool sendsIdentityToDesktop;
final bool protectImportedIdentity;
final bool authorizationInProgress;

const PairingState({
this.status = PairingStatus.idle,
this.errorMessage,
this.sasCode,
this.userConfirmedSas = false,
this.sendsIdentityToDesktop = false,
this.protectImportedIdentity = true,
this.authorizationInProgress = false,
});

PairingState copyWith({
Expand All @@ -52,13 +57,19 @@ class PairingState {
String? sasCode,
bool? userConfirmedSas,
bool? sendsIdentityToDesktop,
bool? protectImportedIdentity,
bool? authorizationInProgress,
}) => PairingState(
status: status ?? this.status,
errorMessage: errorMessage ?? this.errorMessage,
sasCode: sasCode ?? this.sasCode,
userConfirmedSas: userConfirmedSas ?? this.userConfirmedSas,
sendsIdentityToDesktop:
sendsIdentityToDesktop ?? this.sendsIdentityToDesktop,
protectImportedIdentity:
protectImportedIdentity ?? this.protectImportedIdentity,
authorizationInProgress:
authorizationInProgress ?? this.authorizationInProgress,
);
}

Expand Down Expand Up @@ -110,30 +121,86 @@ class PairingNotifier extends Notifier<PairingState> {

/// Confirm that the SAS code matches. Called by the UI after user approval.
void confirmSas() {
if (state.status != PairingStatus.confirmingSas) return;

// If the desktop's sas-confirm has already arrived and been verified,
// transition immediately and process any buffered payload.
if (_sasConfirmReceived) {
state = state.copyWith(status: PairingStatus.transferring);
if (_sendIdentityToSource) {
_sendIdentityPayload();
} else {
final pending = _pendingPayload;
if (pending != null) {
_pendingPayload = null;
_handlePayload(pending);
}
}
if (state.status != PairingStatus.confirmingSas ||
state.authorizationInProgress) {
return;
}

// Desktop hasn't confirmed yet — record intent and wait. The transition
// will happen in _handleSasConfirm() once the transcript hash is verified.
_userConfirmedSas = true;
state = state.copyWith(userConfirmedSas: true);
if (_sasConfirmReceived) unawaited(_continueAfterSas());
}

void setProtectImportedIdentity(bool value) {
if (state.status != PairingStatus.confirmingSas ||
state.sendsIdentityToDesktop ||
state.authorizationInProgress) {
return;
}
state = state.copyWith(protectImportedIdentity: value);
}

Future<void> _continueAfterSas() async {
if (!_userConfirmedSas ||
!_sasConfirmReceived ||
state.status != PairingStatus.confirmingSas ||
state.authorizationInProgress) {
return;
}

final activePolicy = (await ref.read(
authProvider.future,
)).community?.sensitiveActionPolicy;
final requiresAuthorization = _sendIdentityToSource
? activePolicy == SensitiveActionPolicy.enabled
: state.protectImportedIdentity;

if (requiresAuthorization) {
state = state.copyWith(authorizationInProgress: true);
final result = await ref
.read(sensitiveActionAuthorizerProvider)
.authorizeIdentityAction();
if (state.status != PairingStatus.confirmingSas) return;
if (result != DeviceAuthResult.success) {
_userConfirmedSas = false;
state = state.copyWith(
userConfirmedSas: false,
authorizationInProgress: false,
errorMessage: _authorizationError(result),
);
return;
}
}

_userConfirmedSas = false;
state = state.copyWith(
status: PairingStatus.transferring,
authorizationInProgress: false,
);
if (_sendIdentityToSource) {
_sendIdentityPayload();
} else {
final pending = _pendingPayload;
if (pending != null) {
_pendingPayload = null;
_handlePayload(pending);
}
}
}

static String _authorizationError(
DeviceAuthResult result,
) => switch (result) {
DeviceAuthResult.cancelled =>
'Identity confirmation was cancelled. Nothing was transferred.',
DeviceAuthResult.unavailable =>
'Device authentication is unavailable. Configure a device passcode or biometrics, or turn off protection for this import.',
DeviceAuthResult.lockedOut =>
'Device authentication is locked. Unlock it in system settings and try again.',
DeviceAuthResult.failed =>
'Identity confirmation failed. Nothing was transferred.',
DeviceAuthResult.success => '',
};

/// Deny the SAS code. Send abort and terminate.
void denySas() {
_sendAbort('sas_mismatch');
Expand Down Expand Up @@ -415,17 +482,7 @@ class PairingNotifier extends Notifier<PairingState> {
// If the user already tapped "Codes Match", complete the transition now
// that the transcript hash is verified.
if (_userConfirmedSas) {
_userConfirmedSas = false;
state = state.copyWith(status: PairingStatus.transferring);
if (_sendIdentityToSource) {
_sendIdentityPayload();
} else {
final pending = _pendingPayload;
if (pending != null) {
_pendingPayload = null;
_handlePayload(pending);
}
}
unawaited(_continueAfterSas());
}
// Otherwise stay in confirmingSas — user must still confirm via confirmSas().
}
Expand Down Expand Up @@ -534,6 +591,9 @@ class PairingNotifier extends Notifier<PairingState> {
relayUrl: relayUrl,
pubkey: pubkey,
nsec: nsec,
sensitiveActionPolicy: state.protectImportedIdentity
? SensitiveActionPolicy.enabled
: SensitiveActionPolicy.disabledByUser,
);
await ref
.read(authProvider.notifier)
Expand Down
3 changes: 3 additions & 0 deletions mobile/lib/features/settings/settings_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import 'package:package_info_plus/package_info_plus.dart';
import '../../shared/auth/auth.dart';
import '../../shared/clipboard_utils.dart';
import '../../shared/relay/relay.dart';
import '../../shared/security/sensitive_action_authorizer.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/app_list.dart';
import '../../shared/widgets/app_list_card.dart';
Expand All @@ -22,6 +23,7 @@ import 'theme_picker_page.dart';

part 'settings_page/appearance_section.dart';
part 'settings_page/connection_section.dart';
part 'settings_page/mobile_security_section.dart';

class SettingsPage extends HookConsumerWidget {
const SettingsPage({
Expand Down Expand Up @@ -75,6 +77,7 @@ class SettingsPage extends HookConsumerWidget {
_ConnectionSection(
identityRecoveryPageBuilder: identityRecoveryPageBuilder,
),
const _MobileSecuritySection(),
const _RemoveCommunitySection(),
],
),
Expand Down
Loading
Loading