From 75854111b82694375dbf0f4dd67ddf8b100ba64c Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 30 Oct 2026 09:38:24 -0700 Subject: [PATCH] fix(mobile): retain invite keys and reject incomplete onboarding (#8753) Self-hosted invite claims retain the generated identity in secure storage before the HTTP request and reuse it after a lost response or restart. Saving or reopening an existing destination retires the matching journal. Sign-out/removal deletes all matching pending key copies before removing the saved community, preserving the community for retry if cleanup fails. Pairing verifies that relay authentication completed before saving/activating a destination or reporting success. On iOS 16, hosted entry permits recovery and reuse of backed-up identities, including binding a recovered identity with a confirmed backup receipt. Paths requiring a new secure backup explain the iOS 17 requirement before generating or binding a new key. Self-hosted entry remains available. ### Validation - Initial focused entry/recovery files: 83 passed, 1 skipped. Review-fix focused storage, community, invite and hosted files: 82 passed, 1 skipped; extended invite journey: 17 passed, 1 skipped. - Final hosted journey extension: 20 passed, including both bound and unbound recovery with native sealing unavailable. - Isolated mutations removing invite retention, pairing validation, journal removal/re-entry cleanup or restoring the blanket platform gate fail their intended regressions. Restored controls pass. - iOS simulator build passed on the original integration base; final native/device acceptance remains outstanding. - Carl and three specialist reviewers found no source-review blockers at `323cec6ea2`. `a55a246857` adds analyzer fixes, copy cleanup and the recovered-unbound journey coverage. Reviewers did not run tests or the app. - Fixed a push-hook timeout in `4564b3562a`: idle serial queues now release completed futures, avoiding scheduler retention across widget tests while preserving in-flight ordering and failure isolation. The existing dispatcher journey fails promptly before the fix and passes afterward; Carl reviewed this delta with no blockers. Focused dispatcher/invite/storage checks passed, and all 20 hosted journeys passed with hosted auth enabled. - Mandatory pre-push checks passed at `4564b3562a` (scope, branch skew, file size, mobile analysis/tests and crypto package checks); GitHub head verified at that commit. ### Remaining acceptance Draft pending agent/device and human acceptance. Exercise interrupted one-use invite claim and retry across restart, sign-out then reopen without restoring the removed identity, rejected self-hosted pairing, iOS 16 backed-up identity recovery (including binding), and iOS 16 new setup blocked before key creation. Synthetic network/storage boundaries do not prove native durability or live server behavior. Hosted CI results are not claimed here. Optional follow-ups remain outside this patch: pruning provably unadmitted journals, malformed-record recovery, avoiding an iOS 16 dead-end device-key selection, and pre-existing removal/push-revocation ordering. This PR contains three existing-path fixes. The two organization-login fixes depend on #8700 and remain separate: its stale parent conflicts with main in unrelated relay code, and the required branch-skew hook prevents publishing that stack unchanged. This PR does not refresh that parent or change deployment policy. --------- Signed-off-by: Wes Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> --- mobile/ios/Runner/AppDelegate.swift | 4 + .../invites/invite_join_provider.dart | 29 ++- .../features/pairing/pairing_provider.dart | 3 + .../shared/community/community_storage.dart | 74 +++++- mobile/lib/shared/hosted/hosted_flow.dart | 26 ++- .../hosted/hosted_flow_problem_message.dart | 2 + mobile/lib/shared/hosted/hpke_backup.dart | 7 + mobile/lib/shared/storage/serial_queue.dart | 17 +- .../channels/deep_link_dispatcher_test.dart | 4 +- .../invites/invite_join_provider_test.dart | 214 +++++++++++++----- .../onboarding/hosted_journeys_test.dart | 57 ++++- .../pairing/pairing_provider_test.dart | 58 +++++ .../test/helpers/hosted_journey_fixture.dart | 10 +- .../community/community_storage_test.dart | 8 +- 14 files changed, 440 insertions(+), 73 deletions(-) diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 9a49b26dbe..7e208bd55a 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -90,6 +90,10 @@ import os.log browserAuthBridge = BuzzBrowserAuthBridge(messenger: messenger) hostedHpkeChannel = FlutterMethodChannel(name: "buzz/hosted_hpke", binaryMessenger: messenger) hostedHpkeChannel?.setMethodCallHandler { call, result in + if call.method == "isSupported" { + if #available(iOS 17.0, *) { result(true) } else { result(false) } + return + } guard call.method == "seal" else { result(FlutterMethodNotImplemented); return } guard #available(iOS 17.0, *) else { result(FlutterError(code: "unsupported", message: "Backup requires iOS 17 or later", details: nil)); return diff --git a/mobile/lib/features/invites/invite_join_provider.dart b/mobile/lib/features/invites/invite_join_provider.dart index a46d8a6dc9..75411095c1 100644 --- a/mobile/lib/features/invites/invite_join_provider.dart +++ b/mobile/lib/features/invites/invite_join_provider.dart @@ -125,6 +125,7 @@ class InviteJoinNotifier extends Notifier { final communities = await ref.read(communityListProvider.future); final existing = _existingCommunity(communities, invite.relayUrl); if (existing != null) { + await _retirePendingInvite(invite, existing); await ref .read(communityListProvider.notifier) .switchCommunity(existing.id); @@ -177,6 +178,7 @@ class InviteJoinNotifier extends Notifier { final communities = await ref.read(communityListProvider.future); final existing = _existingCommunity(communities, invite.relayUrl); if (existing != null) { + await _retirePendingInvite(invite, existing); await ref .read(communityListProvider.notifier) .switchCommunity(existing.id); @@ -202,7 +204,17 @@ class InviteJoinNotifier extends Notifier { return; } - final keys = ref.read(inviteKeyGeneratorProvider)(); + final storage = ref.read(communityStorageProvider); + final nsec = await storage.pendingInviteIdentity( + invite.relayUrl, + invite.code, + () => ref.read(inviteKeyGeneratorProvider)().nsec, + ); + final decodedKey = nostr.Nip19.decode(payload: nsec); + if (decodedKey.prefix != nostr.Nip19Prefix.nsec) { + throw const FormatException('Pending invite identity is unreadable'); + } + final keys = nostr.Keys(decodedKey.data); final body = jsonEncode({ 'code': invite.code, if (invite.policyReceipt != null) @@ -250,6 +262,7 @@ class InviteJoinNotifier extends Notifier { await ref .read(authProvider.notifier) .authenticateWithCommunity(community); + await _retirePendingInvite(invite, community); _pendingStarterSetupCommunity = community; state = state.copyWith(isStarterSetupRecovery: true); await startStarterSetupRecovery(); @@ -263,6 +276,20 @@ class InviteJoinNotifier extends Notifier { } } + Future _retirePendingInvite( + InviteDeepLink invite, + Community community, + ) async { + try { + await ref + .read(communityStorageProvider) + .completePendingInvite(invite.relayUrl, invite.code, community); + } catch (_) { + // The saved destination is authoritative. Reopening retries cleanup; + // community removal must erase every journal copy before it succeeds. + } + } + /// Runs the pending recovery after its progress UI has been presented. Future startStarterSetupRecovery() async { final community = _pendingStarterSetupCommunity; diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart index 5e155eeb36..0bf6d36f91 100644 --- a/mobile/lib/features/pairing/pairing_provider.dart +++ b/mobile/lib/features/pairing/pairing_provider.dart @@ -945,6 +945,9 @@ class PairingNotifier extends Notifier { _validationSocket = socket; try { await socket.connect().timeout(const Duration(seconds: 8)); + if (socket.state != SocketState.connected) { + throw StateError('Relay did not authenticate the paired identity'); + } } finally { if (identical(_validationSocket, socket)) _validationSocket = null; await socket.disconnect(); diff --git a/mobile/lib/shared/community/community_storage.dart b/mobile/lib/shared/community/community_storage.dart index 9ced276b0b..1e400fcefd 100644 --- a/mobile/lib/shared/community/community_storage.dart +++ b/mobile/lib/shared/community/community_storage.dart @@ -1,8 +1,10 @@ import 'dart:convert'; +import 'package:crypto/crypto.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../storage/secure_storage.dart'; +import '../storage/serial_queue.dart'; import 'community.dart'; @@ -71,6 +73,55 @@ class CommunityStorage { return []; } + static final _pendingInvites = SerialQueue(); + static const _pendingInvitePrefix = 'buzz_pending_invite_v1_'; + + static String _pendingInviteKey(String relayUrl, String code) { + final uri = Uri.parse(relayUrl); + final origin = uri + .replace( + scheme: ['https', 'wss'].contains(uri.scheme) ? 'https' : 'http', + ) + .origin; + return '$_pendingInvitePrefix${sha256.convert(utf8.encode(jsonEncode([origin, code])))}'; + } + + /// Retain the claim identity before sending a request with uncertain outcomes. + /// A failed read/write must never generate a replacement for an unknown key. + Future pendingInviteIdentity( + String relayUrl, + String code, + String Function() generate, + ) => _pendingInvites.run(() async { + final key = _pendingInviteKey(relayUrl, code); + final saved = await _secure.read(key: key); + if (saved != null) return saved; + final nsec = generate(); + await _secure.write(key: key, value: nsec); + if (await _secure.read(key: key) != nsec) { + throw StateError('Could not retain the pending invite identity'); + } + return nsec; + }); + + /// Retire the journal only after the destination identity is durably saved. + Future completePendingInvite( + String relayUrl, + String code, + Community community, + ) => _pendingInvites.run(() async { + final saved = await loadAll(); + if (!saved.any( + (item) => item.id == community.id && item.nsec == community.nsec, + )) { + return; + } + final key = _pendingInviteKey(relayUrl, code); + if (await _secure.read(key: key) == community.nsec) { + await _secure.delete(key: key); + } + }); + Future save(Community community) async { final all = await loadAll(); final index = all.indexWhere((w) => w.id == community.id); @@ -85,11 +136,30 @@ class CommunityStorage { /// Replaces the complete stored community list in one secure-storage write. Future saveAll(List communities) => _saveList(communities); - Future remove(String id) async { + Future remove(String id) => _pendingInvites.run(() async { final all = await loadAll(); + final removedKeys = all + .where((item) => item.id == id) + .map((item) => item.nsec) + .whereType() + .toSet(); + if (removedKeys.isNotEmpty) { + // Retire every journal copy before removing the saved community. If a + // deletion fails, keep that community so sign-out can safely retry. + final records = await _secure.readAll(); + for (final entry in records.entries) { + if (entry.key.startsWith(_pendingInvitePrefix) && + removedKeys.contains(entry.value)) { + await _secure.delete(key: entry.key); + if (await _secure.read(key: entry.key) != null) { + throw StateError('Could not remove the pending invite identity'); + } + } + } + } all.removeWhere((w) => w.id == id); await _saveList(all); - } + }); Future loadActiveId() async { return _secure.read(key: _keyActiveId); diff --git a/mobile/lib/shared/hosted/hosted_flow.dart b/mobile/lib/shared/hosted/hosted_flow.dart index 878a10ee64..dc183d5ea9 100644 --- a/mobile/lib/shared/hosted/hosted_flow.dart +++ b/mobile/lib/shared/hosted/hosted_flow.dart @@ -42,6 +42,7 @@ enum HostedFlowProblem { differentAccount, identityUnreadable, backupUnavailable, + unsupportedPlatform, invalidResponse, requestRejected, identityMismatch, @@ -508,10 +509,13 @@ class HostedFlow extends ChangeNotifier { _clearCommunityBatch(); _batchSubject = session.subject; } + final canSealBackup = + target is NostrBindDeepLink || await HpkeBackup.supportsSealing(); + if (_disposed || generation != _generation) return; var resolved = await identity.resolve( isCurrent: _isCurrent(generation), selectedPubkey: selectedPubkey ?? _pairedPubkey, - createNew: createNew, + createNew: createNew && canSealBackup, ); if (_pairedPubkey != null && resolved.pubkey != _pairedPubkey) { _set(HostedFlowStage.blocked, HostedFlowProblem.identityMismatch); @@ -520,6 +524,26 @@ class HostedFlow extends ChangeNotifier { if (target is! NostrBindDeepLink) { for (var step = 0; step < 2; step++) { if (_disposed || generation != _generation) return; + if (!canSealBackup) { + final saved = await store.read(); + if (_disposed || generation != _generation) return; + final needsNewBackup = + resolved.state == HostedIdentityState.needsSetup || + resolved.state == HostedIdentityState.needsBackup || + (resolved.state == HostedIdentityState.needsBinding && + !(saved?.hasBackupFor( + backend: HostedEnvironment.origin.origin, + subject: session.subject, + ) ?? + false)); + if (needsNewBackup) { + _set( + HostedFlowStage.blocked, + HostedFlowProblem.unsupportedPlatform, + ); + return; + } + } if (resolved.state == HostedIdentityState.needsBinding && resolved.pubkey != null) { final response = await account.post( diff --git a/mobile/lib/shared/hosted/hosted_flow_problem_message.dart b/mobile/lib/shared/hosted/hosted_flow_problem_message.dart index 5e84b6873c..c85723a5d2 100644 --- a/mobile/lib/shared/hosted/hosted_flow_problem_message.dart +++ b/mobile/lib/shared/hosted/hosted_flow_problem_message.dart @@ -26,6 +26,8 @@ String hostedFlowProblemMessage( 'Your browser signed in to a different account. Sign in to your original account on the web, then reopen setup. Your saved account and keys have been kept.', HostedFlowProblem.identityUnreadable => 'Your saved identity could not be read safely. Leave setup and recover access to this device’s secure storage before continuing.', + HostedFlowProblem.unsupportedPlatform => + 'Creating a secure backup requires iOS 17 or later. Update iOS to continue. You can still use self-hosted communities.', HostedFlowProblem.backupUnavailable => 'Secure backup is not supported on this device yet. Your identity has been kept; joining requires a confirmed backup.', HostedFlowProblem.requestRejected => diff --git a/mobile/lib/shared/hosted/hpke_backup.dart b/mobile/lib/shared/hosted/hpke_backup.dart index a497658220..cd0aa8cd84 100644 --- a/mobile/lib/shared/hosted/hpke_backup.dart +++ b/mobile/lib/shared/hosted/hpke_backup.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:buzz/shared/crypto/event_signing.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; /// Never include input, plaintext, or native exception details in this error. enum HpkeBackupFailure { invalidEnrollment, identityMismatch, unavailable } @@ -14,6 +15,12 @@ enum HpkeBackupFailure { invalidEnrollment, identityMismatch, unavailable } class HpkeBackup { static const _channel = MethodChannel('buzz/hosted_hpke'); + /// Existing backups can be recovered on iOS 16, but new ones require iOS 17. + static Future supportsSealing() async { + if (defaultTargetPlatform != TargetPlatform.iOS) return true; + return await _channel.invokeMethod('isSupported') == true; + } + Future> seal({ required Map enrollment, required String secretHex, diff --git a/mobile/lib/shared/storage/serial_queue.dart b/mobile/lib/shared/storage/serial_queue.dart index 270b9f5d23..bb74193b25 100644 --- a/mobile/lib/shared/storage/serial_queue.dart +++ b/mobile/lib/shared/storage/serial_queue.dart @@ -1,10 +1,21 @@ /// Serializes operations without letting a failed operation poison later work. class SerialQueue { - Future _tail = Future.value(); + Future? _tail; Future run(Future Function() operation) { - final result = _tail.then((_) => operation()); - _tail = result.then((_) {}, onError: (Object _, StackTrace _) {}); + final result = (_tail ?? Future.value()).then((_) => operation()); + late final Future tail; + void release() { + // An idle queue must not retain a completed future's scheduler/zone. + // A later operation may already have replaced this tail. + if (identical(_tail, tail)) _tail = null; + } + + tail = result.then( + (_) => release(), + onError: (Object _, StackTrace _) => release(), + ); + _tail = tail; return result; } } diff --git a/mobile/test/features/channels/deep_link_dispatcher_test.dart b/mobile/test/features/channels/deep_link_dispatcher_test.dart index c915222ab8..5c69d38674 100644 --- a/mobile/test/features/channels/deep_link_dispatcher_test.dart +++ b/mobile/test/features/channels/deep_link_dispatcher_test.dart @@ -827,7 +827,7 @@ void main() { ); await tester.pump(); await tester.pump(); - await recovery.started.future; + expect(recovery.started.isCompleted, isTrue); expect(find.text('Finish setting up'), findsOneWidget); expect(find.text('Finishing setup…'), findsOneWidget); @@ -893,7 +893,7 @@ void main() { ); await tester.pump(); await tester.pump(); - await recovery.started.future; + expect(recovery.started.isCompleted, isTrue); recovery.completeError(Exception('relay did not respond')); await tester.pumpAndSettle(); diff --git a/mobile/test/features/invites/invite_join_provider_test.dart b/mobile/test/features/invites/invite_join_provider_test.dart index 54fe0687c9..c92e13240e 100644 --- a/mobile/test/features/invites/invite_join_provider_test.dart +++ b/mobile/test/features/invites/invite_join_provider_test.dart @@ -676,65 +676,167 @@ void main() { }); } - test('failed claim can be retried and preserves policy receipt', () async { - final keys = nostr.Keys.generate(); - var attempts = 0; - final bodies = []; - final storage = CommunityStorage(secure: FakeSecureStorage()); - final auth = _RecordingAuthNotifier(); - final container = ProviderContainer( - overrides: [ - communityStorageProvider.overrideWithValue(storage), - authProvider.overrideWith(() => auth), - inviteKeyGeneratorProvider.overrideWithValue(() => keys), - inviteJoinRecoveryProvider.overrideWithValue( - (_) => _successfulRecovery(), - ), - inviteJoinHttpClientProvider.overrideWithValue( - http_testing.MockClient((request) async { - attempts++; - bodies.add(request.body); - if (attempts == 1) { - return http.Response(jsonEncode({'error': 'temporary'}), 503); - } - return http.Response( - jsonEncode({ - 'status': 'joined', - 'host': 'relay.example.com', - 'role': 'member', - }), - 200, - ); - }), - ), - ], - ); - addTearDown(container.dispose); - - await container - .read(inviteJoinProvider.notifier) - .prepare( - const InviteDeepLink( - relayUrl: 'wss://relay.example.com', - code: 'code', - policyReceipt: 'receipt.value', + test( + 'committed claim with lost response retries the same key after restart', + () async { + var attempts = 0; + String? claimedPubkey; + String? lastClaimPubkey; + final bodies = []; + final secure = FakeSecureStorage(); + final storage = CommunityStorage(secure: secure); + final auth = _RecordingAuthNotifier(); + ProviderContainer open() => ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue((_) async {}), + communityPushLeaseRevocationEnqueuerProvider.overrideWithValue( + (_) async => false, ), - ); - await container.read(inviteJoinProvider.notifier).confirmJoin(); - expect(container.read(inviteJoinProvider).status, InviteJoinStatus.error); + authProvider.overrideWith(() => auth), + // Keep the production fresh-key generator: a fixed fake masks key loss. + inviteJoinRecoveryProvider.overrideWithValue( + (_) => _successfulRecovery(), + ), + inviteJoinHttpClientProvider.overrideWithValue( + http_testing.MockClient((request) async { + attempts++; + bodies.add(request.body); + final event = + jsonDecode( + utf8.decode( + base64Decode( + request.headers['Authorization']!.substring( + 'Nostr '.length, + ), + ), + ), + ) + as Map; + final pubkey = event['pubkey'] as String; + lastClaimPubkey = pubkey; + if (attempts == 1) { + claimedPubkey = pubkey; + throw http.ClientException('Response lost after commit'); + } + if (pubkey != claimedPubkey) { + return http.Response( + jsonEncode({'error': 'invite exhausted'}), + 403, + ); + } + return http.Response( + jsonEncode({ + 'status': 'already_member', + 'host': 'relay.example.com', + 'role': 'member', + }), + 200, + ); + }), + ), + ], + ); + const invite = InviteDeepLink( + relayUrl: 'wss://relay.example.com', + code: 'code', + policyReceipt: 'receipt.value', + ); + var container = open(); + await container.read(inviteJoinProvider.notifier).prepare(invite); + await container.read(inviteJoinProvider.notifier).confirmJoin(); + expect(container.read(inviteJoinProvider).status, InviteJoinStatus.error); + expect(await storage.loadAll(), isEmpty); + container.dispose(); + container = open(); + addTearDown(container.dispose); + await container.read(inviteJoinProvider.notifier).prepare(invite); + await container.read(inviteJoinProvider.notifier).confirmJoin(); + expect( + container.read(inviteJoinProvider).status, + InviteJoinStatus.success, + ); + expect(attempts, 2); + expect( + bodies, + everyElement( + jsonEncode({'code': 'code', 'policy_receipt': 'receipt.value'}), + ), + ); + expect((await storage.loadAll()).single.pubkey, claimedPubkey); + expect( + (await secure.readAll()).keys.where( + (key) => key.startsWith('buzz_pending_invite_'), + ), + isEmpty, + ); + final saved = (await storage.loadAll()).single; + // A crash after saving the community can leave the journal behind. + await storage.pendingInviteIdentity( + invite.relayUrl, + invite.code, + () => saved.nsec!, + ); + await container.read(inviteJoinProvider.notifier).prepare(invite); + expect( + (await secure.readAll()).values.where((value) => value == saved.nsec), + isEmpty, + ); - await container.read(inviteJoinProvider.notifier).confirmJoin(); + // Also cover a destination saved after the confirmation page was opened. + await storage.saveAll([]); + container.invalidate(communityListProvider); + await container.read(inviteJoinProvider.notifier).prepare(invite); + await storage.saveAll([saved]); + container.invalidate(communityListProvider); + await storage.pendingInviteIdentity( + invite.relayUrl, + invite.code, + () => saved.nsec!, + ); + await container.read(inviteJoinProvider.notifier).confirmJoin(); + expect( + (await secure.readAll()).values.where((value) => value == saved.nsec), + isEmpty, + ); - expect(container.read(inviteJoinProvider).status, InviteJoinStatus.success); - expect(attempts, 2); - expect( - bodies, - everyElement( - jsonEncode({'code': 'code', 'policy_receipt': 'receipt.value'}), - ), - ); - expect(auth.authenticatedCommunities, hasLength(1)); - }); + // Any matching journal, including a different invite, must be retired on + // sign-out. Unrelated pending identities must survive. + await storage.pendingInviteIdentity( + invite.relayUrl, + invite.code, + () => saved.nsec!, + ); + await storage.pendingInviteIdentity( + invite.relayUrl, + 'another-code', + () => saved.nsec!, + ); + final unrelated = nostr.Keys.generate().nsec; + await storage.pendingInviteIdentity( + 'wss://other.example', + 'other-code', + () => unrelated, + ); + secure.failPendingInviteDeletes = true; + await expectLater( + container.read(authProvider.notifier).signOut(), + throwsStateError, + ); + expect((await storage.loadAll()).single.nsec, saved.nsec); + secure.failPendingInviteDeletes = false; + await container.read(authProvider.notifier).signOut(); + expect(await storage.loadAll(), isEmpty); + expect((await secure.readAll()).values, isNot(contains(saved.nsec))); + expect((await secure.readAll()).values, contains(unrelated)); + await container.read(inviteJoinProvider.notifier).prepare(invite); + await container.read(inviteJoinProvider.notifier).confirmJoin(); + expect(attempts, 3); + expect(lastClaimPubkey, isNot(claimedPubkey)); + expect(container.read(inviteJoinProvider).status, InviteJoinStatus.error); + expect(await storage.loadAll(), isEmpty); + }, + ); test('builds a fresh recovery with the second community identity', () async { final firstKeys = nostr.Keys.generate(); diff --git a/mobile/test/features/onboarding/hosted_journeys_test.dart b/mobile/test/features/onboarding/hosted_journeys_test.dart index 6de0e4662d..7563d9e0bc 100644 --- a/mobile/test/features/onboarding/hosted_journeys_test.dart +++ b/mobile/test/features/onboarding/hosted_journeys_test.dart @@ -24,6 +24,8 @@ import 'package:buzz/shared/relay/relay_provider.dart'; import 'package:buzz/shared/relay/media_image.dart'; import 'package:buzz/shared/utils/string_utils.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; @@ -68,6 +70,30 @@ void main() { f.bound = null; f.offerProfile = false; await f.mount(tester); + // iOS 16 stops before any identity generation, binding or admission. + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('buzz/hosted_hpke'), + (_) async => false, + ); + try { + await f.flow.signInAccount(); + expect(f.flow.problem, HostedFlowProblem.unsupportedPlatform); + expect(await f.store.read(), isNull); + expect(f.bound, isNull); + expect(f.probes, isEmpty); + } finally { + debugDefaultTargetPlatformOverride = null; + } + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('buzz/hosted_hpke'), + (call) async => call.method == 'isSupported' + ? true + : { + 'enc': Uint8List.fromList([4, ...List.filled(64, 2)]), + 'ciphertext': Uint8List(48), + }, + ); // Account restoration may resume independently of the Add choices. await f.flow.signInAccount(); await f.settle(tester); @@ -753,13 +779,36 @@ void main() { 'new device recovers and preserves existing/unreadable profile ($unreadableProfile)', (tester, f) async { await f.initialize(localIdentity: false); - f.cloudKeys.addAll([f.secondKeys, f.keys]); + // Exercise both an already-bound recovery and recovery that must bind + // its confirmed backup, without requiring native sealing in either case. + if (unreadableProfile) { + f.bound = null; + f.cloudKeys.add(f.keys); + } else { + f.cloudKeys.addAll([f.secondKeys, f.keys]); + } f.rows.addAll([f.row('one'), f.row('two')]); f.offerProfile = false; f.profileUnavailable = unreadableProfile; await f.mount(tester); // Account restoration may resume independently of the Add choices. - await f.flow.signInAccount(); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('buzz/hosted_hpke'), + (call) async { + expect( + call.method, + 'isSupported', + reason: 'Recovery must not seal a new backup', + ); + return false; + }, + ); + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + try { + await f.flow.signInAccount(); + } finally { + debugDefaultTargetPlatformOverride = null; + } await f.settle(tester); expect(f.flow.stage, HostedFlowStage.chooseCommunities); expect(f.probes, isEmpty); @@ -784,7 +833,9 @@ void main() { expect(saved.pubkey, f.keys.public); expect((await f.store.read())!.pubkey, f.keys.public); expect(f.sent('/recovery'), hasLength(1)); - expect(f.sent('/challenge'), isEmpty); + expect(f.sent('/challenge'), hasLength(unreadableProfile ? 1 : 0)); + expect(f.sent('/nsec-backups'), isEmpty); + expect(f.sent('/enrollment'), isEmpty); expect(await f.storage.loadActiveId(), saved.id); expect(find.byType(OnboardingAppEntry), findsNothing); }, diff --git a/mobile/test/features/pairing/pairing_provider_test.dart b/mobile/test/features/pairing/pairing_provider_test.dart index 42379ab795..f72ce53469 100644 --- a/mobile/test/features/pairing/pairing_provider_test.dart +++ b/mobile/test/features/pairing/pairing_provider_test.dart @@ -73,6 +73,45 @@ void main() { }, ); + test( + 'legacy validation rejects failed connection before saving, then retries', + () async { + final auth = FakeAuthNotifier(); + var connected = false; + final notifier = PairingNotifier( + validationSocketFactory: + ({ + required wsUrl, + required nsec, + required onMessage, + required onConnected, + required onDisconnected, + }) => _ValidationOutcomeSocket(() => connected), + ); + container = ProviderContainer( + overrides: [ + pairingProvider.overrideWith(() => notifier), + authProvider.overrideWith(() => auth), + ], + ); + final input = base64Url.encode( + utf8.encode( + jsonEncode({ + 'relayUrl': 'https://relay.example', + 'nsec': nostr.Keys.generate().nsec, + }), + ), + ); + await container.read(pairingProvider.notifier).pair(input); + expect(container.read(pairingProvider).status, PairingStatus.error); + expect(auth.lastCommunity, isNull); + connected = true; + await notifier.pair(input); + expect(container.read(pairingProvider).status, PairingStatus.success); + expect(auth.lastCommunity, isNotNull); + }, + ); + test('starts in idle state', () { container = createContainer(); final state = container.read(pairingProvider); @@ -1475,3 +1514,22 @@ class _PendingValidationSocket extends RelaySocket { @override Future disconnect() async => disposed = true; } + +class _ValidationOutcomeSocket extends RelaySocket { + _ValidationOutcomeSocket(this.connected) + : super( + wsUrl: 'wss://relay.example', + nsec: null, + onMessage: (_) {}, + onConnected: () {}, + onDisconnected: (_) {}, + ); + final bool Function() connected; + @override + SocketState get state => + connected() ? SocketState.connected : SocketState.disconnected; + @override + Future connect() async {} + @override + Future disconnect() async {} +} diff --git a/mobile/test/helpers/hosted_journey_fixture.dart b/mobile/test/helpers/hosted_journey_fixture.dart index a5126990ae..f371ebba0c 100644 --- a/mobile/test/helpers/hosted_journey_fixture.dart +++ b/mobile/test/helpers/hosted_journey_fixture.dart @@ -323,10 +323,12 @@ class HostedJourneyFixture { await tester.binding.setSurfaceSize(const Size(440, 1100)); tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( const MethodChannel('buzz/hosted_hpke'), - (_) async => { - 'enc': Uint8List.fromList([4, ...List.filled(64, 2)]), - 'ciphertext': Uint8List(48), - }, + (call) async => call.method == 'isSupported' + ? true + : { + 'enc': Uint8List.fromList([4, ...List.filled(64, 2)]), + 'ciphertext': Uint8List(48), + }, ); await tester.pumpWidget( WidgetHelpers.testable( diff --git a/mobile/test/shared/community/community_storage_test.dart b/mobile/test/shared/community/community_storage_test.dart index be9c404dbf..822c358de3 100644 --- a/mobile/test/shared/community/community_storage_test.dart +++ b/mobile/test/shared/community/community_storage_test.dart @@ -12,6 +12,7 @@ import 'package:buzz/shared/push/push_subscription.dart'; class FakeSecureStorage extends Fake implements FlutterSecureStorage { final Map _data = {}; final Map _writeCounts = {}; + bool failPendingInviteDeletes = false; @override Future read({ @@ -52,7 +53,12 @@ class FakeSecureStorage extends Fake implements FlutterSecureStorage { WebOptions? webOptions, AppleOptions? mOptions, WindowsOptions? wOptions, - }) async => _data.remove(key); + }) async { + if (failPendingInviteDeletes && key.startsWith('buzz_pending_invite_')) { + throw StateError('Secure storage deletion unavailable'); + } + _data.remove(key); + } @override Future> readAll({