diff --git a/mobile/android/app/src/androidTest/kotlin/xyz/block/buzz/mobile/SecureStorageReadFailureTest.kt b/mobile/android/app/src/androidTest/kotlin/xyz/block/buzz/mobile/SecureStorageReadFailureTest.kt new file mode 100644 index 0000000000..9ace7f9201 --- /dev/null +++ b/mobile/android/app/src/androidTest/kotlin/xyz/block/buzz/mobile/SecureStorageReadFailureTest.kt @@ -0,0 +1,100 @@ +package xyz.block.buzz.mobile + +import android.content.Context +import android.content.SharedPreferences +import android.util.Base64 +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.it_nomads.fluttersecurestorage.FlutterSecureStorageConfig +import io.flutter.FlutterInjector +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.dart.DartExecutor +import io.flutter.plugin.common.MethodChannel +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** Uses the real pinned plugin, Android Keystore and production Dart services. */ +@RunWith(AndroidJUnit4::class) +class SecureStorageReadFailureTest { + @Test + fun decryptFailurePreservesDataAndNeverGenerates() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val context = instrumentation.targetContext + // Never install/run the corruption probe using a person's app identity. + assumeTrue("Requires the disposable storage-probe build", context.packageName.endsWith(".identitysafety")) + val done = CountDownLatch(1) + var failure: Throwable? = null + var passed = false + var engine: FlutterEngine? = null + var preferences: SharedPreferences? = null + var corruptedKey: String? = null + var original: String? = null + var damagedSnapshot: Map? = null + try { + instrumentation.runOnMainSync { + val loader = FlutterInjector.instance().flutterLoader() + loader.startInitialization(context) + loader.ensureInitializationComplete(context, null) + val running = FlutterEngine(context) + engine = running + MethodChannel(running.dartExecutor.binaryMessenger, "buzz/storage_failure_probe") + .setMethodCallHandler { call, reply -> + try { + when (call.method) { + "isolated" -> reply.success(true) + "corrupt" -> { + val options = call.argument>("options")!! + val config = FlutterSecureStorageConfig(options) + val prefs = context.getSharedPreferences(config.effectiveDataPrefsName, Context.MODE_PRIVATE) + val key = config.sharedPreferencesKeyPrefix + "_" + call.argument("key")!! + preferences = prefs + corruptedKey = key + original = prefs.getString(key, null) + assertTrue(original != null, "Probe must corrupt an existing encrypted record") + // Keep framing/Base64 valid, but invalidate the ciphertext authentication tag. + val damaged = Base64.decode(original, Base64.DEFAULT) + assertTrue(damaged.isNotEmpty()) + damaged[damaged.lastIndex] = (damaged.last().toInt() xor 1).toByte() + assertTrue(prefs.edit().putString(key, Base64.encodeToString(damaged, Base64.DEFAULT)).commit()) + damagedSnapshot = prefs.all.toMap() + reply.success(null) + } + "assertPreserved" -> { + assertEquals(damagedSnapshot, preferences!!.all) + reply.success(null) + } + "restore" -> { + assertTrue(preferences!!.edit().putString(corruptedKey, original).commit()) + reply.success(null) + } + "done" -> { + passed = call.argument("passed") == true + reply.success(null) + done.countDown() + } + else -> reply.notImplemented() + } + } catch (error: Throwable) { + failure = error + reply.error("probe_failure", error.javaClass.simpleName, null) + done.countDown() + } + } + running.dartExecutor.executeDartEntrypoint( + DartExecutor.DartEntrypoint(loader.findAppBundlePath(), "main"), + ) + } + assertTrue(done.await(45, TimeUnit.SECONDS), "Dart probe did not complete") + failure?.let { throw it } + assertTrue(passed, "Production Dart resolver did not fail closed") + } finally { + if (original != null) preferences?.edit()?.putString(corruptedKey, original)?.commit() + instrumentation.runOnMainSync { engine?.destroy() } + } + } +} diff --git a/mobile/lib/features/onboarding/hosted_connect_page.dart b/mobile/lib/features/onboarding/hosted_connect_page.dart index 37a7a7958a..fbde88df64 100644 --- a/mobile/lib/features/onboarding/hosted_connect_page.dart +++ b/mobile/lib/features/onboarding/hosted_connect_page.dart @@ -26,7 +26,9 @@ class HostedConnectPage extends HookConsumerWidget { HostedFlowStage.idle => 'Continue connecting your account.', HostedFlowStage.working => 'Connecting your account…', HostedFlowStage.chooseIdentity => - 'Choose the identity to connect to Builderlab. Existing device and cloud keys will be kept.', + flow.identityChoices.any((choice) => choice.fromCloud) + ? 'Use your Builderlab identity on this device? It differs from your saved community identities. Those keys and your cloud backups will be kept. This app cannot change the selected Builderlab identity later.' + : 'Choose the identity to connect to Builderlab. It will be bound to your account and backed up. Existing keys will be kept. This app cannot change the selected Builderlab identity later.', HostedFlowStage.signIn => 'Complete sign-in in your browser.', HostedFlowStage.browserReturned => 'Continue setup in your browser. Open your community in Buzz when it is ready.', diff --git a/mobile/lib/shared/community/community_storage.dart b/mobile/lib/shared/community/community_storage.dart index cf846c8a82..9ced276b0b 100644 --- a/mobile/lib/shared/community/community_storage.dart +++ b/mobile/lib/shared/community/community_storage.dart @@ -2,6 +2,8 @@ import 'dart:convert'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import '../storage/secure_storage.dart'; + import 'community.dart'; class CommunityStorage { @@ -19,7 +21,7 @@ class CommunityStorage { final FlutterSecureStorage _secure; CommunityStorage({FlutterSecureStorage? secure}) - : _secure = secure ?? const FlutterSecureStorage(); + : _secure = secure ?? communitySecureStorage; /// Load all communities. On first call, migrates legacy single-community /// credentials if present. diff --git a/mobile/lib/shared/hosted/account_identity_store.dart b/mobile/lib/shared/hosted/account_identity_store.dart index 982b03423b..e7d9d905c8 100644 --- a/mobile/lib/shared/hosted/account_identity_store.dart +++ b/mobile/lib/shared/hosted/account_identity_store.dart @@ -2,8 +2,11 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + import 'package:nostr/nostr.dart' as nostr; +import '../storage/secure_storage.dart'; + import 'hosted_environment.dart'; /// Safe failures: never include a secret or the storage/plugin exception. @@ -77,13 +80,7 @@ class AccountIdentitySnapshot { /// FlutterSecureStorage does not provide a cross-process compare-and-set. class AccountIdentityStore { AccountIdentityStore({FlutterSecureStorage? storage}) - : _storage = - storage ?? - const FlutterSecureStorage( - iOptions: IOSOptions( - accessibility: KeychainAccessibility.first_unlock_this_device, - ), - ); + : _storage = storage ?? accountSecureStorage; static final storageKey = 'buzz_account_identity_${HostedEnvironment.origin.host.replaceAll('.', '_')}_v1'; diff --git a/mobile/lib/shared/hosted/browser_auth.dart b/mobile/lib/shared/hosted/browser_auth.dart index e6aca0feda..69768fed14 100644 --- a/mobile/lib/shared/hosted/browser_auth.dart +++ b/mobile/lib/shared/hosted/browser_auth.dart @@ -7,8 +7,11 @@ import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + import 'package:url_launcher/url_launcher.dart'; +import '../storage/secure_storage.dart'; + import 'hosted_environment.dart'; /// Safe to show as a generic retry prompt. Never carries a URL or server text. @@ -29,13 +32,7 @@ class BrowserAuth { FlutterSecureStorage? storage, Future Function(Uri)? openBrowser, DateTime Function()? now, - }) : _storage = - storage ?? - const FlutterSecureStorage( - iOptions: IOSOptions( - accessibility: KeychainAccessibility.first_unlock_this_device, - ), - ), + }) : _storage = storage ?? accountSecureStorage, _openBrowser = openBrowser ?? _openPlatformBrowser, _now = now ?? DateTime.now; diff --git a/mobile/lib/shared/hosted/builderlab_account.dart b/mobile/lib/shared/hosted/builderlab_account.dart index c54799f168..7129c31f97 100644 --- a/mobile/lib/shared/hosted/builderlab_account.dart +++ b/mobile/lib/shared/hosted/builderlab_account.dart @@ -3,8 +3,11 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + import 'package:http/http.dart' as http; +import '../storage/secure_storage.dart'; + import 'hosted_environment.dart'; /// Errors contain no response bodies, credentials, or recovered private keys. @@ -47,11 +50,7 @@ class BuilderlabAccountClient { /// Device-only: the session never syncs or restores onto another device. @visibleForTesting - static const defaultStorage = FlutterSecureStorage( - iOptions: IOSOptions( - accessibility: KeychainAccessibility.first_unlock_this_device, - ), - ); + static const defaultStorage = accountSecureStorage; static final origin = HostedEnvironment.origin; static const _prefix = '/api/goose'; diff --git a/mobile/lib/shared/hosted/device_identity_source.dart b/mobile/lib/shared/hosted/device_identity_source.dart index 709eba840b..3026e20882 100644 --- a/mobile/lib/shared/hosted/device_identity_source.dart +++ b/mobile/lib/shared/hosted/device_identity_source.dart @@ -2,20 +2,23 @@ import 'dart:convert'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import '../storage/secure_storage.dart'; + import 'account_identity_store.dart'; /// Public identity choices; never contains private signing material. class DeviceIdentityChoice { - const DeviceIdentityChoice(this.pubkey, this.label); + const DeviceIdentityChoice(this.pubkey, this.label, {this.fromCloud = false}); final String pubkey; final String label; + final bool fromCloud; } /// Read-only inventory, including legacy records hidden by a newer community list. /// Do not call CommunityStorage.loadAll here: it migrates and deletes records. class DeviceIdentitySource { DeviceIdentitySource({FlutterSecureStorage? storage}) - : _storage = storage ?? const FlutterSecureStorage(); + : _storage = storage ?? communitySecureStorage; final FlutterSecureStorage _storage; Future> choices() async => [ @@ -38,10 +41,10 @@ class DeviceIdentitySource { Future> _read() async { try { final result = {}; + final labels = >{}; void add(dynamic nsec, dynamic advertised, String label) { if (nsec == null) { - // An identity reference without its secret is not authoritative absence. - if (advertised != null) throw AccountIdentityFailure.unreadable; + // Token-only legacy communities legitimately have no private key. return; } if (nsec is! String) throw AccountIdentityFailure.unreadable; @@ -49,11 +52,8 @@ class DeviceIdentitySource { if (advertised != null && advertised != pubkey) { throw AccountIdentityFailure.unreadable; } - final previous = result[pubkey]; - result[pubkey] = ( - nsec: nsec, - label: previous == null ? label : '${previous.label}, $label', - ); + final names = labels.putIfAbsent(pubkey, () => {})..add(label); + result[pubkey] = (nsec: nsec, label: names.join(', ')); } for (final key in ['buzz_communities', 'buzz_workspaces']) { @@ -65,7 +65,9 @@ class DeviceIdentitySource { add( value['nsec'], value['pubkey'], - value['name'] as String? ?? 'Saved community', + (value['name'] as String?)?.trim().isNotEmpty == true + ? (value['name'] as String).trim() + : 'Saved community', ); } } diff --git a/mobile/lib/shared/hosted/hosted_backup_service.dart b/mobile/lib/shared/hosted/hosted_backup_service.dart index 7effaaa1e0..9fd1d6f1f5 100644 --- a/mobile/lib/shared/hosted/hosted_backup_service.dart +++ b/mobile/lib/shared/hosted/hosted_backup_service.dart @@ -4,6 +4,8 @@ import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import '../storage/secure_storage.dart'; + import 'account_identity_store.dart'; import 'builderlab_account.dart'; import 'hosted_environment.dart'; @@ -26,13 +28,7 @@ class HostedBackupService { HpkeBackup? hpke, FlutterSecureStorage? storage, }) : _hpke = hpke ?? HpkeBackup(), - _storage = - storage ?? - const FlutterSecureStorage( - iOptions: IOSOptions( - accessibility: KeychainAccessibility.first_unlock_this_device, - ), - ); + _storage = storage ?? accountSecureStorage; final BuilderlabAccountClient account; final AccountIdentityStore identity; diff --git a/mobile/lib/shared/hosted/hosted_flow.dart b/mobile/lib/shared/hosted/hosted_flow.dart index f02b309f2e..2803006f6e 100644 --- a/mobile/lib/shared/hosted/hosted_flow.dart +++ b/mobile/lib/shared/hosted/hosted_flow.dart @@ -227,7 +227,8 @@ class HostedFlow extends ChangeNotifier { /// Resume only an offered public identity; reread local and cloud facts first. Future chooseIdentity(String pubkey) { - if (stage != HostedFlowStage.chooseIdentity || + if (_target == null || + stage != HostedFlowStage.chooseIdentity || !identityChoices.any((item) => item.pubkey == pubkey)) { return Future.value(); } diff --git a/mobile/lib/shared/hosted/hosted_identity_service.dart b/mobile/lib/shared/hosted/hosted_identity_service.dart index 5153c38c38..ff1225677d 100644 --- a/mobile/lib/shared/hosted/hosted_identity_service.dart +++ b/mobile/lib/shared/hosted/hosted_identity_service.dart @@ -225,7 +225,8 @@ class HostedIdentityService { choices: [ DeviceIdentityChoice( candidate.keys.public, - 'Builderlab backup (keep existing community identities)', + 'Use Builderlab identity', + fromCloud: true, ), ], ); diff --git a/mobile/lib/shared/push/push_lease_revocation_outbox.dart b/mobile/lib/shared/push/push_lease_revocation_outbox.dart index 4dcff817be..ba15f58421 100644 --- a/mobile/lib/shared/push/push_lease_revocation_outbox.dart +++ b/mobile/lib/shared/push/push_lease_revocation_outbox.dart @@ -4,9 +4,12 @@ import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; +import '../storage/secure_storage.dart'; + import '../community/community.dart'; import '../relay/signed_event_relay.dart'; import 'dev_push_lease.dart'; @@ -166,7 +169,7 @@ class BuzzPushLeaseRevocationStorage { final FlutterSecureStorage _secure; BuzzPushLeaseRevocationStorage({FlutterSecureStorage? secure}) - : _secure = secure ?? const FlutterSecureStorage(); + : _secure = secure ?? communitySecureStorage; Future> loadAll() async { final raw = await _secure.read(key: _key); diff --git a/mobile/lib/shared/storage/secure_storage.dart b/mobile/lib/shared/storage/secure_storage.dart new file mode 100644 index 0000000000..6d8f72b380 --- /dev/null +++ b/mobile/lib/shared/storage/secure_storage.dart @@ -0,0 +1,18 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Android caches the first configuration per namespace. Every production +/// client must use these options: a failed read must never delete a key. +const secureStorageAndroidOptions = AndroidOptions(resetOnError: false); + +/// Preserve the existing community-storage namespace and iOS accessibility. +const communitySecureStorage = FlutterSecureStorage( + aOptions: secureStorageAndroidOptions, +); + +/// Account credentials and identities remain device-only on iOS. +const accountSecureStorage = FlutterSecureStorage( + aOptions: secureStorageAndroidOptions, + iOptions: IOSOptions( + accessibility: KeychainAccessibility.first_unlock_this_device, + ), +); diff --git a/mobile/test/features/onboarding/identity_choice_test.dart b/mobile/test/features/onboarding/identity_choice_test.dart index 9ee5939c00..a705ea61be 100644 --- a/mobile/test/features/onboarding/identity_choice_test.dart +++ b/mobile/test/features/onboarding/identity_choice_test.dart @@ -2,7 +2,8 @@ import 'dart:convert'; import 'package:buzz/features/onboarding/hosted_connect_page.dart'; import 'package:buzz/hosted_app_gate.dart'; -import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:buzz/onboarding_app_entry.dart'; +import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart'; import 'package:buzz/shared/hosted/account_identity_store.dart'; import 'package:buzz/shared/hosted/browser_auth.dart'; @@ -12,9 +13,9 @@ import 'package:buzz/shared/hosted/hosted_identity_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; +import '../../helpers/widget_helpers.dart'; import '../../shared/hosted/device_identity_reconciliation_test.dart' show InventoryAccount; @@ -31,92 +32,161 @@ class _Browser extends BrowserAuth { void main() { testWidgets( - 'inline link flow shows identity choice; leave and explicit selection preserve keys', + 'real link form supports local choice and cloud consent, leave and resubmit', (tester) async { - final first = nostr.Keys('1'.padLeft(64, '0')); - final second = nostr.Keys('2'.padLeft(64, '0')); - final raw = jsonEncode([ - {'name': 'First community', 'nsec': first.nsec, 'pubkey': first.public}, - { - 'name': 'Second community', - 'nsec': second.nsec, - 'pubkey': second.public, - }, - ]); - FlutterSecureStorage.setMockInitialValues({'buzz_communities': raw}); - PendingDeepLinkNotifier.debugUriStreamOverride = - const Stream.empty(); - final account = InventoryAccount(); - final store = AccountIdentityStore(); - final browser = _Browser(); - var admitted = false; - final flow = HostedFlow( - account: account, - browser: browser, - store: store, - identity: HostedIdentityService( + // One widget zone matches the process-wide secure-store writer queue. + for (final cloud in [false, true]) { + final first = nostr.Keys('1'.padLeft(64, '0')); + final second = nostr.Keys('2'.padLeft(64, '0')); + final raw = jsonEncode([ + Community.create( + name: 'First community', + relayUrl: 'wss://first.example.invalid', + nsec: first.nsec, + pubkey: first.public, + ).toJson(), + if (!cloud) + Community.create( + name: 'Second community', + relayUrl: 'wss://second.example.invalid', + nsec: second.nsec, + pubkey: second.public, + ).toJson(), + ]); + FlutterSecureStorage.setMockInitialValues({'buzz_communities': raw}); + PendingDeepLinkNotifier.debugUriStreamOverride = + const Stream.empty(); + final account = InventoryAccount(); + if (cloud) { + account.bound = second.public; + account.backups = [second.public]; + account.recovery = { + 'backups': [ + { + 'backup_id': 'fixture-backup', + 'nostr_pubkey': second.public, + 'secret_key': base64UrlEncode([ + ...List.filled(31, 0), + 2, + ]).replaceAll('=', ''), + }, + ], + 'failed_backup_ids': [], + }; + } + final store = AccountIdentityStore(); + final browser = _Browser(); + var admitted = false; + final flow = HostedFlow( account: account, + browser: browser, store: store, - deviceIdentities: DeviceIdentitySource(), - ), - enterCommunity: (_, _, _, _) async { - admitted = true; - }, - ); - final container = ProviderContainer( - overrides: [hostedFlowProvider.overrideWithValue(flow)], - ); - addTearDown(() async { - PendingDeepLinkNotifier.debugUriStreamOverride = null; - container.dispose(); - flow.dispose(); - await browser.dispose(); - account.dispose(); - }); - container.read(hostedInlineEntryProvider.notifier).setActive(true); - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: const MaterialApp( - home: HostedAppGate( + identity: HostedIdentityService( + account: account, + store: store, + deviceIdentities: DeviceIdentitySource(), + ), + enterCommunity: (_, _, _, _) async { + admitted = true; + }, + ); + addTearDown(() async { + PendingDeepLinkNotifier.debugUriStreamOverride = null; + flow.dispose(); + await browser.dispose(); + account.dispose(); + }); + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + overrides: [hostedFlowProvider.overrideWithValue(flow)], + child: const HostedAppGate( authenticated: false, - child: Scaffold(body: Text('Link form')), + child: OnboardingAppEntry(), ), ), - ), - ); - await tester.pumpAndSettle(); - const target = AddCommunityDeepLink( - relayUrl: 'wss://team.communities.buzz.xyz', - name: 'Team', - ); - await flow.start(target); - await tester.pumpAndSettle(); - expect(find.byType(HostedConnectPage), findsOneWidget); - expect(find.text('First community'), findsOneWidget); - expect(find.text('Second community'), findsOneWidget); - expect(await store.read(), isNull); - await tester.tap(find.text('Leave setup')); - await tester.pumpAndSettle(); - expect(flow.stage, HostedFlowStage.idle); - expect(await store.read(), isNull); - await flow.start(target); - await tester.pumpAndSettle(); - await tester.tap(find.text('Second community')); - await tester.pumpAndSettle(); - expect((await store.read())!.pubkey, second.public); - expect( - await const FlutterSecureStorage().read(key: 'buzz_communities'), - raw, - ); - expect(admitted, isFalse); - // Fixture refuses binding: choosing a key never bypasses remaining gates. - expect(flow.problem, HostedFlowProblem.denied); - expect( - account.calls.where((path) => path.endsWith('/challenge')).length, - 1, - ); - expect(tester.takeException(), isNull); + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Enter a community link').hitTestable()); + await tester.pumpAndSettle(); + const url = 'https://team.communities.buzz.xyz'; + final input = find.byKey(const Key('community-link-input')); + await tester.enterText(input, url); + await tester.testTextInput.receiveAction(TextInputAction.go); + await tester.pumpAndSettle(); + for ( + var attempt = 0; + attempt < 30 && flow.stage != HostedFlowStage.chooseIdentity; + attempt++ + ) { + await tester.pump(const Duration(milliseconds: 50)); + } + expect( + flow.stage, + HostedFlowStage.chooseIdentity, + reason: 'Calls: ${account.calls}', + ); + expect(find.byType(HostedConnectPage), findsOneWidget); + expect( + find.textContaining( + 'This app cannot change the selected Builderlab identity later.', + ), + findsOneWidget, + ); + if (cloud) { + expect( + find.textContaining( + 'It differs from your saved community identities.', + ), + findsOneWidget, + ); + expect(find.text('Use Builderlab identity'), findsOneWidget); + } else { + expect(find.text('First community'), findsOneWidget); + expect(find.text('Second community'), findsOneWidget); + } + expect(await store.read(), isNull); + await tester.ensureVisible(find.text('Leave setup')); + await tester.tap(find.text('Leave setup')); + await tester.pumpAndSettle(); + expect(flow.stage, HostedFlowStage.idle); + expect(flow.identityChoices, isEmpty); + expect(await store.read(), isNull); + expect(tester.widget(input).controller!.text, url); + expect(tester.widget(input).enabled, isTrue); + await tester.ensureVisible(find.text('Continue')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Continue').hitTestable()); + await tester.pumpAndSettle(); + final choice = find.text( + cloud ? 'Use Builderlab identity' : 'Second community', + ); + await tester.ensureVisible(choice); + await tester.tap(choice); + await tester.pumpAndSettle(); + expect((await store.read())!.pubkey, second.public); + expect( + await const FlutterSecureStorage().read(key: 'buzz_communities'), + raw, + ); + expect(flow.identityChoices, isEmpty); + expect(admitted, cloud); + if (!cloud) { + // The fixture refuses binding: a choice never bypasses later gates. + expect(flow.problem, HostedFlowProblem.denied); + expect( + find.text('This account does not have access.'), + findsOneWidget, + ); + expect( + account.calls.where((path) => path.endsWith('/challenge')).length, + 1, + ); + } + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + } }, ); } diff --git a/mobile/test/shared/hosted/device_identity_reconciliation_test.dart b/mobile/test/shared/hosted/device_identity_reconciliation_test.dart index 998fd4863b..52b7deb9b9 100644 --- a/mobile/test/shared/hosted/device_identity_reconciliation_test.dart +++ b/mobile/test/shared/hosted/device_identity_reconciliation_test.dart @@ -124,7 +124,6 @@ void main() { for (final data in [ {'buzz_workspaces': '{broken'}, {'buzz_nsec': ''}, - {'buzz_pubkey': first.public}, { 'buzz_communities': jsonEncode([ {'nsec': first.nsec, 'pubkey': second.public}, @@ -195,6 +194,55 @@ void main() { }, ); + test( + 'token-only legacy records do not prevent recovering the same cloud identity', + () async { + final raw = jsonEncode([ + {'pubkey': first.public, 'name': 'Token-only community'}, + ]); + await secure.write(key: 'buzz_communities', value: raw); + await secure.write(key: 'buzz_pubkey', value: first.public); + account.bound = first.public; + account.backups = [first.public]; + account.recovery = { + 'backups': [ + { + 'backup_id': 'fixture-backup', + 'nostr_pubkey': first.public, + 'secret_key': base64UrlEncode([ + ...List.filled(31, 0), + 1, + ]).replaceAll('=', ''), + }, + ], + 'failed_backup_ids': [], + }; + final result = await service.resolve(); + expect(result.state, HostedIdentityState.ready); + expect(result.pubkey, first.public); + expect(account.calls, [ + '/v1/buzz/nsec-backups/status', + '/v1/buzz/nsec-backups/recovery', + ]); + expect(await secure.read(key: 'buzz_communities'), raw); + expect(await secure.read(key: 'buzz_pubkey'), first.public); + }, + ); + + test( + 'inventory labels are deduplicated and empty names get a fallback', + () async { + final raw = jsonEncode([record(first, 'Team'), record(second, ' ')]); + await secure.write(key: 'buzz_communities', value: raw); + await secure.write(key: 'buzz_workspaces', value: raw); + final choices = await DeviceIdentitySource().choices(); + expect(choices.map((choice) => choice.label), [ + 'Team', + 'Saved community', + ]); + }, + ); + test('cancelled resolution cannot adopt or generate', () async { await secure.write(key: 'buzz_nsec', value: first.nsec); await expectLater( diff --git a/mobile/test/shared/hosted/identity_choice_lifecycle_test.dart b/mobile/test/shared/hosted/identity_choice_lifecycle_test.dart new file mode 100644 index 0000000000..5972bbdb0f --- /dev/null +++ b/mobile/test/shared/hosted/identity_choice_lifecycle_test.dart @@ -0,0 +1,107 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:buzz/shared/hosted/account_identity_store.dart'; +import 'package:buzz/shared/hosted/browser_auth.dart'; +import 'package:buzz/shared/hosted/device_identity_source.dart'; +import 'package:buzz/shared/hosted/hosted_flow.dart'; +import 'package:buzz/shared/hosted/hosted_identity_service.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import 'device_identity_reconciliation_test.dart' show InventoryAccount; + +class _Browser extends BrowserAuth { + Completer? cancelling; + int cleared = 0; + @override + Future cancel() async => cancelling?.future; + @override + Future clearIntent() async { + cleared++; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final first = nostr.Keys('1'.padLeft(64, '0')); + final second = nostr.Keys('2'.padLeft(64, '0')); + late InventoryAccount account; + late AccountIdentityStore store; + late _Browser browser; + late HostedFlow flow; + const target = AddCommunityDeepLink( + relayUrl: 'wss://team.communities.buzz.xyz', + name: 'Team', + ); + setUp(() { + FlutterSecureStorage.setMockInitialValues({ + 'buzz_communities': jsonEncode([ + {'name': 'First', 'pubkey': first.public, 'nsec': first.nsec}, + {'name': 'Second', 'pubkey': second.public, 'nsec': second.nsec}, + ]), + }); + account = InventoryAccount(); + store = AccountIdentityStore(); + browser = _Browser(); + flow = HostedFlow( + account: account, + browser: browser, + store: store, + identity: HostedIdentityService( + account: account, + store: store, + deviceIdentities: DeviceIdentitySource(), + ), + enterCommunity: (_, _, _, _) async => fail('Must not bypass binding'), + ); + }); + tearDown(() async { + flow.dispose(); + await browser.dispose(); + account.dispose(); + }); + + test( + 'unoffered choices cannot restart resolution or perform any remote work', + () async { + await flow.start(target); + final calls = List.of(account.calls); + final choices = List.of(flow.identityChoices); + await flow.chooseIdentity('0' * 64); + expect(flow.stage, HostedFlowStage.chooseIdentity); + expect(account.calls, calls); + expect(flow.identityChoices, choices); + expect(await store.read(), isNull); + }, + ); + + test( + 'choices are retired on selection and on leaving; late tap cannot cancel leave', + () async { + await flow.start(target); + browser.cancelling = Completer(); + final calls = List.of(account.calls); + final leaving = flow.leave(); + await flow.chooseIdentity(first.public); + expect(account.calls, calls); + expect(await store.read(), isNull); + browser.cancelling!.complete(); + await leaving; + expect(browser.cleared, 1); + expect(flow.stage, HostedFlowStage.idle); + expect(flow.identityChoices, isEmpty); + await flow.start(target); + final choosing = flow.chooseIdentity(second.public); + expect(flow.identityChoices, isEmpty); + await choosing; + expect(flow.problem, HostedFlowProblem.denied); + final completedCalls = List.of(account.calls); + await flow.chooseIdentity(first.public); + expect(account.calls, completedCalls); + expect((await store.read())!.pubkey, second.public); + }, + ); +} diff --git a/mobile/tool/storage/README.md b/mobile/tool/storage/README.md new file mode 100644 index 0000000000..665b8c54d5 --- /dev/null +++ b/mobile/tool/storage/README.md @@ -0,0 +1,36 @@ +# Android secure-storage failure probe + +This instrumentation test uses the pinned `flutter_secure_storage` plugin, +Android Keystore, and the production community/account identity services. It +corrupts the authentication tag of synthetic encrypted community and account +records. The resolver must report unreadable storage, preserve the damaged +ciphertext and other keys, and never generate an identity. + +Use an isolated checkout with its own build outputs and an emulator. In that +checkout, set the **gitignored** `android/AppOverrides.properties` to: + +```properties +applicationIdSuffix=.identitysafety +appName=Buzz Identity Safety Probe +``` + +From `mobile/android`, with the repository Flutter toolchain and JDK 21: + +```sh +ANDROID_SERIAL=emulator-5554 ./gradlew :app:connectedDebugAndroidTest \ + -Ptarget="$PWD/../tool/storage/android_read_failure_probe.dart" \ + -Pandroid.testInstrumentationRunnerArguments.class=xyz.block.buzz.mobile.SecureStorageReadFailureTest +``` + +Verify the emulator serial with `adb devices` first. The test must report one +executed test, zero failures and **zero skipped**. It intentionally skips normal +app IDs; do not treat that skip as a pass. The native bridge checks the disposable +app ID before starting Dart, and the Dart entry point requires that bridge before +writing anything. No live account/backend is used. Normal app entry points and +release builds do not use this probe. + +The first storage access is through `CommunityStorage`, so hosted reads reuse its +cached plugin configuration. Both storage constant variants share fail-closed +Android options while retaining their existing iOS settings. Regression control: +in an isolated source/build tree, changing the shared `resetOnError` option to +`true` must fail the test. Restore the source and rerun the clean control. diff --git a/mobile/tool/storage/android_read_failure_probe.dart b/mobile/tool/storage/android_read_failure_probe.dart new file mode 100644 index 0000000000..d1cb6d9c19 --- /dev/null +++ b/mobile/tool/storage/android_read_failure_probe.dart @@ -0,0 +1,106 @@ +// Instrumentation-only entry point. Never launches against the normal app ID. +import 'package:flutter/widgets.dart'; +import 'package:flutter/services.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/community/community_storage.dart'; +import 'package:buzz/shared/hosted/account_identity_store.dart'; +import 'package:buzz/shared/hosted/builderlab_account.dart'; +import 'package:buzz/shared/hosted/device_identity_source.dart'; +import 'package:buzz/shared/hosted/hosted_identity_service.dart'; +import 'package:buzz/shared/storage/secure_storage.dart'; + +class _Account extends BuilderlabAccountClient { + int calls = 0; + @override + BuilderlabAccount get account => + BuilderlabAccount(subject: 'synthetic', expiresAt: DateTime.utc(2100)); + @override + Future> currentIdentity() async { + calls++; + return {}; + } + + @override + Future> post( + String path, + Map body, + ) async { + calls++; + if (!path.endsWith('/status')) throw StateError('Unexpected remote write'); + return {'backup_nostr_pubkeys': []}; + } +} + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + const bridge = MethodChannel('buzz/storage_failure_probe'); + // Native test rejects any application ID other than the disposable probe ID. + if (await bridge.invokeMethod('isolated') != true) return; + final account = _Account(); + try { + final keys = nostr.Keys('1'.padLeft(64, '0')); + final store = AccountIdentityStore(); + final source = DeviceIdentitySource(); + final resolver = HostedIdentityService( + account: account, + store: store, + deviceIdentities: source, + ); + // Exercise first-config-wins: a real community writer initializes the plugin + // before the account store and inventory read through that same namespace. + await CommunityStorage().saveAll([ + Community.create( + name: 'Synthetic', + relayUrl: 'wss://example.invalid', + nsec: keys.nsec, + pubkey: keys.public, + ), + ]); + // Only synthetic fixture data in the guarded disposable application. + await accountSecureStorage.delete(key: AccountIdentityStore.storageKey); + await communitySecureStorage.write(key: 'probe_sentinel', value: 'keep'); + for (final key in ['buzz_communities', AccountIdentityStore.storageKey]) { + if (key == AccountIdentityStore.storageKey) { + await store.create(keys.nsec); + } + final beforeCalls = account.calls; + await bridge.invokeMethod('corrupt', { + 'key': key, + 'options': secureStorageAndroidOptions.toMap(), + }); + var failedClosed = false; + try { + await resolver.resolve(); + } on AccountIdentityFailure catch (failure) { + failedClosed = failure == AccountIdentityFailure.unreadable; + } + if (!failedClosed) throw StateError('Read failure became absence'); + if (key == 'buzz_communities' && await store.read() != null) { + throw StateError('Generated after unreadable inventory'); + } + if (key == AccountIdentityStore.storageKey && + account.calls != beforeCalls) { + throw StateError('Unreadable account identity reached remote setup'); + } + if (await communitySecureStorage.read(key: 'probe_sentinel') != 'keep') { + throw StateError('Other key lost'); + } + await bridge.invokeMethod('assertPreserved'); + await bridge.invokeMethod('restore'); + } + if ((await store.read())?.pubkey != keys.public) { + throw StateError('Original key did not survive'); + } + await bridge.invokeMethod('done', {'passed': true}); + } catch (error) { + // Diagnostic category only; never expose plugin exception details or keys. + await bridge.invokeMethod('done', { + 'passed': false, + 'error': error.runtimeType.toString(), + }); + } finally { + account.dispose(); + } +}