diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts index 0cd277da39..ba4de8049d 100644 --- a/mobile/android/app/build.gradle.kts +++ b/mobile/android/app/build.gradle.kts @@ -199,6 +199,7 @@ android { dependencies { implementation("com.google.android.play:age-signals:0.0.4") implementation("androidx.appcompat:appcompat:1.6.1") + implementation("androidx.browser:browser:1.9.0") testImplementation(kotlin("test")) diff --git a/mobile/android/app/src/androidTest/kotlin/xyz/block/buzz/mobile/BrowserAuthPluginTest.kt b/mobile/android/app/src/androidTest/kotlin/xyz/block/buzz/mobile/BrowserAuthPluginTest.kt new file mode 100644 index 0000000000..be2c661a8f --- /dev/null +++ b/mobile/android/app/src/androidTest/kotlin/xyz/block/buzz/mobile/BrowserAuthPluginTest.kt @@ -0,0 +1,105 @@ +package xyz.block.buzz.mobile + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.FlutterException +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.StandardMethodCodec +import java.nio.ByteBuffer +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** Real Android Uri/Intent and registered channel; only browser launch is replaced. */ +@RunWith(AndroidJUnit4::class) +class BrowserAuthPluginTest { + @Test fun closeRetryCallbackAndLateResume() { + InstrumentationRegistry.getInstrumentation().runOnMainSync { runJourney() } + } + + private fun runJourney() { + val messenger = Messenger() + var launches = 0 + val plugin = BrowserAuthPlugin(Activity(), messenger) { launches++ } + val first = messenger.start("first") + plugin.onResume() // Initial resume is not a close. + assertEquals(0, first.replies) + plugin.onPause() + plugin.onResume() + assertEquals("cancelled", first.error) + assertEquals(1, first.replies) + + val second = messenger.start("second") + plugin.onPause() + plugin.onNewIntent(Intent(Intent.ACTION_VIEW, Uri.parse( + "xyz.block.buzz.mobile:/auth/callback?state=first&code=old", + ))) + plugin.onResume() // Return caused by the stale callback, not dismissal. + assertEquals(0, second.replies) + plugin.onPause() // The current browser can still complete. + val callback = "xyz.block.buzz.mobile:/auth/callback?state=second&code=code" + plugin.onNewIntent(Intent(Intent.ACTION_VIEW, Uri.parse(callback))) + plugin.onResume() + assertEquals(callback, second.value) + assertNull(second.error) + assertEquals(1, second.replies) + assertEquals(2, launches) + + val third = messenger.start("third") + messenger.call("cancel", null) + plugin.onPause() + plugin.onResume() + assertEquals("cancelled", third.error) + assertEquals(1, third.replies) + val fourth = messenger.start("fourth") + plugin.onPause() + plugin.onNewIntent(Intent(Intent.ACTION_VIEW, Uri.parse(callback))) + plugin.onResume() + assertEquals(0, fourth.replies) + plugin.onPause() // Returning to the browser re-arms dismissal detection. + plugin.onResume() + assertEquals("cancelled", fourth.error) + assertEquals(1, fourth.replies) + plugin.dispose() + assertNull(messenger.handler) + } + + private class Reply { + var replies = 0 + var value: Any? = null + var error: String? = null + } + + private class Messenger : BinaryMessenger { + var handler: BinaryMessenger.BinaryMessageHandler? = null + fun start(state: String) = call("start", mapOf( + "url" to "https://app.builderlab.xyz/api/goose/v1/auth/login?state=$state", + )) + fun call(method: String, arguments: Any?): Reply { + val reply = Reply() + val message = StandardMethodCodec.INSTANCE.encodeMethodCall(MethodCall(method, arguments)) + message.flip() + handler!!.onMessage(message) { response -> + reply.replies++ + response!!.flip() + try { + reply.value = StandardMethodCodec.INSTANCE.decodeEnvelope(response) + } catch (error: FlutterException) { + reply.error = error.code + } + } + return reply + } + override fun send(channel: String, message: ByteBuffer?) = Unit + override fun send(channel: String, message: ByteBuffer?, callback: BinaryMessenger.BinaryReply?) = Unit + override fun setMessageHandler(channel: String, handler: BinaryMessenger.BinaryMessageHandler?) { + assertEquals("buzz/browser_auth", channel) + this.handler = handler + } + } +} diff --git a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/BrowserAuthPlugin.kt b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/BrowserAuthPlugin.kt new file mode 100644 index 0000000000..4a72f487f6 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/BrowserAuthPlugin.kt @@ -0,0 +1,92 @@ +package xyz.block.buzz.mobile + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import androidx.browser.customtabs.CustomTabsIntent +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodChannel + +/** One browser session. Android delivers onNewIntent before onResume on return. */ +internal class BrowserAuthPlugin( + activity: Activity, + messenger: BinaryMessenger, + private val launch: (Uri) -> Unit = { uri -> + CustomTabsIntent.Builder().build().launchUrl(activity, uri) + }, +) { + private val channel = MethodChannel(messenger, "buzz/browser_auth") + private var pending: MethodChannel.Result? = null + private var state: String? = null + private var departed = false + + init { + channel.setMethodCallHandler { call, result -> + when (call.method) { + "start" -> { + val url = call.argument("url")?.let(Uri::parse) + if (url == null || url.scheme != "https" || url.host.isNullOrEmpty()) { + result.error("unavailable", "Unable to open sign-in.", null) + } else { + cancel() + pending = result + state = url.getQueryParameter("state") + departed = false + try { + launch(url) + } catch (_: Exception) { + finishError("unavailable") + } + } + } + "cancel" -> { + cancel() + result.success(null) + } + else -> result.notImplemented() + } + } + } + + fun onPause() { + if (pending != null) departed = true + } + + fun onResume() { + if (departed) cancel() + } + + fun onNewIntent(intent: Intent) { + val uri = intent.data ?: return + if (pending == null || uri.scheme != "xyz.block.buzz.mobile" || + uri.encodedSchemeSpecificPart.substringBefore('?') != "/auth/callback" + ) return + // A rejected auth callback also brings the Activity back. Its resume + // is not a browser dismissal and must not cancel the newer attempt. + departed = false + if (uri.getQueryParameter("state") != state) return + val result = pending + clear() + // Dart remains authoritative for canonical URI, state, expiry and PKCE. + result?.success(uri.toString()) + } + + private fun clear() { + pending = null + state = null + departed = false + } + + private fun finishError(code: String) { + val result = pending + clear() + result?.error(code, "Sign-in did not complete.", null) + } + + private fun cancel() = finishError("cancelled") + + fun dispose() { + cancel() + channel.setMethodCallHandler(null) + } +} 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 a27564c831..377626d737 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 @@ -134,6 +134,7 @@ class MainActivity : FlutterFragmentActivity() { private var hapticsPlugin: HapticsPlugin? = null private var hostedHpkePlugin: HostedHpkePlugin? = null private var identityFilePlugin: IdentityFilePlugin? = null + private var browserAuthPlugin: BrowserAuthPlugin? = null override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) @@ -143,6 +144,7 @@ class MainActivity : FlutterFragmentActivity() { ) hostedHpkePlugin = HostedHpkePlugin(flutterEngine.dartExecutor.binaryMessenger) identityFilePlugin = IdentityFilePlugin(this, flutterEngine.dartExecutor.binaryMessenger) + browserAuthPlugin = BrowserAuthPlugin(this, flutterEngine.dartExecutor.binaryMessenger) hapticsPlugin = HapticsPlugin(this, flutterEngine.dartExecutor.binaryMessenger) huddleMediaPlugin = HuddleMediaPlugin( @@ -203,6 +205,22 @@ class MainActivity : FlutterFragmentActivity() { } } + override fun onNewIntent(intent: Intent) { + // Resolve the browser session before resume can report cancellation. + browserAuthPlugin?.onNewIntent(intent) + super.onNewIntent(intent) + } + + override fun onPause() { + browserAuthPlugin?.onPause() + super.onPause() + } + + override fun onResume() { + super.onResume() + browserAuthPlugin?.onResume() + } + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) identityFilePlugin?.onActivityResult(requestCode, resultCode, data) @@ -218,6 +236,8 @@ class MainActivity : FlutterFragmentActivity() { } override fun onDestroy() { + browserAuthPlugin?.dispose() + browserAuthPlugin = null identityFilePlugin?.dispose() identityFilePlugin = null hostedHpkePlugin?.dispose() diff --git a/mobile/lib/features/pairing/add_community_page/add_community_connection.dart b/mobile/lib/features/pairing/add_community_page/add_community_connection.dart index a9cff8c0d2..b3b0a2dc72 100644 --- a/mobile/lib/features/pairing/add_community_page/add_community_connection.dart +++ b/mobile/lib/features/pairing/add_community_page/add_community_connection.dart @@ -151,6 +151,14 @@ class _AddCommunityConnection extends HookWidget { ), ), actions: [ + if (!enteringLink && errorMessage != null) + Semantics( + liveRegion: true, + child: Text( + errorMessage!, + style: TextStyle(color: context.colors.error), + ), + ), ExcludeSemantics( excluding: enteringLink, child: ExcludeFocus( diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart index 0bf6d36f91..7c0b0faaee 100644 --- a/mobile/lib/features/pairing/pairing_provider.dart +++ b/mobile/lib/features/pairing/pairing_provider.dart @@ -9,6 +9,7 @@ import 'package:nostr/nostr.dart' as nostr; import '../../shared/auth/auth.dart'; import '../../shared/hosted/hosted_flow_provider.dart'; +import '../../shared/hosted/account_identity_store.dart'; import '../../shared/hosted/hosted_environment.dart'; import '../../shared/community/paired_community_landing.dart'; import '../../shared/crypto/ecdh.dart'; @@ -789,7 +790,9 @@ class PairingNotifier extends Notifier { _cleanup(); state = PairingState( status: PairingStatus.error, - errorMessage: 'Failed to import credentials: $e', + errorMessage: e == AccountIdentityFailure.mismatch + ? 'This device uses a different identity. Your saved identity has been kept.' + : 'Could not import the identity. Try pairing again.', ); } } diff --git a/mobile/lib/onboarding_app_entry.dart b/mobile/lib/onboarding_app_entry.dart index a62af38b55..f350f60f7f 100644 --- a/mobile/lib/onboarding_app_entry.dart +++ b/mobile/lib/onboarding_app_entry.dart @@ -195,7 +195,7 @@ class OnboardingAppEntry extends HookConsumerWidget { ? null : () => unawaited(flow.signInAccount()), accountStatus: status, - builderlabBusy: busy, + builderlabBusy: busy && !signingIn, signInPending: signingIn || busy, builderlabLabel: signingIn ? 'Cancel sign-in' diff --git a/mobile/lib/shared/hosted/bounded_http_response.dart b/mobile/lib/shared/hosted/bounded_http_response.dart index 36ec840709..5a7505baf1 100644 --- a/mobile/lib/shared/hosted/bounded_http_response.dart +++ b/mobile/lib/shared/hosted/bounded_http_response.dart @@ -6,8 +6,9 @@ import 'package:http/http.dart' as http; /// leaving the shared client usable for sign-out and subsequent attempts. Future boundedHttpResponse( http.Client client, - http.Request source, -) async { + http.Request source, { + int maxBytes = 65536, +}) async { final abort = Completer(); final request = http.AbortableRequest( @@ -28,7 +29,7 @@ Future boundedHttpResponse( } final bytes = []; while (await iterator.moveNext()) { - if (bytes.length + iterator.current.length > 65536) { + if (bytes.length + iterator.current.length > maxBytes) { throw const FormatException('Response too large'); } bytes.addAll(iterator.current); diff --git a/mobile/lib/shared/hosted/browser_auth.dart b/mobile/lib/shared/hosted/browser_auth.dart index 7e0c7cb896..ba493f03e9 100644 --- a/mobile/lib/shared/hosted/browser_auth.dart +++ b/mobile/lib/shared/hosted/browser_auth.dart @@ -9,8 +9,6 @@ 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'; @@ -244,7 +242,8 @@ class BrowserAuth { Future cancel() async { ++_generation; _activeState = null; - if (defaultTargetPlatform == TargetPlatform.iOS) { + if (defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.android) { try { await _channel.invokeMethod('cancel'); } catch (_) { @@ -270,7 +269,8 @@ class BrowserAuth { } static Future _openPlatformBrowser(Uri uri) async { - if (defaultTargetPlatform == TargetPlatform.iOS) { + if (defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.android) { return _channel.invokeMethod('start', { 'url': uri.toString(), if (kDebugMode && @@ -278,10 +278,6 @@ class BrowserAuth { 'ephemeral': 'true', }); } - if (defaultTargetPlatform == TargetPlatform.android && - await launchUrl(uri, mode: LaunchMode.inAppBrowserView)) { - return null; - } throw BrowserAuthFailure.unavailable; } } diff --git a/mobile/lib/shared/hosted/hosted_join_policy.dart b/mobile/lib/shared/hosted/hosted_join_policy.dart index e28ca7b3b0..19f8018b54 100644 --- a/mobile/lib/shared/hosted/hosted_join_policy.dart +++ b/mobile/lib/shared/hosted/hosted_join_policy.dart @@ -29,12 +29,13 @@ enum HostedInviteFailure { /// Reads only a bounded JSON document, never follows redirects or logs bodies. Future> readJoinResponse( http.Client client, - http.Request request, -) async { + http.Request request, { + int maxBytes = 65536, +}) async { final http.Response response; final Map data; try { - response = await boundedHttpResponse(client, request); + response = await boundedHttpResponse(client, request, maxBytes: maxBytes); if (response.statusCode == 429 || (response.statusCode >= 500 && response.statusCode < 600)) { throw AccountFailure.unavailable; @@ -81,7 +82,14 @@ Future freshJoinPolicyReceipt({ request.headers['Content-Type'] = 'application/json'; request.body = jsonEncode(body); } - final data = await readJoinResponse(client, request); + // The relay permits two 256 KiB Markdown documents. Allow their JSON + // escaping (up to six bytes per input byte), plus the policy envelope. + // Receipt and claim responses retain the ordinary 64 KiB bound. + final data = await readJoinResponse( + client, + request, + maxBytes: path == '/api/join-policy' ? 4 * 1024 * 1024 : 65536, + ); checkCurrent(); return data; } diff --git a/mobile/test/features/onboarding/hosted_journeys_test.dart b/mobile/test/features/onboarding/hosted_journeys_test.dart index 7e279e0aaf..0fc4a1f6ac 100644 --- a/mobile/test/features/onboarding/hosted_journeys_test.dart +++ b/mobile/test/features/onboarding/hosted_journeys_test.dart @@ -1,3 +1,4 @@ +import '../../helpers/controllable_pairing_socket.dart'; import 'package:buzz/shared/widgets/buzz_toast.dart'; import 'package:buzz/features/onboarding/hosted_reconnect_banner.dart'; import 'package:buzz/shared/hosted/hosted_community_creation.dart'; @@ -9,6 +10,7 @@ import 'package:buzz/features/pairing/pairing_provider.dart'; import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart'; import 'dart:async'; import 'dart:convert'; +import 'package:nostr/nostr.dart' as nostr; import 'package:buzz/features/profile/profile_setup_page.dart'; import 'package:buzz/features/profile/profile_setup_avatar_editor.dart'; @@ -131,6 +133,40 @@ void main() { ); } + journey('welcome cancels browser sign-in and can retry', (tester, f) async { + await f.initialize(localIdentity: false, signedIn: false); + f.bound = null; + f.offerProfile = false; + await f.mount(tester, addingCommunity: false); + Future cancelled() async { + for (var i = 0; i < 100 && f.flow.stage != HostedFlowStage.idle; i++) { + await Future.delayed(Duration.zero); + await tester.pump(); + } + expect(f.flow.stage, HostedFlowStage.idle); + await f.settle(tester); + expect(find.text('or sign in with email').hitTestable(), findsOneWidget); + } + + final abandonedBrowser = Completer(); + f.beforeBrowserReturn = () => abandonedBrowser.future; + await f.tap(tester, 'or sign in with email'); + expect(find.text('Cancel sign-in').hitTestable(), findsOneWidget); + await f.tap(tester, 'Cancel sign-in'); + abandonedBrowser.complete(); + await cancelled(); + expect(f.flow.account.account, isNull); + f.beforeBrowserReturn = () async { + throw PlatformException(code: 'cancelled'); + }; + await f.tap(tester, 'or sign in with email'); + await cancelled(); + f.beforeBrowserReturn = null; + await f.tap(tester, 'or sign in with email'); + await f.settle(tester); + expect(find.text('Create my Buzz identity').hitTestable(), findsOneWidget); + }); + for (final importing in [false, true]) { journey( 'new account ${importing ? 'imports' : 'creates'} and keeps its identity on retry', @@ -361,9 +397,86 @@ void main() { } journey( - 'QR credentials enter without an account when domain is not required', + 'QR credentials preserve an existing hosted identity or enter an opt-out community', (tester, f) async { - if (HostedEnvironment.requiresAuth('paired.communities.buzz.xyz')) return; + if (HostedEnvironment.requiresAuth('paired.communities.buzz.xyz')) { + await f.initialize(); + final original = Community.create( + name: 'One', + relayUrl: 'https://one.communities.buzz.xyz', + pubkey: f.keys.public, + nsec: f.keys.nsec, + ); + await f.storage.save(original); + await f.storage.saveActiveId(original.id); + late ControllablePairingSocket socket; + await f.mount( + tester, + pairingSocketFactory: + ({ + required wsUrl, + required ephemeralPrivkey, + required onMessage, + required onDisconnected, + }) { + return socket = ControllablePairingSocket( + ephemeralPrivkey: ephemeralPrivkey, + onMessage: onMessage, + onDisconnected: onDisconnected, + ); + }, + ); + final transferred = nostr.Keys('2'.padLeft(64, '0')); + const sessionSecret = + 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789'; + final notifier = f.container.read(pairingProvider.notifier); + await notifier.pair( + 'nostrpair://${transferred.public}?secret=$sessionSecret&relay=wss%3A%2F%2Fpairing.buzz.xyz&v=1', + ); + notifier.setProtectSensitiveActions(false); + notifier.confirmSas(); + socket.sendSourceMessage( + sourceSecret: transferred.secret, + sessionSecretHex: sessionSecret, + message: {'type': 'sas-confirm'}, + includeTranscriptHash: true, + ); + await f.settle(tester); + socket.sendSourceMessage( + sourceSecret: transferred.secret, + sessionSecretHex: sessionSecret, + message: { + 'type': 'payload', + 'payload_type': 'credentials', + 'payload': jsonEncode({ + 'relayUrl': 'https://paired.communities.buzz.xyz', + 'nsec': transferred.nsec, + 'pubkey': transferred.public, + }), + }, + ); + await f.settle(tester); + const message = + 'This device uses a different identity. Your saved identity has been kept.'; + expect(find.text(message).hitTestable(), findsOneWidget); + expect(find.text('Scan a QR code').hitTestable(), findsOneWidget); + final announcement = find.ancestor( + of: find.text(message), + matching: find.byWidgetPredicate( + (widget) => + widget is Semantics && widget.properties.liveRegion == true, + ), + ); + expect(announcement, findsOneWidget); + final saved = (await f.storage.loadAll()).single; + expect(saved.pubkey, f.keys.public); + expect(saved.nsec, f.keys.nsec); + expect((await f.store.read())?.pubkey, f.keys.public); + await tester.tap(find.byTooltip('Back').hitTestable()); + await f.settle(tester); + expect(find.text('Community home').hitTestable(), findsOneWidget); + return; + } // Seed a previous account session: an opt-out build must not restore it. await f.initialize(localIdentity: false); f.requests.clear(); @@ -688,6 +801,20 @@ void main() { await f.link(tester, 'one'); expect((await f.store.read())!.backup, isNotNull); f.beforeUploadReturn = null; + // Production legal documents exceed the ordinary 64 KiB account limit. + f.policyResponses.add( + http.Response( + jsonEncode({ + 'policy': { + 'version': 'one.communities.buzz.xyz', + 'age_attestation_required': true, + 'terms_markdown': 'Be kind to this community.', + 'privacy_markdown': 'Public privacy notice. ' * 5000, + }, + }), + 200, + ), + ); await f.link(tester, 'one'); expect((await f.store.read())!.pubkey, retainedKey); expect(f.browserStarts, 1); diff --git a/mobile/test/features/pairing/pairing_provider_test.dart b/mobile/test/features/pairing/pairing_provider_test.dart index f72ce53469..785289f25b 100644 --- a/mobile/test/features/pairing/pairing_provider_test.dart +++ b/mobile/test/features/pairing/pairing_provider_test.dart @@ -1,3 +1,4 @@ +import '../../helpers/controllable_pairing_socket.dart'; import 'package:buzz/features/pairing/pairing_page.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:flutter/material.dart'; @@ -9,12 +10,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:local_auth/local_auth.dart'; import 'package:nostr/nostr.dart' as nostr; -import 'package:buzz/features/pairing/pairing_crypto.dart'; import 'package:buzz/features/pairing/pairing_provider.dart'; import 'package:buzz/features/pairing/pairing_socket.dart'; import 'package:buzz/shared/auth/auth.dart'; -import 'package:buzz/shared/crypto/ecdh.dart'; -import 'package:buzz/shared/crypto/nip44.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/security/sensitive_action_authorizer.dart'; @@ -261,7 +259,7 @@ void main() { '09b3065e3570a3a4054660dccd66e12774a99a904fdb0ca02dbc6c3136249506'; const sessionSecretHex = 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789'; - late _ControllableSocket socket; + late ControllablePairingSocket socket; late PairingNotifier notifier; late FakeAuthNotifier importAuth; late _FakeSensitiveActionAuthorizer authorizer; @@ -288,7 +286,7 @@ void main() { required onMessage, required onDisconnected, }) { - socket = _ControllableSocket( + socket = ControllablePairingSocket( ephemeralPrivkey: ephemeralPrivkey, onMessage: onMessage, onDisconnected: onDisconnected, @@ -738,7 +736,7 @@ void main() { '09b3065e3570a3a4054660dccd66e12774a99a904fdb0ca02dbc6c3136249506'; const sessionSecretHex = 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789'; - late _ControllableSocket socket; + late ControllablePairingSocket socket; late PairingNotifier notifier; late String recoveryCode; late _FakeSensitiveActionAuthorizer authorizer; @@ -760,7 +758,7 @@ void main() { required onMessage, required onDisconnected, }) { - socket = _ControllableSocket( + socket = ControllablePairingSocket( ephemeralPrivkey: ephemeralPrivkey, onMessage: onMessage, onDisconnected: onDisconnected, @@ -1416,86 +1414,6 @@ class _FakeSensitiveActionAuthorizer implements SensitiveActionAuthorizer { Future> enrolledBiometrics() async => const []; } -class _ControllableSocket extends PairingSocket { - final String ephemeralPrivkey; - final void Function(List message) relayMessageCallback; - final List> published = []; - bool _connected = false; - int _eventSequence = 0; - - _ControllableSocket({ - required this.ephemeralPrivkey, - required super.onMessage, - required super.onDisconnected, - }) : relayMessageCallback = onMessage, - super(wsUrl: 'ws://unused', ephemeralPrivkey: ephemeralPrivkey); - - @override - bool get isConnected => _connected; - - @override - Future connect() async => _connected = true; - - @override - void subscribe(String subId, int kind, String pubkeyHex) {} - - @override - void publishEvent(Map event) => published.add(event); - - @override - void dispose() => _connected = false; - - List> decryptedPublishedMessages(String sourceSecret) { - final key = getConversationKey( - sourceSecret, - nostr.Keys(ephemeralPrivkey).public, - ); - return published - .map( - (event) => - jsonDecode(nip44Decrypt(key, event['content'] as String)) - as Map, - ) - .toList(); - } - - void sendSourceMessage({ - required String sourceSecret, - required String sessionSecretHex, - required Map message, - bool includeTranscriptHash = false, - }) { - final source = nostr.Keys(sourceSecret); - final targetPubkey = nostr.Keys(ephemeralPrivkey).public; - final sessionSecret = hexToBytes(sessionSecretHex); - final body = Map.from(message); - if (includeTranscriptHash) { - final shared = ecdhSharedSecret(sourceSecret, targetPubkey); - final (_, sasInput) = deriveSas(shared, sessionSecret); - body['transcript_hash'] = bytesToHex( - deriveTranscriptHash( - deriveSessionId(sessionSecret), - hexToBytes(source.public), - hexToBytes(targetPubkey), - sasInput, - sessionSecret, - ), - ); - } - final key = getConversationKey(sourceSecret, targetPubkey); - final event = nostr.Event.from( - kind: 24134, - content: nip44Encrypt(key, jsonEncode(body)), - tags: [ - ['p', targetPubkey], - ], - secretKey: sourceSecret, - createdAt: 1_700_000_000 + _eventSequence++, - ); - relayMessageCallback(['EVENT', 'pair', event.toMap()]); - } -} - class _PendingValidationSocket extends RelaySocket { _PendingValidationSocket() : super( diff --git a/mobile/test/helpers/controllable_pairing_socket.dart b/mobile/test/helpers/controllable_pairing_socket.dart new file mode 100644 index 0000000000..6b6ddcb3bd --- /dev/null +++ b/mobile/test/helpers/controllable_pairing_socket.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/features/pairing/pairing_crypto.dart'; +import 'package:buzz/features/pairing/pairing_socket.dart'; +import 'package:buzz/shared/crypto/ecdh.dart'; +import 'package:buzz/shared/crypto/nip44.dart'; + +class ControllablePairingSocket extends PairingSocket { + final String ephemeralPrivkey; + final void Function(List message) relayMessageCallback; + final List> published = []; + bool _connected = false; + int _eventSequence = 0; + + ControllablePairingSocket({ + required this.ephemeralPrivkey, + required super.onMessage, + required super.onDisconnected, + }) : relayMessageCallback = onMessage, + super(wsUrl: 'ws://unused', ephemeralPrivkey: ephemeralPrivkey); + + @override + bool get isConnected => _connected; + + @override + Future connect() async => _connected = true; + + @override + void subscribe(String subId, int kind, String pubkeyHex) {} + + @override + void publishEvent(Map event) => published.add(event); + + @override + void dispose() => _connected = false; + + List> decryptedPublishedMessages(String sourceSecret) { + final key = getConversationKey( + sourceSecret, + nostr.Keys(ephemeralPrivkey).public, + ); + return published + .map( + (event) => + jsonDecode(nip44Decrypt(key, event['content'] as String)) + as Map, + ) + .toList(); + } + + void sendSourceMessage({ + required String sourceSecret, + required String sessionSecretHex, + required Map message, + bool includeTranscriptHash = false, + }) { + final source = nostr.Keys(sourceSecret); + final targetPubkey = nostr.Keys(ephemeralPrivkey).public; + final sessionSecret = hexToBytes(sessionSecretHex); + final body = Map.from(message); + if (includeTranscriptHash) { + final shared = ecdhSharedSecret(sourceSecret, targetPubkey); + final (_, sasInput) = deriveSas(shared, sessionSecret); + body['transcript_hash'] = bytesToHex( + deriveTranscriptHash( + deriveSessionId(sessionSecret), + hexToBytes(source.public), + hexToBytes(targetPubkey), + sasInput, + sessionSecret, + ), + ); + } + final key = getConversationKey(sourceSecret, targetPubkey); + final event = nostr.Event.from( + kind: 24134, + content: nip44Encrypt(key, jsonEncode(body)), + tags: [ + ['p', targetPubkey], + ], + secretKey: sourceSecret, + createdAt: 1_700_000_000 + _eventSequence++, + ); + relayMessageCallback(['EVENT', 'pair', event.toMap()]); + } +} diff --git a/mobile/test/helpers/hosted_journey_fixture.dart b/mobile/test/helpers/hosted_journey_fixture.dart index 25a7d8c30c..0fa4709430 100644 --- a/mobile/test/helpers/hosted_journey_fixture.dart +++ b/mobile/test/helpers/hosted_journey_fixture.dart @@ -319,9 +319,15 @@ class HostedJourneyFixture { Future mount( WidgetTester tester, { bool openEntry = true, + bool addingCommunity = true, PairingCredentialValidator? pairingValidator, + PairingSocketFactory? pairingSocketFactory, }) async { await tester.binding.setSurfaceSize(const Size(440, 1100)); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('buzz/browser_auth'), + (_) async => null, + ); tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( const MethodChannel('buzz/hosted_hpke'), (call) async => call.method == 'isSupported' @@ -336,9 +342,12 @@ class HostedJourneyFixture { disableAnimations: true, overrides: [ hostedFlowProvider.overrideWith(createFlow), - if (pairingValidator != null) + if (pairingValidator != null || pairingSocketFactory != null) pairingProvider.overrideWith( - () => PairingNotifier(credentialValidator: pairingValidator), + () => PairingNotifier( + credentialValidator: pairingValidator, + socketFactory: pairingSocketFactory, + ), ), if (relayAuthEnabled) ...[ hostedRelayAuthProvider.overrideWith((ref) { @@ -396,7 +405,7 @@ class HostedJourneyFixture { onPressed: () => Navigator.of(context).push( MaterialPageRoute( builder: (_) => - const OnboardingAppEntry(addingCommunity: true), + OnboardingAppEntry(addingCommunity: addingCommunity), ), ), child: const Text('Community home'), @@ -475,6 +484,10 @@ class HostedJourneyFixture { await Future.delayed(Duration.zero); await tester.pump(); PendingDeepLinkNotifier.debugUriStreamOverride = null; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('buzz/browser_auth'), + null, + ); tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( const MethodChannel('buzz/hosted_hpke'), null, diff --git a/mobile/test/shared/hosted/hosted_admission_test.dart b/mobile/test/shared/hosted/hosted_admission_test.dart index ca5dfa1d1f..b4a6ec0cdd 100644 --- a/mobile/test/shared/hosted/hosted_admission_test.dart +++ b/mobile/test/shared/hosted/hosted_admission_test.dart @@ -108,7 +108,12 @@ void main() { // Wire limits stay focused; consent/claim ordering is covered by the journey. for (final (name, response, status, failure) in [ ('redirect', '{}', 302, AccountFailure.denied), - ('oversized policy', ' ' * 65537, 200, AccountFailure.invalidResponse), + ( + 'oversized policy', + '{}${' ' * (4 * 1024 * 1024)}', + 200, + AccountFailure.invalidResponse, + ), ('invalid JSON', 'not JSON', 200, AccountFailure.invalidResponse), ('wrong shape', '[]', 200, AccountFailure.invalidResponse), ]) {