From 37693a81a4bb1d4e202a89b893f7cb4280489a29 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 15 Oct 2026 19:19:47 -0700 Subject: [PATCH] feat(mobile): extend key recovery and community onboarding Add Android HPKE sealing, bounded native key-file import, guarded export, explicit cloud-key selection, and retry-safe self-hosted setup. Route hosted pairing through account admission and preserve first-successful batch landing. Keep account eligibility failures and setup progress on the entry surface. Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Signed-off-by: Wes --- .../xyz/block/buzz/mobile/HostedHpkeBackup.kt | 136 ++++++ .../xyz/block/buzz/mobile/HostedHpkePlugin.kt | 37 ++ .../block/buzz/mobile/IdentityFilePlugin.kt | 122 ++++++ .../block/buzz/mobile/IdentityFileReader.kt | 35 ++ .../xyz/block/buzz/mobile/MainActivity.kt | 14 + .../block/buzz/mobile/HostedHpkeBackupTest.kt | 59 +++ .../block/buzz/mobile/HostedHpkePluginTest.kt | 94 ++++ .../buzz/mobile/IdentityFileReaderTest.kt | 74 ++++ mobile/ios/Runner.xcodeproj/project.pbxproj | 4 + mobile/ios/Runner/AppDelegate.swift | 2 + mobile/ios/Runner/IdentityFilePlugin.swift | 124 ++++++ .../lib/features/channels/channels_page.dart | 2 +- .../channels_page/community_switcher.dart | 2 +- .../onboarding/hosted_connect_sheet.dart | 4 +- .../onboarding/self_hosted_entry_sheet.dart | 158 +++++++ mobile/lib/features/pairing/pairing_page.dart | 6 + .../pairing_page/community_link_form.dart | 3 +- .../pairing_page/pairing_welcome_view.dart | 13 +- .../features/pairing/pairing_provider.dart | 43 +- .../lib/features/settings/settings_page.dart | 1 + .../settings_page/connection_section.dart | 17 + mobile/lib/hosted_app_gate.dart | 76 +++- mobile/lib/onboarding_app_entry.dart | 102 ++++- mobile/lib/shared/auth/auth_provider.dart | 13 +- mobile/lib/shared/hosted/browser_auth.dart | 10 + .../lib/shared/hosted/builderlab_account.dart | 26 +- .../shared/hosted/device_identity_source.dart | 10 + mobile/lib/shared/hosted/hosted_flow.dart | 82 +++- .../hosted/hosted_flow_problem_message.dart | 4 +- .../hosted/hosted_identity_service.dart | 40 +- .../lib/shared/hosted/self_hosted_entry.dart | 143 ++++++ .../identity_transfer/nsec_export_sheet.dart | 137 ++++++ .../identity_transfer/nsec_import_sheet.dart | 145 ++++++ .../identity_transfer/nsec_transfer.dart | 105 +++++ .../security/sensitive_action_authorizer.dart | 2 +- .../features/age_gate/age_gate_app_test.dart | 1 + .../invites/invite_join_provider_test.dart | 1 + .../onboarding/community_link_entry_test.dart | 4 + .../hosted_app_gate_environment_test.dart | 2 +- .../onboarding/hosted_communities_test.dart | 40 +- .../onboarding/identity_choice_test.dart | 4 +- .../onboarding_entry_routes_test.dart | 414 ++++++++++++++++++ .../pairing/pairing_hosted_handoff_test.dart | 120 +++++ .../pairing/pairing_provider_test.dart | 1 + .../helpers/hosted_communities_fixture.dart | 4 +- .../test/shared/auth/auth_provider_test.dart | 39 ++ .../hosted/builderlab_account_test.dart | 70 +++ .../hosted/hosted_identity_service_test.dart | 9 +- .../hosted_multi_backup_recovery_test.dart | 327 ++++++++++++++ .../shared/hosted/self_hosted_entry_test.dart | 312 +++++++++++++ .../nsec_import_sheet_test.dart | 72 +++ .../identity_transfer/nsec_transfer_test.dart | 180 ++++++++ .../sensitive_action_authorizer_test.dart | 2 +- mobile/tool/hpke_android/README.md | 23 + mobile/tool/hpke_android/Seal.java | 20 + mobile/tool/hpke_android/check_interop.py | 51 +++ 56 files changed, 3427 insertions(+), 114 deletions(-) create mode 100644 mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/HostedHpkeBackup.kt create mode 100644 mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/HostedHpkePlugin.kt create mode 100644 mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/IdentityFilePlugin.kt create mode 100644 mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/IdentityFileReader.kt create mode 100644 mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/HostedHpkeBackupTest.kt create mode 100644 mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/HostedHpkePluginTest.kt create mode 100644 mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/IdentityFileReaderTest.kt create mode 100644 mobile/ios/Runner/IdentityFilePlugin.swift create mode 100644 mobile/lib/features/onboarding/self_hosted_entry_sheet.dart create mode 100644 mobile/lib/shared/hosted/self_hosted_entry.dart create mode 100644 mobile/lib/shared/identity_transfer/nsec_export_sheet.dart create mode 100644 mobile/lib/shared/identity_transfer/nsec_import_sheet.dart create mode 100644 mobile/lib/shared/identity_transfer/nsec_transfer.dart create mode 100644 mobile/test/features/onboarding/onboarding_entry_routes_test.dart create mode 100644 mobile/test/features/pairing/pairing_hosted_handoff_test.dart create mode 100644 mobile/test/shared/hosted/hosted_multi_backup_recovery_test.dart create mode 100644 mobile/test/shared/hosted/self_hosted_entry_test.dart create mode 100644 mobile/test/shared/identity_transfer/nsec_import_sheet_test.dart create mode 100644 mobile/test/shared/identity_transfer/nsec_transfer_test.dart create mode 100644 mobile/tool/hpke_android/README.md create mode 100644 mobile/tool/hpke_android/Seal.java create mode 100644 mobile/tool/hpke_android/check_interop.py diff --git a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/HostedHpkeBackup.kt b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/HostedHpkeBackup.kt new file mode 100644 index 0000000000..6c4b40248c --- /dev/null +++ b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/HostedHpkeBackup.kt @@ -0,0 +1,136 @@ +package xyz.block.buzz.mobile + +import java.math.BigInteger +import java.security.AlgorithmParameters +import java.security.KeyFactory +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.interfaces.ECPublicKey +import java.security.spec.ECGenParameterSpec +import java.security.spec.ECParameterSpec +import java.security.spec.ECPoint +import java.security.spec.ECPublicKeySpec +import javax.crypto.Cipher +import javax.crypto.KeyAgreement +import javax.crypto.Mac +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** RFC 9180 base mode, DHKEM(P-256, HKDF-SHA256), HKDF-SHA256, AES-256-GCM. + * Enrollment and AAD must come from the authenticated hosted coordinator. + * Only JCA platform primitives are used; this is a single-message sender. + */ +internal object HostedHpkeBackup { + private val kemSuite = "KEM".toByteArray() + byteArrayOf(0, 16) + private val suite = "HPKE".toByteArray() + byteArrayOf(0, 16, 0, 1, 0, 2) + private val version = "HPKE-v1".toByteArray() + private val info = "buzz/nsec-backup/v1".toByteArray() + private val curve: ECParameterSpec + get() = AlgorithmParameters.getInstance("EC").apply { + init(ECGenParameterSpec("secp256r1")) + }.getParameterSpec(ECParameterSpec::class.java) + + fun seal(recipient: ByteArray, plaintext: ByteArray, aad: ByteArray): Map { + require(plaintext.size == 32 && aad.isNotEmpty()) + val ephemeral = KeyPairGenerator.getInstance("EC").apply { + initialize(ECGenParameterSpec("secp256r1")) + }.generateKeyPair() + return sealBase(recipient, plaintext, aad, info, ephemeral) + } + + // Internal seam for the RFC known-answer vector. The channel only calls seal, + // which fixes the protocol info and always generates a fresh ephemeral key. + internal fun sealBase( + recipient: ByteArray, + plaintext: ByteArray, + aad: ByteArray, + info: ByteArray, + ephemeral: KeyPair, + ): Map { + val publicKey = decodePublicKey(recipient) + val enc = encodePublicKey(ephemeral.public as ECPublicKey) + val dh = KeyAgreement.getInstance("ECDH").run { + init(ephemeral.private) + doPhase(publicKey, true) + generateSecret() + } + var shared = ByteArray(0) + var secret = ByteArray(0) + var key = ByteArray(0) + try { + val eae = extract(kemSuite, ByteArray(0), "eae_prk", dh) + try { + shared = expand(kemSuite, eae, "shared_secret", enc + recipient, 32) + } finally { + eae.fill(0) + } + val context = byteArrayOf(0) + + extract(suite, ByteArray(0), "psk_id_hash", ByteArray(0)) + + extract(suite, ByteArray(0), "info_hash", info) + secret = extract(suite, shared, "secret", ByteArray(0)) + key = expand(suite, secret, "key", context, 32) + val nonce = expand(suite, secret, "base_nonce", context, 12) + // Sequence number is zero: the first and only seal in this context. + val ciphertext = Cipher.getInstance("AES/GCM/NoPadding").run { + init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce)) + updateAAD(aad) + doFinal(plaintext) + } + return mapOf("enc" to enc, "ciphertext" to ciphertext) + } finally { + dh.fill(0) + shared.fill(0) + secret.fill(0) + key.fill(0) + } + } + + internal fun decodePublicKey(encoded: ByteArray): ECPublicKey { + require(encoded.size == 65 && encoded[0] == 4.toByte()) + val parameters = curve + val x = BigInteger(1, encoded.copyOfRange(1, 33)) + val y = BigInteger(1, encoded.copyOfRange(33, 65)) + val p = (parameters.curve.field as java.security.spec.ECFieldFp).p + // Validate the SEC1 point ourselves: provider validation differs by API. + require(x < p && y < p) + require(y.multiply(y).mod(p) == + x.multiply(x).multiply(x).add(parameters.curve.a.multiply(x)) + .add(parameters.curve.b).mod(p)) + return KeyFactory.getInstance("EC").generatePublic( + ECPublicKeySpec(ECPoint(x, y), parameters), + ) as ECPublicKey + } + + private fun encodePublicKey(key: ECPublicKey): ByteArray { + fun coordinate(value: BigInteger): ByteArray { + val bytes = value.toByteArray() + return ByteArray(32).also { + val length = minOf(bytes.size, 32) + bytes.copyInto(it, 32 - length, bytes.size - length) + } + } + return byteArrayOf(4) + coordinate(key.w.affineX) + coordinate(key.w.affineY) + } + + private fun hmac(key: ByteArray, data: ByteArray): ByteArray = + Mac.getInstance("HmacSHA256").run { + init(SecretKeySpec(if (key.isEmpty()) ByteArray(32) else key, "HmacSHA256")) + doFinal(data) + } + + private fun extract(suite: ByteArray, salt: ByteArray, label: String, input: ByteArray): ByteArray { + val labeledInput = version + suite + label.toByteArray() + input + return try { hmac(salt, labeledInput) } finally { labeledInput.fill(0) } + } + + private fun expand( + suite: ByteArray, prk: ByteArray, label: String, context: ByteArray, length: Int, + ): ByteArray { + // Every output in this fixed suite is at most one SHA-256 block. + require(length in 1..32) + val labeledInfo = byteArrayOf(0, length.toByte()) + version + suite + + label.toByteArray() + context + val block = hmac(prk, labeledInfo + byteArrayOf(1)) + return try { block.copyOf(length) } finally { block.fill(0) } + } +} diff --git a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/HostedHpkePlugin.kt b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/HostedHpkePlugin.kt new file mode 100644 index 0000000000..ab7fc3d84f --- /dev/null +++ b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/HostedHpkePlugin.kt @@ -0,0 +1,37 @@ +package xyz.block.buzz.mobile + +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +/** Native sealing bridge; never returns plaintext or provider error details. */ +internal class HostedHpkePlugin(messenger: BinaryMessenger) : MethodChannel.MethodCallHandler { + private val channel = MethodChannel( + messenger, "buzz/hosted_hpke", io.flutter.plugin.common.StandardMethodCodec.INSTANCE, + messenger.makeBackgroundTaskQueue(), + ).apply { setMethodCallHandler(this@HostedHpkePlugin) } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + if (call.method != "seal") { + result.notImplemented() + return + } + val arguments = call.arguments as? Map<*, *> + val plaintext = arguments?.get("plaintext") as? ByteArray + try { + val recipient = arguments?.get("recipient") as? ByteArray + val aad = arguments?.get("aad") as? ByteArray + if (recipient == null || plaintext == null || aad == null) { + result.error("invalid_input", null, null) + return + } + result.success(HostedHpkeBackup.seal(recipient, plaintext, aad)) + } catch (_: Exception) { + result.error("backup_unavailable", null, null) + } finally { + plaintext?.fill(0) + } + } + + fun dispose() { channel.setMethodCallHandler(null) } +} diff --git a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/IdentityFilePlugin.kt b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/IdentityFilePlugin.kt new file mode 100644 index 0000000000..6ac3004851 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/IdentityFilePlugin.kt @@ -0,0 +1,122 @@ +package xyz.block.buzz.mobile + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.util.concurrent.Executors + +/** Transfers identity files directly through the document picker, never cache. */ +internal class IdentityFilePlugin( + private val activity: Activity, + messenger: BinaryMessenger, +) : MethodChannel.MethodCallHandler { + private val channel = MethodChannel(messenger, "buzz/identity_file") + .apply { setMethodCallHandler(this@IdentityFilePlugin) } + private val worker = Executors.newSingleThreadExecutor() + private class Request(val result: MethodChannel.Result, val importing: Boolean, var bytes: ByteArray?) { + val failure get() = if (importing) "import_failed" else "export_failed" + } + private var pending: Request? = null + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + if (call.method != "export" && call.method != "import") { + result.notImplemented() + return + } + if (pending != null) { + result.error(if (call.method == "import") "import_busy" else "export_busy", null, null) + return + } + val importing = call.method == "import" + val args = call.arguments as? Map<*, *> + val text = args?.get("text") as? String + val name = args?.get("name") as? String + if (!importing && (text == null || text.length !in 1..1024 || name == null || + !name.matches(Regex("[A-Za-z0-9._-]{1,80}")))) { + result.error("invalid_input", null, null) + return + } + pending = Request(result, importing, if (importing) null else text!!.toByteArray(Charsets.UTF_8)) + try { + activity.startActivityForResult(Intent( + if (importing) Intent.ACTION_OPEN_DOCUMENT else Intent.ACTION_CREATE_DOCUMENT, + ).apply { + addCategory(Intent.CATEGORY_OPENABLE) + // Exported .nsec files may be classified as application/octet-stream. + type = if (importing) "*/*" else "text/plain" + if (!importing) putExtra(Intent.EXTRA_TITLE, name) + }, REQUEST_CODE) + } catch (_: Exception) { + finishFailure() + } + } + + fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { + if (requestCode != REQUEST_CODE) return false + val request = pending ?: return true + if (resultCode != Activity.RESULT_OK) { + clear() + request.result.success(if (request.importing) null else false) + return true + } + val uri: Uri = data?.data ?: run { + finishFailure() + return true + } + // Keep pending set until completion, preventing overlapping transfers. + val content = request.bytes + request.bytes = null + worker.execute { + var value: Any? = null + val successful = try { + if (request.importing) { + val input = activity.contentResolver.openInputStream(uri) + ?: throw IllegalStateException("Document unavailable") + value = IdentityFileReader.read(input) + } else { + requireNotNull(content) + activity.contentResolver.openOutputStream(uri, "wt")?.use { it.write(content) } + ?: throw IllegalStateException("Document unavailable") + value = true + } + true + } catch (_: Exception) { + false + } finally { + content?.fill(0) + } + activity.runOnUiThread { + if (pending === request) { + clear() + if (successful) request.result.success(value) + else request.result.error(request.failure, null, null) + } + } + } + return true + } + + private fun clear() { + pending?.bytes?.fill(0) + pending = null + } + + private fun finishFailure() { + val request = pending + clear() + request?.result?.error(request.failure, null, null) + } + + fun dispose() { + channel.setMethodCallHandler(null) + finishFailure() + worker.shutdown() + } + + companion object { + private const val REQUEST_CODE = 4627 + } +} diff --git a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/IdentityFileReader.kt b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/IdentityFileReader.kt new file mode 100644 index 0000000000..fb080e8657 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/IdentityFileReader.kt @@ -0,0 +1,35 @@ +package xyz.block.buzz.mobile + +import java.io.InputStream +import java.nio.ByteBuffer +import java.nio.charset.CodingErrorAction + +/** Reads at most one KiB plus a sentinel byte, without a plaintext cache file. */ +internal object IdentityFileReader { + fun read(input: InputStream): String = input.use { + val buffer = ByteArray(1025) + try { + var length = 0 + while (length < buffer.size) { + val count = input.read(buffer, length, buffer.size - length) + if (count < 0) break + if (count == 0) { + // Defensive progress for providers that return zero for a + // nonempty read; this still never reads past the sentinel. + val byte = input.read() + if (byte < 0) break + buffer[length++] = byte.toByte() + } else { + length += count + } + } + require(length <= 1024) { "Identity document exceeds size limit" } + Charsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(buffer, 0, length)).toString() + } finally { + buffer.fill(0) + } + } +} 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 8425affdb3..72a7da67d0 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 @@ -1,6 +1,7 @@ package xyz.block.buzz.mobile +import android.content.Intent import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Canvas @@ -131,9 +132,13 @@ class MainActivity : FlutterFragmentActivity() { private val ageSignalRequest = AgeSignalRequest() private var huddleMediaPlugin: HuddleMediaPlugin? = null private var hapticsPlugin: HapticsPlugin? = null + private var hostedHpkePlugin: HostedHpkePlugin? = null + private var identityFilePlugin: IdentityFilePlugin? = null override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) + hostedHpkePlugin = HostedHpkePlugin(flutterEngine.dartExecutor.binaryMessenger) + identityFilePlugin = IdentityFilePlugin(this, flutterEngine.dartExecutor.binaryMessenger) hapticsPlugin = HapticsPlugin(this, flutterEngine.dartExecutor.binaryMessenger) huddleMediaPlugin = HuddleMediaPlugin( @@ -194,6 +199,11 @@ class MainActivity : FlutterFragmentActivity() { } } + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + identityFilePlugin?.onActivityResult(requestCode, resultCode, data) + } + override fun onRequestPermissionsResult( requestCode: Int, permissions: Array, @@ -204,6 +214,10 @@ class MainActivity : FlutterFragmentActivity() { } override fun onDestroy() { + identityFilePlugin?.dispose() + identityFilePlugin = null + hostedHpkePlugin?.dispose() + hostedHpkePlugin = null hapticsPlugin?.dispose() hapticsPlugin = null ageSignalRequest.retire() diff --git a/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/HostedHpkeBackupTest.kt b/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/HostedHpkeBackupTest.kt new file mode 100644 index 0000000000..cd63297a03 --- /dev/null +++ b/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/HostedHpkeBackupTest.kt @@ -0,0 +1,59 @@ +package xyz.block.buzz.mobile + +import java.math.BigInteger +import java.security.KeyFactory +import java.security.KeyPair +import java.security.spec.ECPrivateKeySpec +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse + +class HostedHpkeBackupTest { + // RFC 9180 reference vectors: cfrg/draft-irtf-cfrg-hpke/test-vectors.json, + // mode=0, kem_id=16, kdf_id=1, aead_id=2, sequence number zero. + @Test fun `RFC 9180 base mode known answer`() { + val publicKey = HostedHpkeBackup.decodePublicKey(hex( + "04c06b4f6bebc7bb495cb797ab753f911aff80aefb86fd8b6fcc35525f3ab5f03e0b" + + "21bd31a86c6048af3cb2d98e0d3bf01da5cc4c39ff5370d331a4f1f7d5a4e0", + )) + val privateKey = KeyFactory.getInstance("EC").generatePrivate(ECPrivateKeySpec( + BigInteger("90345e3a1d116c1dd39ae76d95ab858c142223a63e44f8f85318cfa91a84858e", 16), + publicKey.params, + )) + val recipient = hex( + "04abc7e49a4c6b3566d77d0304addc6ed0e98512ffccf505e6a8e3eb25c685136f8" + + "53148544876de76c0f2ef99cdc3a05ccf5ded7860c7c021238f9e2073d2356c", + ) + val result = HostedHpkeBackup.sealBase( + recipient, hex("4265617574792069732074727574682c20747275746820626561757479"), + hex("436f756e742d30"), hex("4f6465206f6e2061204772656369616e2055726e"), + KeyPair(publicKey, privateKey), + ) + assertContentEquals(hex( + "58c61a45059d0c5704560e9d88b564a8b63f1364b8d1fcb3c4c6ddc1d291742465e902cd216f8908da49f8f96f", + ), result["ciphertext"]) + } + + @Test fun `backup sender validates input and generates fresh encapsulation`() { + val first = HostedHpkeBackup.seal(recipient, ByteArray(32) { 0x42 }, byteArrayOf(1)) + val second = HostedHpkeBackup.seal(recipient, ByteArray(32) { 0x42 }, byteArrayOf(1)) + assertEquals(65, first.getValue("enc").size) + assertEquals(48, first.getValue("ciphertext").size) + assertFalse(first.getValue("enc").contentEquals(second.getValue("enc"))) + for (bad in listOf(ByteArray(65), recipient.copyOf(33), recipient.copyOf().apply { this[64] = 0 })) { + assertFailsWith { HostedHpkeBackup.seal(bad, ByteArray(32), byteArrayOf(1)) } + } + assertFailsWith { HostedHpkeBackup.seal(recipient, ByteArray(31), byteArrayOf(1)) } + assertFailsWith { HostedHpkeBackup.seal(recipient, ByteArray(32), ByteArray(0)) } + } + + companion object { + fun hex(value: String) = value.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + val recipient = hex( + "04c5b6519400186a8e10abbdc05d3e450c8612e5dba06927091527110324acfb0371" + + "d98d662395d872fae967fe75ef24db20a7c2c1af19c34422d94f629b1b1dad", + ) + } +} diff --git a/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/HostedHpkePluginTest.kt b/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/HostedHpkePluginTest.kt new file mode 100644 index 0000000000..b28a8c0499 --- /dev/null +++ b/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/HostedHpkePluginTest.kt @@ -0,0 +1,94 @@ +package xyz.block.buzz.mobile + +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.StandardMethodCodec +import java.nio.ByteBuffer +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class HostedHpkePluginTest { + @Test fun `registered channel seals and clears native plaintext`() { + val messenger = Messenger() + val plugin = HostedHpkePlugin(messenger) + assertEquals("buzz/hosted_hpke", messenger.channel) + assertNotNull(messenger.queue) + val secret = ByteArray(32) { 0x42 } + val reply = Reply() + plugin.onMethodCall(MethodCall("seal", mapOf( + "recipient" to HostedHpkeBackupTest.recipient, "plaintext" to secret, "aad" to byteArrayOf(1), + )), reply) + assertNull(reply.code) + val envelope = reply.value as Map<*, *> + assertEquals(65, (envelope["enc"] as ByteArray).size) + assertEquals(48, (envelope["ciphertext"] as ByteArray).size) + assertContentEquals(ByteArray(32), secret) + // Traverse the actual registered codec handler for a successful seal. + val encoded = StandardMethodCodec.INSTANCE.encodeMethodCall(MethodCall("seal", mapOf( + "recipient" to HostedHpkeBackupTest.recipient, + "plaintext" to ByteArray(32) { 0x42 }, "aad" to byteArrayOf(1), + ))) + encoded.flip() + var dispatched = false + messenger.handler!!.onMessage(encoded) { + it!!.flip() + val sealed = StandardMethodCodec.INSTANCE.decodeEnvelope(it) as Map<*, *> + assertEquals(48, (sealed["ciphertext"] as ByteArray).size) + dispatched = true + } + assertEquals(true, dispatched) + val call = StandardMethodCodec.INSTANCE.encodeMethodCall(MethodCall("unknown", null)) + call.flip() + messenger.handler!!.onMessage(call) { assertNull(it) } + plugin.dispose() + assertNull(messenger.handler) + } + + @Test fun `invalid input is redacted and plaintext cleared on failure`() { + val plugin = HostedHpkePlugin(Messenger()) + for (recipient in listOf(null, ByteArray(65))) { + val secret = ByteArray(32) { 0x42 } + val reply = Reply() + plugin.onMethodCall(MethodCall("seal", mapOf( + "recipient" to recipient, "plaintext" to secret, "aad" to byteArrayOf(1), + )), reply) + assertEquals(if (recipient == null) "invalid_input" else "backup_unavailable", reply.code) + assertNull(reply.value) + assertContentEquals(ByteArray(32), secret) + } + } + + private class Reply : MethodChannel.Result { + var value: Any? = null + var code: String? = null + override fun success(result: Any?) { value = result } + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + code = errorCode + assertNull(errorMessage) + assertNull(errorDetails) + } + override fun notImplemented() { error("Unexpected missing method") } + } + + private class Messenger : BinaryMessenger { + var channel: String? = null + var handler: BinaryMessenger.BinaryMessageHandler? = null + var queue: BinaryMessenger.TaskQueue? = null + override fun makeBackgroundTaskQueue(options: BinaryMessenger.TaskQueueOptions) = + object : BinaryMessenger.TaskQueue {} + 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?) { + this.channel = channel + this.handler = handler + } + override fun setMessageHandler(channel: String, handler: BinaryMessenger.BinaryMessageHandler?, taskQueue: BinaryMessenger.TaskQueue?) { + this.queue = taskQueue + setMessageHandler(channel, handler) + } + } +} diff --git a/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/IdentityFileReaderTest.kt b/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/IdentityFileReaderTest.kt new file mode 100644 index 0000000000..97ccedef8b --- /dev/null +++ b/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/IdentityFileReaderTest.kt @@ -0,0 +1,74 @@ +package xyz.block.buzz.mobile + +import java.io.ByteArrayInputStream +import java.io.IOException +import java.io.InputStream +import java.nio.charset.CharacterCodingException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class IdentityFileReaderTest { + @Test fun `reads exact limit and closes document`() { + val input = CountingInput(ByteArray(1024) { 'a'.code.toByte() }) + assertEquals("a".repeat(1024), IdentityFileReader.read(input)) + assertEquals(1024, input.consumed) + assertTrue(input.closed) + } + + @Test fun `oversized document reads only sentinel and closes`() { + for (size in listOf(1025, 1000000)) { + val input = CountingInput(ByteArray(size) { 'a'.code.toByte() }) + assertFailsWith { IdentityFileReader.read(input) } + assertEquals(1025, input.consumed) + assertTrue(input.closed) + } + } + + @Test fun `short provider reads do not truncate document`() { + val input = CountingInput("nsec-test\n".toByteArray(), chunk = 1) + assertEquals("nsec-test\n", IdentityFileReader.read(input)) + assertTrue(input.closed) + } + + @Test fun `zero-length provider reads still make bounded progress`() { + var consumed = 0 + var closed = false + var supplied: ByteArray? = null + val input = object : InputStream() { + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + supplied = buffer + return 0 + } + override fun read(): Int { consumed++; return 'a'.code } + override fun close() { closed = true } + } + assertFailsWith { IdentityFileReader.read(input) } + assertEquals(1025, consumed) + assertTrue(closed) + assertTrue(supplied!!.all { it == 0.toByte() }) + } + + @Test fun `invalid UTF8 and read failures reject and close`() { + val malformed = CountingInput(byteArrayOf(0xc3.toByte(), 0x28)) + assertFailsWith { IdentityFileReader.read(malformed) } + assertTrue(malformed.closed) + var closed = false + val failing = object : InputStream() { + override fun read(): Int = throw IOException("provider detail must not escape channel") + override fun close() { closed = true } + } + assertFailsWith { IdentityFileReader.read(failing) } + assertTrue(closed) + } + + private class CountingInput(bytes: ByteArray, private val chunk: Int = Int.MAX_VALUE) : + ByteArrayInputStream(bytes) { + var consumed = 0 + var closed = false + override fun read(buffer: ByteArray, offset: Int, length: Int): Int = + super.read(buffer, offset, minOf(length, chunk)).also { if (it > 0) consumed += it } + override fun close() { closed = true; super.close() } + } +} diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 7407d5f586..272ca42b36 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + BBAA00000000000000000011 /* IdentityFilePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBAA00000000000000000012 /* IdentityFilePlugin.swift */; }; BBAA00000000000000000001 /* HostedHpkeBackup.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBAA00000000000000000002 /* HostedHpkeBackup.swift */; }; 4A71C0192F40D00100A17E02 /* MeAvatarLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01A2F40D00100A17E02 /* MeAvatarLoader.swift */; }; C0AF00000000000000000001 /* NativeConfirmationDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AF00000000000000000002 /* NativeConfirmationDialog.swift */; }; @@ -95,6 +96,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + BBAA00000000000000000012 /* IdentityFilePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityFilePlugin.swift; sourceTree = ""; }; BBAA00000000000000000002 /* HostedHpkeBackup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostedHpkeBackup.swift; sourceTree = ""; }; 4A71C01A2F40D00100A17E02 /* MeAvatarLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeAvatarLoader.swift; sourceTree = ""; }; C0AF00000000000000000002 /* NativeConfirmationDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeConfirmationDialog.swift; sourceTree = ""; }; @@ -275,6 +277,7 @@ AB1200122F40400100A17E01 /* NativeReactionDetails.swift */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, BBAA00000000000000000002 /* HostedHpkeBackup.swift */, + BBAA00000000000000000012 /* IdentityFilePlugin.swift */, BZZ00000000000000000024 /* PushNativeState.swift */, BZZ00000000000000000027 /* PushEndpointGrantStore.swift */, BZZ0000000000000000002A /* PushSnapshotBridge.swift */, @@ -614,6 +617,7 @@ AB1200022F40400100A17E01 /* NativeReactionDetails.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, BBAA00000000000000000001 /* HostedHpkeBackup.swift in Sources */, + BBAA00000000000000000011 /* IdentityFilePlugin.swift in Sources */, BZZ00000000000000000023 /* PushNativeState.swift in Sources */, BZZ00000000000000000026 /* PushEndpointGrantStore.swift in Sources */, BZZ00000000000000000029 /* PushSnapshotBridge.swift in Sources */, diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 586943f4c1..b4cd9b1adb 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -46,6 +46,7 @@ import os.log private var nativeConfirmationDialogCoordinator: NativeConfirmationDialogCoordinator? private var nativeProfileTextEditorCoordinator: NativeProfileTextEditorCoordinator? private var nativeMessageActionSurfaceSupportChannel: FlutterMethodChannel? + private var identityFilePlugin: IdentityFilePlugin? private var hostedHpkeChannel: FlutterMethodChannel? private var browserAuthBridge: BuzzBrowserAuthBridge? private var huddleMediaPlugin: HuddleMediaPlugin? @@ -67,6 +68,7 @@ import os.log } let messenger = engineBridge.applicationRegistrar.messenger() + identityFilePlugin = IdentityFilePlugin(messenger: messenger) browserAuthBridge = BuzzBrowserAuthBridge(messenger: messenger) hostedHpkeChannel = FlutterMethodChannel(name: "buzz/hosted_hpke", binaryMessenger: messenger) hostedHpkeChannel?.setMethodCallHandler { call, result in diff --git a/mobile/ios/Runner/IdentityFilePlugin.swift b/mobile/ios/Runner/IdentityFilePlugin.swift new file mode 100644 index 0000000000..adb1a6adc7 --- /dev/null +++ b/mobile/ios/Runner/IdentityFilePlugin.swift @@ -0,0 +1,124 @@ +import Flutter +import UIKit +import UniformTypeIdentifiers + +/// Reads the user-selected document in place; never imports a plaintext copy +/// into Buzz's sandbox. The security-scoped grant lasts only for the read. +final class IdentityFilePlugin: NSObject, UIDocumentPickerDelegate, + UIAdaptivePresentationControllerDelegate +{ + private let channel: FlutterMethodChannel + private var pending: FlutterResult? + private var requestID: UUID? + + init(messenger: FlutterBinaryMessenger) { + channel = FlutterMethodChannel(name: "buzz/identity_file", binaryMessenger: messenger) + super.init() + channel.setMethodCallHandler { [weak self] call, result in + guard let self else { + result(FlutterError(code: "import_failed", message: nil, details: nil)) + return + } + guard call.method == "import" else { + result(FlutterMethodNotImplemented) + return + } + self.pick(result: result) + } + } + + private func pick(result: @escaping FlutterResult) { + guard pending == nil else { + result(FlutterError(code: "import_busy", message: nil, details: nil)) + return + } + // The implicit Flutter engine can initialize before a view controller is + // attached. Resolve the live scene at the user action, never cache the + // registrar's possibly-nil launch-time view controller. + let root = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { $0.activationState == .foregroundActive } + .flatMap(\.windows) + .first(where: \.isKeyWindow)?.rootViewController + guard var top = root else { + result(FlutterError(code: "import_failed", message: nil, details: nil)) + return + } + while true { + if let presented = top.presentedViewController { top = presented } + else if let navigation = top as? UINavigationController, + let visible = navigation.visibleViewController { top = visible } + else if let tabs = top as? UITabBarController, + let selected = tabs.selectedViewController { top = selected } + else { break } + } + guard !top.isBeingDismissed, top.viewIfLoaded?.window != nil else { + result(FlutterError(code: "import_failed", message: nil, details: nil)) + return + } + let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.item], asCopy: false) + picker.allowsMultipleSelection = false + picker.delegate = self + pending = result + requestID = UUID() + top.present(picker, animated: true) + picker.presentationController?.delegate = self + } + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let url = urls.first, urls.count == 1 else { + finish(FlutterError(code: "import_failed", message: nil, details: nil)) + return + } + guard let requestID else { return } + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + var coordinationError: NSError? + var text: String? + NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &coordinationError) { accessible in + text = Self.readBounded(accessible) + } + let value: Any? = coordinationError == nil && text != nil + ? text : FlutterError(code: "import_failed", message: nil, details: nil) + DispatchQueue.main.async { + guard self?.requestID == requestID else { return } + self?.finish(value) + } + } + } + + /// Reads at most 1025 bytes, including the one-byte oversize sentinel. + private static func readBounded(_ url: URL) -> String? { + guard let stream = InputStream(url: url) else { return nil } + stream.open() + var bytes = [UInt8](repeating: 0, count: 1025) + defer { + stream.close() + bytes.withUnsafeMutableBytes { $0.initializeMemory(as: UInt8.self, repeating: 0) } + } + var count = 0 + while count < bytes.count { + let remaining = bytes.count - count + let read = bytes.withUnsafeMutableBufferPointer { buffer in + guard let base = buffer.baseAddress else { return -1 } + return stream.read(base.advanced(by: count), maxLength: remaining) + } + if read < 0 { return nil } + if read == 0 { break } + count += read + } + guard count > 0, count <= 1024 else { return nil } + return String(bytes: bytes.prefix(count), encoding: .utf8) + } + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { finish(nil) } + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { finish(nil) } + + private func finish(_ value: Any?) { + let result = pending + pending = nil + requestID = nil + result?(value) + } +} diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 91c2225ac2..7405ae80af 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -40,7 +40,7 @@ import '../profile/profile_provider.dart'; import '../profile/presence_cache_provider.dart'; import '../../shared/identity_names/identity_names_provider.dart'; import '../../shared/profile/user_cache_provider.dart'; -import '../pairing/pairing_page.dart'; +import '../../onboarding_app_entry.dart'; import '../pairing/pairing_provider.dart'; import 'channel.dart'; import 'channel_actions_sheet.dart'; diff --git a/mobile/lib/features/channels/channels_page/community_switcher.dart b/mobile/lib/features/channels/channels_page/community_switcher.dart index 18796dc8b2..7a54904805 100644 --- a/mobile/lib/features/channels/channels_page/community_switcher.dart +++ b/mobile/lib/features/channels/channels_page/community_switcher.dart @@ -247,7 +247,7 @@ class _CommunitySwitcherPage extends HookConsumerWidget { unawaited( navigator.push( MaterialPageRoute( - builder: (_) => const PairingPage(addingCommunity: true), + builder: (_) => const OnboardingAppEntry(addingCommunity: true), ), ), ); diff --git a/mobile/lib/features/onboarding/hosted_connect_sheet.dart b/mobile/lib/features/onboarding/hosted_connect_sheet.dart index 8d2b1bf6e2..9742c5337a 100644 --- a/mobile/lib/features/onboarding/hosted_connect_sheet.dart +++ b/mobile/lib/features/onboarding/hosted_connect_sheet.dart @@ -29,7 +29,9 @@ class HostedConnectSheet extends HookConsumerWidget { '${flow.addingCommunityName} is already saved with a different identity. ' 'Keep that identity and skip this addition, or return to your choices?', HostedFlowStage.chooseIdentity => - flow.identityChoices.any((choice) => choice.fromCloud) + flow.identityChoices.where((choice) => choice.fromCloud).length > 1 + ? 'Choose a backed-up identity to connect to Builderlab. Your other backups and saved community keys will be kept. This app cannot change the selected Builderlab identity later.' + : 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.', diff --git a/mobile/lib/features/onboarding/self_hosted_entry_sheet.dart b/mobile/lib/features/onboarding/self_hosted_entry_sheet.dart new file mode 100644 index 0000000000..9e2f9c49ae --- /dev/null +++ b/mobile/lib/features/onboarding/self_hosted_entry_sheet.dart @@ -0,0 +1,158 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/auth/auth.dart'; +import '../../shared/hosted/device_identity_source.dart'; +import '../../shared/hosted/self_hosted_entry.dart'; +import '../../shared/identity_transfer/nsec_import_sheet.dart'; +import '../../shared/theme/theme.dart'; +import '../../shared/utils/string_utils.dart'; +import '../../shared/widgets/modal_presentation.dart'; + +/// Local preparation seam; the production service validates and retains keys. +final selfHostedEntryProvider = Provider( + (ref) => SelfHostedEntry(), +); + +/// Uses an explicit identity choice without involving a hosted account. +Future showSelfHostedEntry( + BuildContext context, { + required String relay, + required String name, +}) async => + await showBuzzModalBottomSheet( + context: context, + title: 'Join community', + isScrollControlled: true, + useRootNavigator: true, + useSafeArea: true, + builder: (_) => SelfHostedEntrySheet(relay: relay, name: name), + ) ?? + false; + +class SelfHostedEntrySheet extends HookConsumerWidget { + const SelfHostedEntrySheet({ + super.key, + required this.relay, + required this.name, + }); + final String relay; + final String name; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final service = ref.watch(selfHostedEntryProvider); + final choices = useFuture(useMemoized(service.choices)); + final busy = useState(false); + final error = useState(null); + bool mounted() => context.mounted; + Future connect({ + String? pubkey, + String? nsec, + bool create = false, + bool close = true, + }) async { + if (busy.value) return; + busy.value = true; + error.value = null; + try { + final community = await service.prepare( + relay: relay, + name: name, + savedPubkey: pubkey, + nsec: nsec, + create: create, + isCurrent: mounted, + ); + if (!mounted()) return; + await ref + .read(authProvider.notifier) + .authenticateWithCommunity(community, shouldContinue: mounted); + if (close && context.mounted) Navigator.of(context).pop(true); + } catch (_) { + if (mounted()) { + error.value = + 'Could not join. Check this community accepts your identity, then try again. Your saved keys have been kept.'; + } + rethrow; + } finally { + if (mounted()) busy.value = false; + } + } + + Future select({String? pubkey, bool create = false}) async { + try { + await connect(pubkey: pubkey, create: create); + } catch (_) {} + } + + final saved = choices.data ?? const []; + return Padding( + padding: const EdgeInsets.all(Grid.gutter), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(Uri.parse(relay).host, style: context.textTheme.titleMedium), + const SizedBox(height: Grid.sm), + const Text( + 'Choose the identity to use in this community. Your key stays on this device; Builderlab is not required.', + ), + const SizedBox(height: Grid.sm), + if (choices.hasError) + const Text( + 'Saved identities could not be read. Unlock your device and try again.', + ), + for (final identity in saved) + OutlinedButton( + onPressed: busy.value + ? null + : () => select(pubkey: identity.pubkey), + child: Column( + children: [ + Text(identity.label), + Text(fullNpub(identity.pubkey) ?? identity.pubkey), + ], + ), + ), + if (choices.connectionState == ConnectionState.done && + !choices.hasError) ...[ + OutlinedButton( + onPressed: busy.value + ? null + : () async { + final imported = await showNsecImportSheet( + context, + onImport: (nsec) => connect(nsec: nsec, close: false), + ); + if (imported && context.mounted) { + Navigator.of(context).pop(true); + } + }, + child: const Text('Import a Nostr key'), + ), + if (saved.isEmpty) + FilledButton( + onPressed: busy.value ? null : () => select(create: true), + child: const Text('Create an identity and join'), + ), + if (saved.isEmpty) + const Text( + 'Export a backup in Settings after joining. This device will hold your only copy until you back it up.', + ), + ], + if (busy.value) + const Padding( + padding: EdgeInsets.all(Grid.sm), + child: Text('Joining…'), + ), + if (error.value != null) + Text(error.value!, style: TextStyle(color: context.colors.error)), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index ac0642f80c..d5afb8c008 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -48,7 +48,9 @@ class PairingPage extends HookConsumerWidget { final bool enteringCommunityLink; final VoidCallback? onCommunityLink; final VoidCallback? onBuilderlab; + final VoidCallback? onImportKey; final String? accountStatus; + final Widget? setupContent; final String builderlabLabel; const PairingPage({ @@ -62,7 +64,9 @@ class PairingPage extends HookConsumerWidget { this.enteringCommunityLink = false, this.onCommunityLink, this.onBuilderlab, + this.onImportKey, this.accountStatus, + this.setupContent, this.builderlabLabel = 'Connect to Builderlab', }); @@ -266,7 +270,9 @@ class PairingPage extends HookConsumerWidget { ref.read(pendingDeepLinkProvider.notifier).open(uri); }, onBuilderlab: onBuilderlab, + onImportKey: onImportKey, accountStatus: accountStatus, + setupContent: setupContent, builderlabLabel: builderlabLabel, codeController: codeController, isBusy: isBusy, diff --git a/mobile/lib/features/pairing/pairing_page/community_link_form.dart b/mobile/lib/features/pairing/pairing_page/community_link_form.dart index 716e029bb6..015f208c32 100644 --- a/mobile/lib/features/pairing/pairing_page/community_link_form.dart +++ b/mobile/lib/features/pairing/pairing_page/community_link_form.dart @@ -19,7 +19,7 @@ class _CommunityLinkForm extends HookConsumerWidget { !flow.accountOnly && (flow.stage == HostedFlowStage.working || flow.stage == HostedFlowStage.signIn); - final failed = ownsFlow && flow.problem != null; + final failed = ownsFlow && !flow.accountOnly && flow.problem != null; void submit() { if (busy || (failed && !flow.canRetry)) return; @@ -80,7 +80,6 @@ class _CommunityLinkForm extends HookConsumerWidget { style: context._onboardingGhostButtonStyle, onPressed: () { unawaited(flow.leave()); - ref.read(hostedInlineEntryProvider.notifier).setActive(false); onBack?.call(); }, child: const Text('Back'), diff --git a/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart b/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart index 5ab6b86cd7..72de5432ab 100644 --- a/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart +++ b/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart @@ -15,7 +15,9 @@ class _PairingWelcomeView extends StatelessWidget { final bool enteringCommunityLink; final VoidCallback? onCommunityLink; final VoidCallback? onBuilderlab; + final VoidCallback? onImportKey; final String? accountStatus; + final Widget? setupContent; final String builderlabLabel; final ValueChanged onOpenCommunity; @@ -35,7 +37,9 @@ class _PairingWelcomeView extends StatelessWidget { this.enteringCommunityLink = false, this.onCommunityLink, this.onBuilderlab, + this.onImportKey, this.accountStatus, + this.setupContent, this.builderlabLabel = 'Connect to Builderlab', }); @@ -101,6 +105,11 @@ class _PairingWelcomeView extends StatelessWidget { onPressed: isBusy ? null : onAlreadyUseBuzz, child: const Text('Use pairing code'), ), + TextButton( + style: context._onboardingGhostButtonStyle, + onPressed: isBusy ? null : onImportKey, + child: const Text('Import a Nostr key'), + ), ] else ...[ if (!connectionLanding) ...[ FilledButton( @@ -293,7 +302,9 @@ class _PairingWelcomeView extends StatelessWidget { constraints: const BoxConstraints(maxWidth: 440), child: SizedBox( width: double.infinity, - child: !connectionLanding + child: setupContent != null + ? setupContent! + : !connectionLanding ? actions(2) : ClipRect( child: Stack( diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart index 0da7b129a3..a40ff042ba 100644 --- a/mobile/lib/features/pairing/pairing_provider.dart +++ b/mobile/lib/features/pairing/pairing_provider.dart @@ -8,6 +8,8 @@ import 'package:http/http.dart' as http; import 'package:nostr/nostr.dart' as nostr; import '../../shared/auth/auth.dart'; +import '../../hosted_app_gate.dart'; +import '../../shared/hosted/hosted_environment.dart'; import '../../shared/community/paired_community_landing.dart'; import '../../shared/crypto/ecdh.dart'; import '../../shared/crypto/nip44.dart'; @@ -737,16 +739,6 @@ class PairingNotifier extends Notifier { _validateRelayUrl(relayUrl); state = state.copyWith(destinationRelayUrl: relayUrl); - // Validate credentials against the relay via NIP-42 WS handshake. - final credentialValidator = _credentialValidator ?? _validateCredentials; - await credentialValidator(relayUrl: relayUrl, nsec: nsec); - if (pairingGeneration != _pairingGeneration || - state.status != PairingStatus.storing || - _sendIdentityToSource) { - return; - } - - // Store only after the shared community-entry requirements pass. final community = Community.create( name: Community.nameFromUrl(relayUrl), relayUrl: relayUrl, @@ -756,6 +748,24 @@ class PairingNotifier extends Notifier { ? SensitiveActionPolicy.enabled : SensitiveActionPolicy.disabledByUser, ); + bool current() => + pairingGeneration == _pairingGeneration && + state.status == PairingStatus.storing && + !_sendIdentityToSource; + if (HostedEnvironment.isReservedHost(Uri.parse(relayUrl).host)) { + final flow = ref.read(hostedFlowProvider); + await flow.stagePairedCommunity(community, isCurrent: current); + if (!current()) return; + // NIP-AB complete acknowledges durable transfer, not hosted admission. + _sendComplete(true); + _cleanup(); + state = const PairingState(); + unawaited(flow.retry()); + return; + } + final credentialValidator = _credentialValidator ?? _validateCredentials; + await credentialValidator(relayUrl: relayUrl, nsec: nsec); + if (!current()) return; await ref .read(authProvider.notifier) .authenticateWithCommunity( @@ -869,6 +879,19 @@ class PairingNotifier extends Notifier { try { final community = _parseLegacyInput(rawInput); + if (HostedEnvironment.isReservedHost( + Uri.parse(community.relayUrl).host, + )) { + final flow = ref.read(hostedFlowProvider); + await flow.stagePairedCommunity( + community, + isCurrent: () => generation == _pairingGeneration, + ); + if (generation != _pairingGeneration) return; + state = const PairingState(); + unawaited(flow.retry()); + return; + } final validator = _credentialValidator ?? _validateCredentials; await validator(relayUrl: community.relayUrl, nsec: community.nsec); diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index 4d9199be74..bbf6ea4a97 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -10,6 +10,7 @@ import 'package:nostr/nostr.dart' as nostr; import 'package:package_info_plus/package_info_plus.dart'; import '../../shared/auth/auth.dart'; +import '../../shared/identity_transfer/nsec_export_sheet.dart'; import '../../shared/clipboard_utils.dart'; import '../../shared/success_haptic.dart'; import '../../shared/push/push_relay_capability_provider.dart'; diff --git a/mobile/lib/features/settings/settings_page/connection_section.dart b/mobile/lib/features/settings/settings_page/connection_section.dart index b45860012d..b02ecd2cd6 100644 --- a/mobile/lib/features/settings/settings_page/connection_section.dart +++ b/mobile/lib/features/settings/settings_page/connection_section.dart @@ -19,6 +19,23 @@ class _ConnectionSection extends ConsumerWidget { return AppListCard( verticalPadding: Grid.twelve, children: [ + AppListRow( + title: 'Export private key', + subtitle: 'Save an nsec backup of this community identity', + trailing: const _RowChevron(), + onTap: () => showNsecExportSheet( + context, + community: community, + isCurrent: () { + if (!context.mounted) return false; + final current = ref.read(authProvider).value?.community; + return current?.id == community.id && + current?.nsec == community.nsec && + current?.sensitiveActionPolicy == + community.sensitiveActionPolicy; + }, + ), + ), AppListRow( title: 'Send identity to desktop', subtitle: 'Scan a recovery code shown by Buzz Desktop', diff --git a/mobile/lib/hosted_app_gate.dart b/mobile/lib/hosted_app_gate.dart index cc1279cfa3..c99894000b 100644 --- a/mobile/lib/hosted_app_gate.dart +++ b/mobile/lib/hosted_app_gate.dart @@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'features/channels/deep_link_dispatcher.dart'; import 'features/onboarding/hosted_connect_page.dart'; +import 'features/onboarding/self_hosted_entry_sheet.dart'; import 'shared/auth/auth.dart'; import 'shared/theme/grid.dart'; import 'shared/deeplink/deep_link.dart'; @@ -28,7 +29,24 @@ class HostedInlineEntry extends Notifier { @override bool build() => false; - void setActive(bool active) => state = active; + void setActive(bool active) { + if (ref.mounted) state = active; + } +} + +/// Signals a completed local admission to the currently mounted Add route. +final communityEntryCompletionProvider = + NotifierProvider( + CommunityEntryCompletion.new, + ); + +class CommunityEntryCompletion extends Notifier { + @override + int build() => 0; + + void complete() { + if (ref.mounted) state++; + } } final hostedFlowProvider = Provider((ref) { @@ -36,7 +54,8 @@ final hostedFlowProvider = Provider((ref) { final browser = BrowserAuth(); final store = AccountIdentityStore(); final admission = HostedAdmission(account: account, store: store); - final flow = HostedFlow( + late final HostedFlow flow; + flow = HostedFlow( account: account, browser: browser, store: store, @@ -55,7 +74,15 @@ final hostedFlowProvider = Provider((ref) { admission: admission, persist: (community, current) => ref .read(authProvider.notifier) - .authenticateWithCommunity(community, shouldContinue: current), + .authenticateWithCommunity( + flow.pairedSensitiveActionPolicy == null + ? community + : community.copyWith( + sensitiveActionPolicy: flow.pairedSensitiveActionPolicy, + ), + shouldContinue: current, + activate: flow.addedCommunityCount == 0, + ), ); }, ); @@ -104,6 +131,12 @@ class HostedAppGate extends HookConsumerWidget { (flow.accountOnly && flow.stage == HostedFlowStage.working && flow.account.account == null); + final inlineAccountStatus = + (!authenticated || inlineEntry) && + flow.accountOnly && + (flow.stage == HostedFlowStage.working || + (flow.stage == HostedFlowStage.blocked && + !flow.canChangeCommunities)); return DeepLinkDispatcher( dispatchMessageLinks: authenticated && !visible, onboardingHandler: (routeContext, link) async { @@ -125,29 +158,23 @@ class HostedAppGate extends HookConsumerWidget { await ref .read(communityListProvider.notifier) .switchCommunity(existing.id); + if (context.mounted) { + ref.read(communityEntryCompletionProvider.notifier).complete(); + } return true; } final host = Uri.parse(relay).host.toLowerCase(); final reserved = HostedEnvironment.isReservedHost(host); if (!reserved && link is AddCommunityDeepLink) { if (routeContext.mounted) { - await showDialog( - context: routeContext, - builder: (context) => AlertDialog( - title: const Text('An invite or pairing code is needed'), - content: const Text( - 'To join this community in this build, enter an invite link ' - 'or pair with your desktop. Setup using only an address ' - 'is not available for this community yet.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Back'), - ), - ], - ), + final joined = await showSelfHostedEntry( + routeContext, + relay: link.relayUrl, + name: link.name, ); + if (joined && context.mounted) { + ref.read(communityEntryCompletionProvider.notifier).complete(); + } } return true; } @@ -195,8 +222,8 @@ class HostedAppGate extends HookConsumerWidget { child: SafeArea( child: Material( color: Colors.transparent, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, + child: Wrap( + alignment: WrapAlignment.center, children: [ TextButton( onPressed: flow.retry, @@ -213,9 +240,12 @@ class HostedAppGate extends HookConsumerWidget { ), if (visible && !openingSignIn && + !inlineAccountStatus && (!inlineEntry || - flow.accountOnly || - flow.stage == HostedFlowStage.chooseIdentity)) + flow.stage == HostedFlowStage.chooseIdentity || + flow.stage == HostedFlowStage.chooseCommunities || + flow.stage == HostedFlowStage.communityConflict || + flow.canChangeCommunities)) HostedConnectPage( flow: flow, onClose: () => unawaited(flow.leave()), diff --git a/mobile/lib/onboarding_app_entry.dart b/mobile/lib/onboarding_app_entry.dart index 9e9208318a..17eb1f6585 100644 --- a/mobile/lib/onboarding_app_entry.dart +++ b/mobile/lib/onboarding_app_entry.dart @@ -4,29 +4,76 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'features/pairing/pairing_page.dart'; +import 'features/onboarding/hosted_connect_sheet.dart'; import 'features/pairing/pairing_provider.dart'; import 'hosted_app_gate.dart'; import 'shared/hosted/hosted_flow.dart'; +import 'shared/hosted/account_identity_store.dart'; +import 'shared/identity_transfer/nsec_import_sheet.dart'; +import 'shared/identity_transfer/nsec_transfer.dart'; +import 'shared/hosted/hosted_flow_problem_message.dart'; /// Navigation over the existing branded welcome surface. class OnboardingAppEntry extends HookConsumerWidget { - const OnboardingAppEntry({super.key}); + const OnboardingAppEntry({super.key, this.addingCommunity = false}); + final bool addingCommunity; @override Widget build(BuildContext context, WidgetRef ref) { final existingUser = useState(false); + final importedIdentity = useState(false); final enteringCommunityLink = useState(false); final flow = ref.watch(hostedFlowProvider); useListenable(flow); useEffect(() { Future restore() async { await flow.initialize(); - await flow.signInAccount(openBrowser: false); + if (context.mounted) await flow.signInAccount(openBrowser: false); } unawaited(restore()); return null; }, [flow]); + void finishEntry() { + if (!addingCommunity) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + final route = ModalRoute.of(context); + if (route != null && route.isActive) { + route.navigator?.removeRoute(route); + } + }); + } + + ref.listen(communityEntryCompletionProvider, (_, _) => finishEntry()); + useEffect(() { + final presentation = ref.read(hostedInlineEntryProvider.notifier); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) presentation.setActive(true); + }); + void completed() { + if (flow.stage == HostedFlowStage.complete) finishEntry(); + } + + flow.addListener(completed); + return () { + flow.removeListener(completed); + scheduleMicrotask(() { + presentation.setActive(false); + if (addingCommunity && flow.stage != HostedFlowStage.complete) { + unawaited(flow.leave()); + } + }); + }; + }, [flow]); + final inlineSetup = + !flow.accountOnly && + flow.stage != HostedFlowStage.idle && + flow.stage != HostedFlowStage.complete && + flow.stage != HostedFlowStage.chooseIdentity && + flow.stage != HostedFlowStage.chooseCommunities && + flow.stage != HostedFlowStage.communityConflict && + !flow.canChangeCommunities; final signingIn = flow.stage == HostedFlowStage.signIn; final busy = flow.accountOnly && @@ -34,21 +81,18 @@ class OnboardingAppEntry extends HookConsumerWidget { flow.stage == HostedFlowStage.signIn); final connected = flow.account.account != null; final problem = flow.accountOnly ? flow.problem : null; - final status = flow.noCommunitiesToAdd + final status = problem != null + ? hostedFlowProblemMessage(problem) + : busy + ? 'Connecting to Builderlab…' + : flow.noCommunitiesToAdd ? 'No communities available to add. Scan a QR code or use an invite link.' + : importedIdentity.value + ? 'Identity saved. Continue with Builderlab to back it up and find your communities.' : connected - ? 'Connected to Builderlab. Your session is saved securely on this device.' - : problem != null - ? switch (problem) { - HostedFlowProblem.differentAccount => - 'A different account is already saved on this device. Sign in with that account.', - HostedFlowProblem.identityUnreadable => - 'Unable to read or save your secure session. Your existing data has been kept.', - _ => 'Could not finish signing in. Please try again.', - } + ? 'Connected to Builderlab.' : null; void back() { - ref.read(hostedInlineEntryProvider.notifier).setActive(false); unawaited(flow.leave()); existingUser.value = false; enteringCommunityLink.value = false; @@ -57,12 +101,29 @@ class OnboardingAppEntry extends HookConsumerWidget { } return PopScope( - canPop: !existingUser.value && !enteringCommunityLink.value, + canPop: + !existingUser.value && !enteringCommunityLink.value && !inlineSetup, onPopInvokedWithResult: (didPop, _) { if (!didPop) back(); }, child: PairingPage( connectionLanding: true, + addingCommunity: addingCommunity, + onImportKey: () async { + final imported = await showNsecImportSheet( + context, + onImport: (nsec) async { + await NsecTransfer.importAccount( + nsec, + AccountIdentityStore(), + checkCurrent: () { + if (!context.mounted) throw StateError('Import cancelled'); + }, + ); + }, + ); + if (context.mounted && imported) importedIdentity.value = true; + }, existingUser: existingUser.value, enteringCommunityLink: enteringCommunityLink.value, onCommunityLink: () => enteringCommunityLink.value = true, @@ -72,12 +133,21 @@ class OnboardingAppEntry extends HookConsumerWidget { ? null : () => unawaited(flow.signInAccount()), accountStatus: status, + setupContent: inlineSetup + ? Column( + mainAxisSize: MainAxisSize.min, + children: [ + HostedConnectSheet(flow: flow), + TextButton(onPressed: back, child: const Text('Leave setup')), + ], + ) + : null, builderlabLabel: signingIn ? 'Cancel sign-in' + : busy + ? 'Connecting…' : connected ? 'Continue with Builderlab' - : busy - ? 'Signing in…' : 'Connect to Builderlab', onWelcomeBack: back, onAlreadyUseBuzz: () => existingUser.value = true, diff --git a/mobile/lib/shared/auth/auth_provider.dart b/mobile/lib/shared/auth/auth_provider.dart index 6f9a1d37a0..15f3b4a2f3 100644 --- a/mobile/lib/shared/auth/auth_provider.dart +++ b/mobile/lib/shared/auth/auth_provider.dart @@ -60,25 +60,28 @@ class AuthNotifier extends AsyncNotifier { Future authenticateWithCommunity( Community community, { bool Function()? shouldContinue, + bool activate = true, }) { return ref.read(communityTransitionProvider).runExclusive(() async { if (shouldContinue != null && !shouldContinue()) return; await CommunityEntryRequirements.verify(community); if (shouldContinue != null && !shouldContinue()) return; - await ref.read(communityTransitionProvider).run(); + if (activate) await ref.read(communityTransitionProvider).run(); if (shouldContinue != null && !shouldContinue()) return; final storage = ref.read(communityStorageProvider); await storage.save(community); - await storage.saveActiveId(community.id); + if (activate) await storage.saveActiveId(community.id); await syncStoredCommunitySnapshot(ref); // Invalidate community providers so other consumers pick up the new data. ref.invalidate(communityListProvider); ref.invalidate(activeCommunityProvider); - state = AsyncData( - AuthState(status: AuthStatus.authenticated, community: community), - ); + if (activate) { + state = AsyncData( + AuthState(status: AuthStatus.authenticated, community: community), + ); + } }); } diff --git a/mobile/lib/shared/hosted/browser_auth.dart b/mobile/lib/shared/hosted/browser_auth.dart index 69768fed14..051f296f2c 100644 --- a/mobile/lib/shared/hosted/browser_auth.dart +++ b/mobile/lib/shared/hosted/browser_auth.dart @@ -97,6 +97,16 @@ class BrowserAuth { } }); + /// Persist a verified pairing destination before acknowledging key transfer. + Future retainIntent(Map intent) => _serial(() async { + final raw = jsonEncode(intent); + if (raw.length > 16384) throw BrowserAuthFailure.storage; + await _storage.write(key: _intentKey, value: raw); + if (await _storage.read(key: _intentKey) != raw) { + throw BrowserAuthFailure.storage; + } + }); + Future start(Map intent) async { final generation = ++_generation; final uri = await _serial(() async { diff --git a/mobile/lib/shared/hosted/builderlab_account.dart b/mobile/lib/shared/hosted/builderlab_account.dart index dfcb128d0f..503f7c63b2 100644 --- a/mobile/lib/shared/hosted/builderlab_account.dart +++ b/mobile/lib/shared/hosted/builderlab_account.dart @@ -16,6 +16,7 @@ import 'hosted_environment.dart'; enum AccountFailure { signedOut, denied, + accountSetupRequired, unavailable, invalidResponse, storage, @@ -29,7 +30,12 @@ enum AccountFailure { } class BuilderlabAccount { - const BuilderlabAccount({required this.subject, required this.expiresAt}); + const BuilderlabAccount({ + required this.subject, + required this.expiresAt, + this.needsAccountSetup = false, + }); + final bool needsAccountSetup; final String subject; final DateTime expiresAt; } @@ -187,7 +193,17 @@ class BuilderlabAccountClient { throw AccountFailure.invalidResponse; } if (!parsed.isAfter(DateTime.now().toUtc())) throw AccountFailure.signedOut; - return BuilderlabAccount(subject: subject, expiresAt: parsed); + final standing = result['account_standing']; + final needsSetup = + standing is Map && + (standing['details_status'] == 'ACCOUNT_DETAILS_STATUS_MISSING' || + standing['screening_status'] == + 'ACCOUNT_SCREENING_STATUS_NOT_SCREENED'); + return BuilderlabAccount( + subject: subject, + expiresAt: parsed, + needsAccountSetup: needsSetup, + ); } /// Public identity metadata only. Private-key recovery belongs to the identity @@ -208,6 +224,10 @@ class BuilderlabAccountClient { try { return await _request(path, credential: credential, body: body); } on AccountFailure catch (failure) { + if (failure == AccountFailure.denied && + _account?.needsAccountSetup == true) { + throw AccountFailure.accountSetupRequired; + } if (failure != AccountFailure.signedOut) rethrow; } // Routes also answer 401 for authorization failures, so only a rejected @@ -259,7 +279,7 @@ class BuilderlabAccountClient { .timeout(const Duration(seconds: 30)); if (kDebugMode && response.statusCode >= 400) { debugPrint( - 'Builderlab ${request.method} $path: HTTP ${response.statusCode}', + 'Builderlab ${origin.host} ${request.method} $path: HTTP ${response.statusCode} contentType=${response.headers['content-type']?.split(';').first}', ); } if (response.statusCode == 401) throw AccountFailure.signedOut; diff --git a/mobile/lib/shared/hosted/device_identity_source.dart b/mobile/lib/shared/hosted/device_identity_source.dart index 3026e20882..aef15e53a0 100644 --- a/mobile/lib/shared/hosted/device_identity_source.dart +++ b/mobile/lib/shared/hosted/device_identity_source.dart @@ -38,6 +38,16 @@ class DeviceIdentitySource { return store.create(candidate.nsec, checkCurrent: checkCurrent); } + /// Supplies a freshly validated saved identity only to an admission service. + Future withIdentity( + String pubkey, + Future Function(String nsec) operation, + ) async { + final candidate = (await _read())[pubkey]; + if (candidate == null) throw AccountIdentityFailure.absent; + return operation(candidate.nsec); + } + Future> _read() async { try { final result = {}; diff --git a/mobile/lib/shared/hosted/hosted_flow.dart b/mobile/lib/shared/hosted/hosted_flow.dart index e75dd61e58..3da91b635a 100644 --- a/mobile/lib/shared/hosted/hosted_flow.dart +++ b/mobile/lib/shared/hosted/hosted_flow.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:url_launcher/url_launcher.dart'; import '../deeplink/deep_link.dart'; +import '../community/community.dart'; import 'account_identity_store.dart'; import 'device_identity_source.dart'; import 'browser_auth.dart'; @@ -31,6 +32,7 @@ enum HostedFlowStage { enum HostedFlowProblem { retry, denied, + accountSetupRequired, differentAccount, identityUnreadable, backupUnavailable, @@ -80,6 +82,8 @@ class HostedFlow extends ChangeNotifier { List identityChoices = const []; BuzzDeepLink? _target; bool accountOnly = false; + String? _pairedPubkey; + SensitiveActionPolicy? pairedSensitiveActionPolicy; List communities = const []; Set selectedRelays = const {}; final Set _addedRelays = {}; @@ -118,6 +122,7 @@ class HostedFlow extends ChangeNotifier { return; } accountOnly = false; + _restorePairing(code.intent); final target = _decode(code.intent); if (target == null) throw BrowserAuthFailure.restartLogin; _target = target; @@ -162,6 +167,7 @@ class HostedFlow extends ChangeNotifier { accountOnly = true; _set(HostedFlowStage.idle); } else { + _restorePairing(retained); _target = _decode(retained); } if (_target != null && stage == HostedFlowStage.idle) { @@ -204,7 +210,64 @@ class HostedFlow extends ChangeNotifier { }); } - Future start(BuzzDeepLink target) { + /// Securely retain the transferred identity before NIP-AB completion. Hosted + /// admission still waits for account, binding and backup in the normal flow. + Future stagePairedCommunity( + Community community, { + required bool Function() isCurrent, + }) async { + final uri = Uri.parse(community.relayUrl); + if (!HostedEnvironment.supportsRelay(uri)) throw AccountFailure.denied; + final nsec = community.nsec; + if (nsec == null) throw AccountIdentityFailure.absent; + final pubkey = AccountIdentityStore.publicKeyForNsec(nsec); + if (community.pubkey != null && community.pubkey != pubkey) { + throw AccountIdentityFailure.mismatch; + } + final device = await DeviceIdentitySource().choices(); + if (device.isNotEmpty && !device.any((item) => item.pubkey == pubkey)) { + throw AccountIdentityFailure.mismatch; + } + final generation = ++_generation; + void check() { + if (!isCurrent() || generation != _generation || _disposed) { + throw AccountFailure.unavailable; + } + } + + check(); + await store.create(nsec, checkCurrent: check); + check(); + final relay = uri.replace(scheme: 'wss').toString(); + final target = AddCommunityDeepLink(relayUrl: relay, name: community.name); + _pairedPubkey = pubkey; + pairedSensitiveActionPolicy = community.sensitiveActionPolicy; + await browser.retainIntent(_intent(target)); + check(); + _target = target; + accountOnly = false; + _set(HostedFlowStage.signIn); + } + + void _restorePairing(Map intent) { + _pairedPubkey = intent['paired_pubkey']; + pairedSensitiveActionPolicy = intent['paired_protection'] == 'enabled' + ? SensitiveActionPolicy.enabled + : null; + } + + Map _intent(BuzzDeepLink target) => { + ..._encode(target), + 'paired_pubkey': ?_pairedPubkey, + if (pairedSensitiveActionPolicy == SensitiveActionPolicy.enabled) + 'paired_protection': 'enabled', + }; + + Future start(BuzzDeepLink target, {bool resumePairing = false}) { + if (!resumePairing) { + _pairedPubkey = null; + pairedSensitiveActionPolicy = null; + } if (target is! NostrBindDeepLink && target is! AddCommunityDeepLink && target is! InviteDeepLink) { @@ -222,7 +285,7 @@ class HostedFlow extends ChangeNotifier { if (generation != _generation) return; if (restored == null) { _set(HostedFlowStage.signIn); - await browser.start(_encode(target)); + await browser.start(_intent(target)); } else { await _continue(generation); } @@ -237,7 +300,7 @@ class HostedFlow extends ChangeNotifier { await browser.cancel(); if (_disposed || generation != _generation) return; if (target != null) { - await start(target); + await start(target, resumePairing: _pairedPubkey != null); } else { _set(HostedFlowStage.working); await _enqueue(() async { @@ -285,6 +348,10 @@ class HostedFlow extends ChangeNotifier { isCurrent: () => !_disposed && generation == _generation, selectedPubkey: selectedPubkey, ); + if (_pairedPubkey != null && resolved.pubkey != _pairedPubkey) { + _set(HostedFlowStage.blocked, HostedFlowProblem.identityMismatch); + return; + } if (target is! NostrBindDeepLink) { for (var step = 0; step < 2; step++) { if (_disposed || generation != _generation) return; @@ -556,6 +623,8 @@ class HostedFlow extends ChangeNotifier { static HostedFlowProblem _problemFor(Object error) => switch (error) { AccountFailure.denied => HostedFlowProblem.denied, + AccountFailure.accountSetupRequired => + HostedFlowProblem.accountSetupRequired, AccountFailure.rejected || HostedBackupFailure.rejected => HostedFlowProblem.requestRejected, HostedBackupFailure.unavailable => HostedFlowProblem.backupUnavailable, @@ -601,6 +670,8 @@ class HostedFlow extends ChangeNotifier { ++_generation; final generation = _generation; _target = null; + _pairedPubkey = null; + pairedSensitiveActionPolicy = null; accountOnly = false; _clearCommunityBatch(); noCommunitiesToAdd = noCommunities; @@ -631,7 +702,10 @@ class HostedFlow extends ChangeNotifier { } static BuzzDeepLink? _decode(Map intent) { - final fields = Map.of(intent)..remove('type'); + final fields = Map.of(intent) + ..remove('type') + ..remove('paired_pubkey') + ..remove('paired_protection'); final host = switch (intent['type']) { 'bind' => 'nostr-bind', 'add' => 'add-community', diff --git a/mobile/lib/shared/hosted/hosted_flow_problem_message.dart b/mobile/lib/shared/hosted/hosted_flow_problem_message.dart index 01569941e0..d3eb3bdb34 100644 --- a/mobile/lib/shared/hosted/hosted_flow_problem_message.dart +++ b/mobile/lib/shared/hosted/hosted_flow_problem_message.dart @@ -5,6 +5,8 @@ String hostedFlowProblemMessage( ) => switch (problem) { HostedFlowProblem.unsupportedEnvironment => 'This community needs a different or unsupported Builderlab environment. Use an app build configured for this community.', + HostedFlowProblem.accountSetupRequired => + 'This account is not eligible for Buzz yet.', HostedFlowProblem.denied => 'This account does not have access.', HostedFlowProblem.differentAccount => '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.', @@ -19,7 +21,7 @@ String hostedFlowProblemMessage( HostedFlowProblem.identityMismatch => 'This account uses a different identity. Your saved identity has been kept.', HostedFlowProblem.backupRequired => - 'Your identity needs a confirmed backup before joining. Backup setup is not available in this build yet.', + 'Your identity needs a confirmed cloud backup before joining. Your saved key has been kept.', HostedFlowProblem.bindingRequired => 'Connect your identity from the account setup page first.', HostedFlowProblem.identityUnavailable => diff --git a/mobile/lib/shared/hosted/hosted_identity_service.dart b/mobile/lib/shared/hosted/hosted_identity_service.dart index ff1225677d..585aa847c1 100644 --- a/mobile/lib/shared/hosted/hosted_identity_service.dart +++ b/mobile/lib/shared/hosted/hosted_identity_service.dart @@ -94,7 +94,16 @@ class HostedIdentityService { final statusKeys = listed.map(_pubkey).toSet(); if (statusBound != bound) throw AccountFailure.invalidResponse; if (local != null) { - if (statusKeys.any((key) => key != local.pubkey)) { + // A previously chosen recovery key may still await binding after a + // restart. Its account-scoped receipt and current backup listing preserve + // that choice even when the account also retains older identities. + final confirmedSelection = + statusKeys.contains(local.pubkey) && + local.hasBackupFor( + backend: BuilderlabAccountClient.origin.origin, + subject: subject, + ); + if (statusKeys.any((key) => key != local.pubkey) && !confirmedSelection) { return const HostedIdentityResult(HostedIdentityState.mismatch); } return _result(local, bound, subject); @@ -210,12 +219,29 @@ class HostedIdentityService { } catch (_) { throw AccountFailure.invalidResponse; } - if (candidates.length != 1) { - return const HostedIdentityResult(HostedIdentityState.recoveryBlocked); - } - final candidate = candidates.values.single; - if (bound != null && bound != candidate.keys.public) { - return const HostedIdentityResult(HostedIdentityState.mismatch); + // An account binding is authoritative even when older backups contain + // other keys. Without a binding, distinct keys require an explicit choice; + // never let response ordering choose the user's identity. + final chosen = + bound ?? + selectedPubkey ?? + (candidates.length == 1 ? candidates.keys.single : null); + final candidate = candidates[chosen]; + if (candidate == null) { + if (bound != null) { + return const HostedIdentityResult(HostedIdentityState.mismatch); + } + return HostedIdentityResult( + HostedIdentityState.needsSelection, + choices: [ + for (final pubkey in candidates.keys) + DeviceIdentityChoice( + pubkey, + 'Use Builderlab identity', + fromCloud: true, + ), + ], + ); } if (device.isNotEmpty && !device.any((item) => item.pubkey == candidate.keys.public) && diff --git a/mobile/lib/shared/hosted/self_hosted_entry.dart b/mobile/lib/shared/hosted/self_hosted_entry.dart new file mode 100644 index 0000000000..8edd41901e --- /dev/null +++ b/mobile/lib/shared/hosted/self_hosted_entry.dart @@ -0,0 +1,143 @@ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import '../community/community.dart'; +import '../relay/relay_socket.dart'; +import '../storage/secure_storage.dart'; +import 'account_identity_store.dart'; +import 'community_entry_requirements.dart'; +import 'device_identity_source.dart'; + +/// A self-hosted attempt keeps its key before any network operation. Retrying +/// after a refused connection or process death never rotates that identity. +class SelfHostedEntry { + SelfHostedEntry({ + FlutterSecureStorage? storage, + DeviceIdentitySource? identities, + AccountIdentityStore? accountStore, + Future Function(Community)? probe, + }) : _storage = storage ?? communitySecureStorage, + identities = identities ?? DeviceIdentitySource(), + _accountStore = accountStore ?? AccountIdentityStore(), + _probe = probe ?? probeRelay; + + final FlutterSecureStorage _storage; + final DeviceIdentitySource identities; + final AccountIdentityStore _accountStore; + final Future Function(Community) _probe; + static Future _tail = Future.value(); + + /// Saved keys available for explicit reuse, including a hosted account that + /// has not joined any community yet. This performs no account network calls. + Future> choices() async { + final device = await identities.choices(); + final account = await _accountStore.read(); + return [ + ...device, + if (account != null && + !device.any((item) => item.pubkey == account.pubkey)) + DeviceIdentityChoice(account.pubkey, 'Saved account identity'), + ]; + } + + Future prepare({ + required String relay, + required String name, + String? nsec, + String? savedPubkey, + bool create = false, + required bool Function() isCurrent, + }) { + final result = _tail.then((_) async { + CommunityEntryRequirements.requireSelfHosted(relay); + final uri = Uri.parse(relay); + if (uri.scheme != 'wss' || + uri.host.isEmpty || + uri.userInfo.isNotEmpty || + uri.hasQuery || + uri.hasFragment || + (uri.path.isNotEmpty && uri.path != '/')) { + throw const FormatException('Invalid community address'); + } + void check() { + if (!isCurrent()) throw StateError('Setup cancelled'); + } + + check(); + final key = + 'buzz_pending_self_hosted_${base64Url.encode(utf8.encode(relay))}'; + final retained = await _storage.read(key: key); + String? candidate = nsec; + if (savedPubkey != null) { + final account = await _accountStore.read(); + candidate = account?.pubkey == savedPubkey + ? await _accountStore.withKeys((keys) async { + if (keys.public != savedPubkey) { + throw AccountIdentityFailure.mismatch; + } + return keys.nsec; + }) + : await identities.withIdentity( + savedPubkey, + (value) async => value, + ); + } + if (retained != null) { + final retainedPubkey = AccountIdentityStore.publicKeyForNsec(retained); + if (candidate != null && + AccountIdentityStore.publicKeyForNsec(candidate) != + retainedPubkey) { + throw AccountIdentityFailure.occupied; + } + candidate = retained; + } + if (candidate == null) { + if (!create || (await choices()).isNotEmpty) { + throw AccountIdentityFailure.absent; + } + candidate = nostr.Keys.generate().nsec; + } + final pubkey = AccountIdentityStore.publicKeyForNsec(candidate); + check(); + if (retained == null) { + await _storage.write(key: key, value: candidate); + if (await _storage.read(key: key) != candidate) { + throw AccountIdentityFailure.unreadable; + } + } + check(); + final community = Community.create( + name: name, + relayUrl: relay, + pubkey: pubkey, + nsec: candidate, + ); + await _probe(community); + check(); + return community; + }); + _tail = result.then((_) {}, onError: (Object _, StackTrace _) {}); + return result; + } + + /// Authentication must succeed before a new community becomes active. + static Future probeRelay(Community community) async { + final socket = RelaySocket( + wsUrl: community.relayUrl, + nsec: community.nsec, + onMessage: (_) {}, + onConnected: () {}, + onDisconnected: (_) {}, + ); + try { + await socket.connect().timeout(const Duration(seconds: 20)); + if (socket.state != SocketState.connected) { + throw StateError('Community refused entry'); + } + } finally { + socket.dispose(); + } + } +} diff --git a/mobile/lib/shared/identity_transfer/nsec_export_sheet.dart b/mobile/lib/shared/identity_transfer/nsec_export_sheet.dart new file mode 100644 index 0000000000..392392a5a9 --- /dev/null +++ b/mobile/lib/shared/identity_transfer/nsec_export_sheet.dart @@ -0,0 +1,137 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../community/community.dart'; +import '../security/sensitive_action_authorizer.dart'; +import '../theme/theme.dart'; +import '../utils/string_utils.dart'; +import '../widgets/modal_presentation.dart'; +import 'nsec_transfer.dart'; + +/// Presents explicit plaintext-export consent before device authentication. +Future showNsecExportSheet( + BuildContext context, { + required Community community, + required bool Function() isCurrent, +}) => showBuzzModalBottomSheet( + context: context, + title: 'Export private key', + isScrollControlled: true, + builder: (_) => NsecExportSheet(community: community, isCurrent: isCurrent), +); + +/// Public preview and device-authorized export of the selected community key. +class NsecExportSheet extends HookConsumerWidget { + const NsecExportSheet({ + super.key, + required this.community, + required this.isCurrent, + }); + + final Community community; + final bool Function() isCurrent; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final busy = useState(false); + final error = useState(null); + return SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(Grid.gutter), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Identity for ${community.name}'), + const SizedBox(height: Grid.xs), + SelectableText( + fullNpub(community.pubkey ?? '') ?? 'Identity unavailable', + ), + const SizedBox(height: Grid.sm), + const Text( + 'This file contains your unencrypted private key (nsec). Anyone with it can act as you. Save it somewhere private. Apps you share it with may keep a copy.', + ), + const SizedBox(height: Grid.sm), + if (error.value != null) + Text(error.value!, style: TextStyle(color: context.colors.error)), + FilledButton( + onPressed: busy.value + ? null + : () async { + busy.value = true; + error.value = null; + final session = ref.read( + sensitiveActionAuthorizationSessionProvider, + ); + final box = context.findRenderObject() as RenderBox; + final origin = box.localToGlobal(Offset.zero) & box.size; + try { + final exported = await NsecTransfer.exportCommunity( + community: community, + authorize: (biometricOnly) => + session.authorize(biometricOnly: biometricOnly), + isCurrent: () => context.mounted && isCurrent(), + share: (nsec) async { + // Native authentication can finish before its app-resume + // frame. Do not hand secrets to a background share UI. + final resumed = await _waitForResumedFrame(); + if (!resumed || + !context.mounted || + !isCurrent() || + WidgetsBinding.instance.lifecycleState != + AppLifecycleState.resumed) { + throw StateError('Export interrupted'); + } + return NsecTransfer.shareFile(nsec, origin); + }, + ); + if (!context.mounted) return; + if (exported) { + Navigator.of(context).pop(); + } else { + error.value = + 'Export cancelled. Unlock your device and try again.'; + } + } catch (_) { + if (context.mounted) { + error.value = + 'Could not export this identity. Try again.'; + } + } finally { + if (context.mounted) busy.value = false; + } + }, + child: Text( + busy.value ? 'Authorizing…' : 'Save or share private key', + ), + ), + ], + ), + ), + ); + } +} + +Future _waitForResumedFrame() async { + final binding = WidgetsBinding.instance; + if (binding.lifecycleState != AppLifecycleState.resumed) { + final resumed = Completer(); + final listener = AppLifecycleListener( + onResume: () { + if (!resumed.isCompleted) resumed.complete(); + }, + ); + try { + await resumed.future.timeout(const Duration(seconds: 5)); + } on TimeoutException { + return false; + } finally { + listener.dispose(); + } + } + await binding.endOfFrame; + return true; +} diff --git a/mobile/lib/shared/identity_transfer/nsec_import_sheet.dart b/mobile/lib/shared/identity_transfer/nsec_import_sheet.dart new file mode 100644 index 0000000000..b5302b448f --- /dev/null +++ b/mobile/lib/shared/identity_transfer/nsec_import_sheet.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import '../hosted/account_identity_store.dart'; +import '../theme/theme.dart'; +import '../utils/string_utils.dart'; +import '../widgets/modal_presentation.dart'; +import 'nsec_transfer.dart'; + +/// Lets the user inspect the public identity before explicitly importing it. +/// [onImport] is the secure persistence boundary, not an observable UI store. +Future showNsecImportSheet( + BuildContext context, { + required Future Function(String nsec) onImport, +}) async => + await showBuzzModalBottomSheet( + context: context, + title: 'Import identity', + isScrollControlled: true, + builder: (_) => NsecImportSheet(onImport: onImport), + ) ?? + false; + +/// Transient secret input; only public preview and safe errors are displayed. +class NsecImportSheet extends HookWidget { + const NsecImportSheet({super.key, required this.onImport, this.pickFile}); + + final Future Function(String nsec) onImport; + final Future Function()? pickFile; + + @override + Widget build(BuildContext context) { + final input = useTextEditingController(); + final publicKey = useState(null); + final error = useState(null); + final busy = useState(false); + + Future chooseFile() async { + busy.value = true; + error.value = null; + try { + final text = await (pickFile ?? NsecTransfer.pickFile)(); + if (!context.mounted || text == null) return; + final preview = NsecTransfer.preview(text); + input.text = text.trim(); + publicKey.value = preview; + } catch (_) { + if (context.mounted) { + error.value = 'Choose a text file containing one valid nsec.'; + } + } finally { + if (context.mounted) busy.value = false; + } + } + + Future submit() async { + error.value = null; + try { + final preview = NsecTransfer.preview(input.text); + if (publicKey.value != preview) { + publicKey.value = preview; + return; + } + busy.value = true; + await onImport(input.text.trim()); + if (!context.mounted) return; + input.clear(); + Navigator.of(context).pop(true); + } catch (failure) { + if (!context.mounted) return; + error.value = switch (failure) { + AccountIdentityFailure.occupied || AccountIdentityFailure.mismatch => + 'This device already has a different identity. It has not been replaced.', + AccountIdentityFailure.invalidKey => + 'Enter a valid nsec private key.', + _ => + 'Could not finish importing this identity. Try again with the same key.', + }; + } finally { + if (context.mounted) busy.value = false; + } + } + + return SafeArea( + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + Grid.gutter, + Grid.xs, + Grid.gutter, + Grid.gutter + MediaQuery.viewInsetsOf(context).bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Your nsec is your private key. Only import a key you trust. It is never copied to the clipboard.', + ), + const SizedBox(height: Grid.sm), + TextField( + controller: input, + maxLength: 1024, + enabled: !busy.value, + obscureText: true, + autocorrect: false, + enableSuggestions: false, + enableIMEPersonalizedLearning: false, + decoration: const InputDecoration( + labelText: 'Private key (nsec)', + ), + onChanged: (_) { + publicKey.value = null; + error.value = null; + }, + ), + TextButton( + onPressed: busy.value ? null : chooseFile, + child: const Text('Choose nsec file'), + ), + if (publicKey.value != null) ...[ + const Text( + 'Confirm this is the public identity you want to use:', + ), + const SizedBox(height: Grid.xs), + SelectableText(fullNpub(publicKey.value!)!), + const SizedBox(height: Grid.sm), + ], + if (error.value != null) + Text(error.value!, style: TextStyle(color: context.colors.error)), + FilledButton( + onPressed: busy.value ? null : submit, + child: Text( + busy.value + ? 'Importing…' + : publicKey.value == null + ? 'Preview identity' + : 'Import this identity', + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/shared/identity_transfer/nsec_transfer.dart b/mobile/lib/shared/identity_transfer/nsec_transfer.dart new file mode 100644 index 0000000000..0fe48dd27a --- /dev/null +++ b/mobile/lib/shared/identity_transfer/nsec_transfer.dart @@ -0,0 +1,105 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../hosted/account_identity_store.dart'; +import '../community/community.dart'; +import '../security/sensitive_action_authorizer.dart'; + +/// Manual transfer boundaries. Exceptions contain no key material. +abstract final class NsecTransfer { + /// Validates a pasted nsec and returns only its public identity. + static String preview(String text) { + if (text.length > 1024) throw AccountIdentityFailure.invalidKey; + return AccountIdentityStore.publicKeyForNsec(text.trim()); + } + + /// Creates the hosted account slot without replacing an occupied identity. + static Future importAccount( + String text, + AccountIdentityStore store, { + void Function()? checkCurrent, + }) => store.create(text.trim(), checkCurrent: checkCurrent); + + /// Reads a single small UTF-8 nsec file. Cancellation returns null. + static Future pickFile() async { + if (defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS) { + final text = await const MethodChannel( + 'buzz/identity_file', + ).invokeMethod('import'); + if (text == null) return null; + preview(text); + return text.trim(); + } + final file = await openFile(); + if (file == null) return null; + return readFile(file); + } + + /// Validates a small UTF-8 private-key file before returning transient input. + static Future readFile(XFile file) async { + try { + if (await file.length() > 1024) { + throw AccountIdentityFailure.invalidKey; + } + final text = utf8.decode(await file.readAsBytes()).trim(); + preview(text); + return text; + } catch (_) { + throw AccountIdentityFailure.invalidKey; + } + } + + /// Authorizes the selected community identity immediately before export. + static Future exportCommunity({ + required Community community, + required Future Function(bool biometricOnly) authorize, + required bool Function() isCurrent, + required Future Function(String nsec) share, + }) async { + final nsec = community.nsec; + if (nsec == null || preview(nsec) != community.pubkey) { + throw AccountIdentityFailure.mismatch; + } + final result = await authorize( + community.sensitiveActionPolicy == SensitiveActionPolicy.enabled, + ); + if (result != DeviceAuthResult.success || !isCurrent()) return false; + return share(nsec); + } + + /// Shares a plaintext backup, removing our temporary file on completion. + /// The OS share target owns any copy the user chooses to save or send. + static Future shareFile(String nsec, Rect origin) async { + preview(nsec); + if (Platform.isAndroid) { + return await const MethodChannel('buzz/identity_file').invokeMethod( + 'export', + {'text': '${nsec.trim()}\n', 'name': 'buzz-identity.nsec'}, + ) ?? + false; + } + final directory = await (await getTemporaryDirectory()).createTemp( + 'buzz-identity-', + ); + try { + final file = File('${directory.path}/buzz-identity.nsec'); + await file.writeAsString('${nsec.trim()}\n', flush: true); + final result = await SharePlus.instance.share( + ShareParams( + files: [XFile(file.path, mimeType: 'text/plain')], + sharePositionOrigin: origin, + ), + ); + return result.status == ShareResultStatus.success; + } finally { + await directory.delete(recursive: true); + } + } +} diff --git a/mobile/lib/shared/security/sensitive_action_authorizer.dart b/mobile/lib/shared/security/sensitive_action_authorizer.dart index b229f63330..e8b6f1deda 100644 --- a/mobile/lib/shared/security/sensitive_action_authorizer.dart +++ b/mobile/lib/shared/security/sensitive_action_authorizer.dart @@ -25,7 +25,7 @@ class LocalSensitiveActionAuthorizer implements SensitiveActionAuthorizer { Future authorizeIdentityAction({ required bool biometricOnly, }) => _authorize( - localizedReason: 'Confirm sending your Buzz identity to desktop', + localizedReason: 'Confirm exporting your Buzz identity', biometricOnly: biometricOnly, ); diff --git a/mobile/test/features/age_gate/age_gate_app_test.dart b/mobile/test/features/age_gate/age_gate_app_test.dart index 4678a48a3a..6e0076573c 100644 --- a/mobile/test/features/age_gate/age_gate_app_test.dart +++ b/mobile/test/features/age_gate/age_gate_app_test.dart @@ -516,6 +516,7 @@ class _RecordingPairAuthNotifier extends _UnauthenticatedAuthNotifier { Future authenticateWithCommunity( Community community, { bool Function()? shouldContinue, + bool activate = true, }) async { if (shouldContinue != null && !shouldContinue()) return; imports += 1; diff --git a/mobile/test/features/invites/invite_join_provider_test.dart b/mobile/test/features/invites/invite_join_provider_test.dart index 856868fd4a..05093754f9 100644 --- a/mobile/test/features/invites/invite_join_provider_test.dart +++ b/mobile/test/features/invites/invite_join_provider_test.dart @@ -790,6 +790,7 @@ class _RecordingAuthNotifier extends AuthNotifier { Future authenticateWithCommunity( Community community, { bool Function()? shouldContinue, + bool activate = true, }) async { if (shouldContinue != null && !shouldContinue()) return; final storage = ref.read(communityStorageProvider); diff --git a/mobile/test/features/onboarding/community_link_entry_test.dart b/mobile/test/features/onboarding/community_link_entry_test.dart index 7c01a9ee51..95531d5644 100644 --- a/mobile/test/features/onboarding/community_link_entry_test.dart +++ b/mobile/test/features/onboarding/community_link_entry_test.dart @@ -36,6 +36,8 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 250)); expect(tester.getTopLeft(logo), position); + await tester.ensureVisible(find.text('Continue')); + await tester.pump(); await tester.tap(find.text('Continue').hitTestable()); await tester.pump(); expect( @@ -46,6 +48,8 @@ void main() { find.byKey(const Key('community-link-input')), 'https://team.example.com/invite/abc123', ); + await tester.ensureVisible(find.text('Continue')); + await tester.pump(); await tester.tap(find.text('Continue').hitTestable()); await tester.pump(); final container = ProviderScope.containerOf( diff --git a/mobile/test/features/onboarding/hosted_app_gate_environment_test.dart b/mobile/test/features/onboarding/hosted_app_gate_environment_test.dart index 5b2c37e99e..7bad4e3994 100644 --- a/mobile/test/features/onboarding/hosted_app_gate_environment_test.dart +++ b/mobile/test/features/onboarding/hosted_app_gate_environment_test.dart @@ -26,7 +26,7 @@ class _Flow extends HostedFlow { @override Future initialize() async {} @override - Future start(BuzzDeepLink target) async { + Future start(BuzzDeepLink target, {bool resumePairing = false}) async { started = target; } } diff --git a/mobile/test/features/onboarding/hosted_communities_test.dart b/mobile/test/features/onboarding/hosted_communities_test.dart index b3027c46b2..4bf9d3ba82 100644 --- a/mobile/test/features/onboarding/hosted_communities_test.dart +++ b/mobile/test/features/onboarding/hosted_communities_test.dart @@ -17,6 +17,8 @@ void main() { testWidgets( 'welcome discovery selection and empty-state handoff use the real flow', (tester) async { + await tester.binding.setSurfaceSize(const Size(440, 956)); + addTearDown(() => tester.binding.setSurfaceSize(null)); // Keep the process-wide secure-store queue in one widget-test zone. { FlutterSecureStorage.setMockInitialValues({}); @@ -135,35 +137,21 @@ void main() { findsOneWidget, ); f.rows = [f.row('denied')]; - f.beforeAdmission = (_) async => throw AccountFailure.denied; + f.rejectCurrent = true; await tester.tap(find.text('Continue with Builderlab')); await tester.pumpAndSettle(); - expect(find.byType(BuzzSheetHeader), findsOneWidget); - expect( - find.text( - 'No communities available to add. Scan a QR code or use an invite link.', - ), - findsNothing, - ); - final semantics = tester.ensureSemantics(); - await tester.pumpAndSettle(); - expect( - tester - .binding - .renderViews - .single - .owner! - .semanticsOwner! - .rootSemanticsNode! - .toStringDeep(), - isNot(contains('Scan a QR code')), - ); - semantics.dispose(); - expect(find.text('Scan a QR code'), findsOneWidget); - await tester.tap(find.byTooltip('Close sheet').hitTestable()); - await tester.pumpAndSettle(); expect(find.byType(BuzzSheetHeader), findsNothing); - expect(find.text('Scan a QR code'), findsOneWidget); + expect(find.text('This account does not have access.'), findsOneWidget); + await tester.ensureVisible(find.text('Scan a QR code')); + expect(find.text('Scan a QR code').hitTestable(), findsOneWidget); + await tester.ensureVisible(find.text('Use pairing code')); + expect(find.text('Use pairing code').hitTestable(), findsOneWidget); + await tester.ensureVisible(find.text('Continue with Builderlab')); + f.rejectCurrent = false; + await tester.tap(find.text('Continue with Builderlab')); + await tester.pumpAndSettle(); + expect(f.admitted, ['denied']); + expect(find.text('This account does not have access.'), findsNothing); } }, ); diff --git a/mobile/test/features/onboarding/identity_choice_test.dart b/mobile/test/features/onboarding/identity_choice_test.dart index a705ea61be..6ebb71d174 100644 --- a/mobile/test/features/onboarding/identity_choice_test.dart +++ b/mobile/test/features/onboarding/identity_choice_test.dart @@ -146,8 +146,8 @@ void main() { 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')); + // Choice sheets use the shared close control to leave setup. + await tester.tap(find.byTooltip('Close sheet').hitTestable()); await tester.pumpAndSettle(); expect(flow.stage, HostedFlowStage.idle); expect(flow.identityChoices, isEmpty); diff --git a/mobile/test/features/onboarding/onboarding_entry_routes_test.dart b/mobile/test/features/onboarding/onboarding_entry_routes_test.dart new file mode 100644 index 0000000000..0c1f16ca1c --- /dev/null +++ b/mobile/test/features/onboarding/onboarding_entry_routes_test.dart @@ -0,0 +1,414 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channels_provider.dart'; +import 'package:buzz/features/onboarding/hosted_connect_page.dart'; +import 'package:buzz/features/onboarding/self_hosted_entry_sheet.dart'; +import 'package:buzz/hosted_app_gate.dart'; +import 'package:buzz/onboarding_app_entry.dart'; +import 'package:buzz/shared/auth/auth.dart'; +import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart'; +import 'package:buzz/shared/hosted/builderlab_account.dart'; +import 'package:buzz/shared/hosted/hosted_flow.dart'; +import 'package:buzz/shared/hosted/account_identity_store.dart'; +import 'package:buzz/shared/hosted/device_identity_source.dart'; +import 'package:buzz/shared/hosted/hosted_identity_service.dart'; +import 'package:buzz/shared/hosted/hosted_community_connection.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/shared/hosted/self_hosted_entry.dart'; +import 'package:buzz/shared/widgets/buzz_sheet_header.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 '../../helpers/hosted_communities_fixture.dart'; +import '../../helpers/widget_helpers.dart'; + +class _NoChannels extends ChannelsNotifier { + @override + Future> build() async => []; +} + +void main() { + testWidgets('pushed Add route owns paired progress and self-hosted completion', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(440, 956)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + FlutterSecureStorage.setMockInitialValues({}); + PendingDeepLinkNotifier.debugUriStreamOverride = const Stream.empty(); + final f = HostedCommunitiesFixture(); + await f.initialize(widget: true); + addTearDown(() async { + PendingDeepLinkNotifier.debugUriStreamOverride = null; + await f.dispose(); + }); + var probes = 0; + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + overrides: [ + hostedFlowProvider.overrideWithValue(f.flow), + channelsProvider.overrideWith(_NoChannels.new), + selfHostedEntryProvider.overrideWithValue( + SelfHostedEntry( + probe: (_) async { + probes++; + }, + ), + ), + ], + child: HostedAppGate( + authenticated: true, + child: Builder( + builder: (context) => Center( + child: FilledButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + const OnboardingAppEntry(addingCommunity: true), + ), + ), + child: const Text('Add community'), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final container = ProviderScope.containerOf( + tester.element(find.byType(HostedAppGate)), + ); + await container.read(authProvider.future); + + Future openEntry() async { + await tester.tap(find.text('Add community')); + await tester.pumpAndSettle(); + expect(find.byType(OnboardingAppEntry), findsOneWidget); + } + + Future pair() async { + await tester.tap(find.text('Use pairing code').hitTestable()); + await tester.pumpAndSettle(); + await tester.enterText( + find.byType(TextField).hitTestable(), + base64Url.encode( + utf8.encode( + jsonEncode({ + 'relayUrl': 'https://paired.communities.buzz.xyz', + 'nsec': f.keys.nsec, + 'pubkey': f.keys.public, + }), + ), + ), + ); + await tester.tap(find.text('Connect').hitTestable()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + } + + // A root-navigator choice modal must remain actionable above pushed Add. + f.rows = [f.row('one'), f.row('two')]; + f.beforeAdmission = (name) async { + if (name == 'two') throw const SavedCommunityIdentityConflict(); + }; + await openEntry(); + await tester.tap(find.text('Continue with Builderlab').hitTestable()); + await tester.pumpAndSettle(); + expect(f.flow.stage, HostedFlowStage.chooseCommunities); + expect(find.text('Choose your communities').hitTestable(), findsOneWidget); + expect(find.byType(CheckboxListTile).hitTestable(), findsNWidgets(2)); + expect(find.text('Continue').hitTestable(), findsOneWidget); + await tester.tap(find.text('Continue').hitTestable()); + await tester.pumpAndSettle(); + expect(f.flow.stage, HostedFlowStage.communityConflict); + expect( + find.text('Keep saved identity and skip').hitTestable(), + findsOneWidget, + ); + expect(find.text('Back to choices').hitTestable(), findsOneWidget); + await tester.tap(find.text('Back to choices').hitTestable()); + await tester.pumpAndSettle(); + expect(find.byType(CheckboxListTile).hitTestable(), findsOneWidget); + expect(find.text('two').hitTestable(), findsOneWidget); + f.beforeAdmission = null; + await tester.tap(find.text('Continue').hitTestable()); + await tester.pumpAndSettle(); + expect(f.admitted, ['one', 'two']); + expect(find.byType(OnboardingAppEntry), findsNothing); + expect(find.text('Add community').hitTestable(), findsOneWidget); + f.admitted.clear(); + f.rows = []; + await f.flow.leave(); + + await openEntry(); + final admission = Completer(); + f.beforeAdmission = (_) => admission.future; + await pair(); + expect(f.flow.accountOnly, isFalse); + expect(f.flow.stage, HostedFlowStage.working); + expect(find.text('Leave setup').hitTestable(), findsOneWidget); + expect( + find.byType(CircularProgressIndicator).hitTestable(), + findsOneWidget, + ); + expect(find.byType(BuzzSheetHeader), findsNothing); + expect(find.byType(HostedConnectPage), findsNothing); + admission.completeError(AccountFailure.unavailable); + await tester.pumpAndSettle(); + expect(f.flow.stage, HostedFlowStage.blocked); + expect( + find + .text('We could not finish connecting. You can try again.') + .hitTestable(), + findsOneWidget, + ); + expect(find.text('Try again').hitTestable(), findsOneWidget); + expect(find.text('Leave setup').hitTestable(), findsOneWidget); + expect(find.byType(BuzzSheetHeader), findsNothing); + f.beforeAdmission = null; + await tester.tap(find.text('Try again').hitTestable()); + await tester.pumpAndSettle(); + expect(f.admitted, ['paired']); + expect(find.byType(OnboardingAppEntry), findsNothing); + expect(find.text('Add community').hitTestable(), findsOneWidget); + + // Cancelling a paired sign-in keeps the Add route usable and clears intent. + await f.flow.leave(); + await openEntry(); + f.expired = true; + await pair(); + await tester.pumpAndSettle(); + expect(f.flow.stage, HostedFlowStage.signIn); + expect( + find.text('Complete sign-in in your browser.').hitTestable(), + findsOneWidget, + ); + expect(find.text('Leave setup').hitTestable(), findsOneWidget); + await tester.tap(find.text('Leave setup').hitTestable()); + await tester.pumpAndSettle(); + expect(f.flow.stage, HostedFlowStage.idle); + expect(find.text('Enter a community link').hitTestable(), findsOneWidget); + expect(container.read(hostedInlineEntryProvider), isTrue); + f.expired = false; + + // Drive the real deep-link dispatcher, sheet, service and auth persistence. + await tester.tap(find.text('Enter a community link').hitTestable()); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const Key('community-link-input')).hitTestable(), + 'new-community.example', + ); + await tester.tap(find.text('Continue').hitTestable()); + await tester.pumpAndSettle(); + expect(find.byType(SelfHostedEntrySheet), findsOneWidget); + await tester.tap(find.text('Saved account identity').hitTestable()); + await tester.pumpAndSettle(); + expect(probes, 1); + expect(find.byType(SelfHostedEntrySheet), findsNothing); + expect(find.byType(OnboardingAppEntry), findsNothing); + expect(find.text('Add community').hitTestable(), findsOneWidget); + final saved = await container.read(communityStorageProvider).loadAll(); + expect(saved.single.relayUrl, 'wss://new-community.example'); + expect(saved.single.pubkey, f.keys.public); + expect( + await container.read(communityStorageProvider).loadActiveId(), + saved.single.id, + ); + + // Reopening an already saved URL switches to it and also closes Add. + await openEntry(); + await tester.tap(find.text('Enter a community link').hitTestable()); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const Key('community-link-input')).hitTestable(), + 'new-community.example', + ); + await tester.tap(find.text('Continue').hitTestable()); + await tester.pumpAndSettle(); + expect(find.byType(OnboardingAppEntry), findsNothing); + expect(find.text('Add community').hitTestable(), findsOneWidget); + expect(probes, 1); + expect( + await container.read(communityStorageProvider).loadActiveId(), + saved.single.id, + ); + + // Popping Add releases ownership; a later invite uses the app-level UI. + await openEntry(); + await tester.tap(find.text('Enter a community link').hitTestable()); + await tester.pumpAndSettle(); + await tester.tap(find.text('Back').hitTestable()); + await tester.pumpAndSettle(); + expect(container.read(hostedInlineEntryProvider), isTrue); + await tester.pageBack(); + await tester.pumpAndSettle(); + expect(find.byType(OnboardingAppEntry), findsNothing); + expect(container.read(hostedInlineEntryProvider), isFalse); + f.expired = true; + container + .read(pendingDeepLinkProvider.notifier) + .open( + Uri( + scheme: 'buzz', + host: 'join', + queryParameters: { + 'relay': 'wss://invited.communities.buzz.xyz', + 'code': 'invite', + }, + ), + ); + await tester.pumpAndSettle(); + expect(f.flow.stage, HostedFlowStage.signIn); + expect(find.text('Start login again').hitTestable(), findsOneWidget); + expect(find.text('Leave setup').hitTestable(), findsOneWidget); + await tester.tap(find.text('Leave setup').hitTestable()); + await tester.pumpAndSettle(); + f.expired = false; + + // Disposing the entire scope while an entry owns presentation is safe. + await openEntry(); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + expect(tester.takeException(), isNull); + + // A new coordinator restores the paired target and presents its waiting + // state on the entry route, without depending on accountOnly discovery. + await f.flow.stagePairedCommunity( + Community.create( + name: 'Restored', + relayUrl: 'wss://restored.communities.buzz.xyz', + pubkey: f.keys.public, + nsec: f.keys.nsec, + ), + isCurrent: () => true, + ); + final restartedBrowser = DiscoveryBrowser(); + final restarted = HostedFlow( + account: f.account, + browser: restartedBrowser, + store: f.store, + identity: f.flow.identity, + enterCommunity: (link, subject, pubkey, current) async {}, + ); + addTearDown(() async { + restarted.dispose(); + await restartedBrowser.dispose(); + }); + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + overrides: [hostedFlowProvider.overrideWithValue(restarted)], + child: const HostedAppGate( + authenticated: false, + child: OnboardingAppEntry(), + ), + ), + ); + await tester.pumpAndSettle(); + expect(restarted.stage, HostedFlowStage.signIn); + expect( + find.text('Complete sign-in in your browser.').hitTestable(), + findsOneWidget, + ); + expect(find.text('Leave setup').hitTestable(), findsOneWidget); + expect(find.byType(BuzzSheetHeader), findsNothing); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + expect(tester.takeException(), isNull); + + // Identity selection is also a real root-navigator sheet above Add. + final first = nostr.Keys('1'.padLeft(64, '0')); + final second = nostr.Keys('2'.padLeft(64, '0')); + FlutterSecureStorage.setMockInitialValues({ + 'buzz_communities': jsonEncode([ + {'name': 'First', 'nsec': first.nsec, 'pubkey': first.public}, + {'name': 'Second', 'nsec': second.nsec, 'pubkey': second.public}, + ]), + }); + final choiceAccount = _ChoiceAccount(); + final choiceStore = AccountIdentityStore(); + final choiceBrowser = DiscoveryBrowser(); + final choiceFlow = HostedFlow( + account: choiceAccount, + browser: choiceBrowser, + store: choiceStore, + identity: HostedIdentityService( + account: choiceAccount, + store: choiceStore, + deviceIdentities: DeviceIdentitySource(), + ), + enterCommunity: (_, _, _, _) async => + fail('Selection cannot bypass binding'), + ); + addTearDown(() async { + choiceFlow.dispose(); + choiceAccount.dispose(); + await choiceBrowser.dispose(); + }); + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + overrides: [ + hostedFlowProvider.overrideWithValue(choiceFlow), + channelsProvider.overrideWith(_NoChannels.new), + ], + child: HostedAppGate( + authenticated: true, + child: Builder( + builder: (context) => Center( + child: FilledButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + const OnboardingAppEntry(addingCommunity: true), + ), + ), + child: const Text('Add community'), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await openEntry(); + await tester.tap(find.text('Continue with Builderlab').hitTestable()); + await tester.pumpAndSettle(); + expect(choiceFlow.stage, HostedFlowStage.chooseIdentity); + expect(find.text('First').hitTestable(), findsOneWidget); + expect(find.text('Second').hitTestable(), findsOneWidget); + await tester.tap(find.text('Second').hitTestable()); + await tester.pumpAndSettle(); + expect((await choiceStore.read())!.pubkey, second.public); + expect(choiceFlow.stage, HostedFlowStage.blocked); + expect( + find.text('This account does not have access.').hitTestable(), + findsOneWidget, + ); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + expect(tester.takeException(), isNull); + }); +} + +class _ChoiceAccount extends BuilderlabAccountClient { + @override + BuilderlabAccount get account => + BuilderlabAccount(subject: 'fixture', expiresAt: DateTime.utc(2100)); + @override + Future restore() async => account; + @override + Future> currentIdentity() async => {}; + @override + Future> post( + String path, + Map body, + ) async { + if (path.endsWith('/status')) return {'backup_nostr_pubkeys': []}; + throw AccountFailure.denied; + } +} diff --git a/mobile/test/features/pairing/pairing_hosted_handoff_test.dart b/mobile/test/features/pairing/pairing_hosted_handoff_test.dart new file mode 100644 index 0000000000..d0609d5b67 --- /dev/null +++ b/mobile/test/features/pairing/pairing_hosted_handoff_test.dart @@ -0,0 +1,120 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:buzz/features/pairing/pairing_provider.dart'; +import 'package:buzz/hosted_app_gate.dart'; +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/hosted/hosted_flow.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/hosted_communities_fixture.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => FlutterSecureStorage.setMockInitialValues({})); + for (final backup in [true, false]) { + test( + 'hosted pairing requires backup=$backup through real coordinator', + () async { + final f = HostedCommunitiesFixture(); + await f.initialize(backup: backup); + addTearDown(f.dispose); + var legacyProbes = 0; + final notifier = PairingNotifier( + credentialValidator: ({required relayUrl, required nsec}) async { + legacyProbes++; + }, + ); + final container = ProviderContainer( + overrides: [ + hostedFlowProvider.overrideWithValue(f.flow), + pairingProvider.overrideWith(() => notifier), + ], + ); + addTearDown(container.dispose); + final settled = Completer(); + f.flow.addListener(() { + if ((f.flow.stage == HostedFlowStage.complete || + f.flow.stage == HostedFlowStage.blocked) && + !settled.isCompleted) { + settled.complete(); + } + }); + await container.read(pairingProvider.notifier).pair(_payload(f.keys)); + await settled.future.timeout(const Duration(seconds: 2)); + expect(legacyProbes, 0); + expect(f.admitted, backup ? ['paired'] : isEmpty); + expect((await f.store.read())?.pubkey, f.keys.public); + }, + ); + } + test( + 'verified paired destination survives restart with identity constraint', + () async { + final f = HostedCommunitiesFixture(); + await f.initialize(); + addTearDown(f.dispose); + await f.flow.stagePairedCommunity( + Community.create( + name: 'Paired', + relayUrl: 'https://paired.communities.buzz.xyz', + pubkey: f.keys.public, + nsec: f.keys.nsec, + sensitiveActionPolicy: SensitiveActionPolicy.enabled, + ), + isCurrent: () => true, + ); + final browser = DiscoveryBrowser(); + final admitted = []; + final resumed = HostedFlow( + account: f.account, + browser: browser, + store: f.store, + identity: f.flow.identity, + enterCommunity: (link, subject, pubkey, current) async { + if (current()) admitted.add(pubkey); + }, + ); + addTearDown(() async { + resumed.dispose(); + await browser.dispose(); + }); + await resumed.initialize(); + expect(resumed.stage, HostedFlowStage.signIn); + expect( + resumed.pairedSensitiveActionPolicy, + SensitiveActionPolicy.enabled, + ); + await resumed.retry(); + expect(admitted, [f.keys.public]); + expect(resumed.stage, HostedFlowStage.complete); + }, + ); + + test('hosted pairing never replaces occupied account key', () async { + final f = HostedCommunitiesFixture(); + await f.initialize(); + addTearDown(f.dispose); + final container = ProviderContainer( + overrides: [hostedFlowProvider.overrideWithValue(f.flow)], + ); + addTearDown(container.dispose); + await container + .read(pairingProvider.notifier) + .pair(_payload(nostr.Keys('2'.padLeft(64, '0')))); + expect(container.read(pairingProvider).status, PairingStatus.error); + expect((await f.store.read())?.pubkey, f.keys.public); + expect(f.admitted, isEmpty); + }); +} + +String _payload(nostr.Keys keys) => base64Url.encode( + utf8.encode( + jsonEncode({ + 'relayUrl': 'https://paired.communities.buzz.xyz', + 'nsec': keys.nsec, + 'pubkey': keys.public, + }), + ), +); diff --git a/mobile/test/features/pairing/pairing_provider_test.dart b/mobile/test/features/pairing/pairing_provider_test.dart index cd5d481196..bbece7916d 100644 --- a/mobile/test/features/pairing/pairing_provider_test.dart +++ b/mobile/test/features/pairing/pairing_provider_test.dart @@ -1285,6 +1285,7 @@ class FakeAuthNotifier extends AsyncNotifier Future authenticateWithCommunity( Community community, { bool Function()? shouldContinue, + bool activate = true, }) async { if (shouldContinue != null && !shouldContinue()) return; lastCommunity = community; diff --git a/mobile/test/helpers/hosted_communities_fixture.dart b/mobile/test/helpers/hosted_communities_fixture.dart index 915d7f0a97..101a7930fb 100644 --- a/mobile/test/helpers/hosted_communities_fixture.dart +++ b/mobile/test/helpers/hosted_communities_fixture.dart @@ -30,8 +30,6 @@ class DiscoveryBrowser extends BrowserAuth { @override Future initialize() async {} @override - Future?> retainedIntent() async => null; - @override Future clearIntent() async {} @override Future cancel() async {} @@ -55,6 +53,7 @@ class WidgetDiscoveryAccount extends BuilderlabAccountClient { Map body, ) async { if (path.endsWith('/current')) { + if (fixture.rejectCurrent) throw AccountFailure.denied; return { 'identity': {'pubkey_hex': fixture.bound}, }; @@ -77,6 +76,7 @@ class HostedCommunitiesFixture { List> rows = []; String? bound; bool rejectList = false; + bool rejectCurrent = false; bool expired = false; String subject = 'owner'; Future Function(String)? beforeAdmission; diff --git a/mobile/test/shared/auth/auth_provider_test.dart b/mobile/test/shared/auth/auth_provider_test.dart index 9dcced5f33..b4379fe251 100644 --- a/mobile/test/shared/auth/auth_provider_test.dart +++ b/mobile/test/shared/auth/auth_provider_test.dart @@ -54,6 +54,45 @@ void main() { ); }); + test( + 'background batch addition preserves the first active community', + () async { + final storage = CommunityStorage(secure: FakeSecureStorage()); + final first = Community.create( + name: 'First', + relayUrl: 'wss://first.example', + nsec: nostr.Keys('1'.padLeft(64, '0')).nsec, + ); + final second = Community.create( + name: 'Second', + relayUrl: 'wss://second.example', + nsec: nostr.Keys('1'.padLeft(64, '0')).nsec, + ); + final container = ProviderContainer( + overrides: [communityStorageProvider.overrideWithValue(storage)], + ); + addTearDown(container.dispose); + await container.read(authProvider.future); + await container + .read(authProvider.notifier) + .authenticateWithCommunity(first); + var transitions = 0; + container.read(communityTransitionProvider).register(() async { + transitions++; + }); + await container + .read(authProvider.notifier) + .authenticateWithCommunity(second, activate: false); + expect(await storage.loadActiveId(), first.id); + expect(container.read(authProvider).value?.community?.id, first.id); + expect((await storage.loadAll()).map((item) => item.id), [ + first.id, + second.id, + ]); + expect(transitions, 0); + }, + ); + test('waits for transition teardown before removing credentials', () async { final storage = CommunityStorage(secure: FakeSecureStorage()); final community = Community.create( diff --git a/mobile/test/shared/hosted/builderlab_account_test.dart b/mobile/test/shared/hosted/builderlab_account_test.dart index 626d24574b..ead9f4a64b 100644 --- a/mobile/test/shared/hosted/builderlab_account_test.dart +++ b/mobile/test/shared/hosted/builderlab_account_test.dart @@ -61,6 +61,76 @@ void main() { }, ); + for (final scenario in [ + ( + 'ACCOUNT_DETAILS_STATUS_MISSING', + 'ACCOUNT_SCREENING_STATUS_NOT_SCREENED', + true, + ), + ( + 'ACCOUNT_DETAILS_STATUS_COMPLETE', + 'ACCOUNT_SCREENING_STATUS_NOT_SCREENED', + true, + ), + ( + 'ACCOUNT_DETAILS_STATUS_COMPLETE', + 'ACCOUNT_SCREENING_STATUS_REJECTED', + false, + ), + ( + 'ACCOUNT_DETAILS_STATUS_COMPLETE', + 'ACCOUNT_SCREENING_STATUS_PASSED', + false, + ), + ]) { + test( + 'account screening ${scenario.$2} maps denial and preserves session', + () async { + final client = BuilderlabAccountClient( + httpClient: MockClient((request) async { + if (request.url.path.endsWith('/exchange')) { + return http.Response( + jsonEncode({ + 'session_credential': 'fixture', + 'expires_at': expiry, + }), + 200, + ); + } + if (request.url.path.endsWith('/me')) { + return http.Response( + jsonEncode({ + 'subject': 'fixture', + 'expires_at': expiry, + 'roles': ['ROLE_USER'], + 'enabled_products': [], + 'account_standing': { + 'details_status': scenario.$1, + 'screening_status': scenario.$2, + }, + }), + 200, + ); + } + return http.Response('forbidden', 403); + }), + ); + addTearDown(client.dispose); + await client.exchange(code: 'fixture', verifier: verifier); + expect(client.account?.needsAccountSetup, scenario.$3); + await expectLater( + client.currentIdentity(), + throwsA( + scenario.$3 + ? AccountFailure.accountSetupRequired + : AccountFailure.denied, + ), + ); + expect(client.account, isNotNull); + }, + ); + } + test('invalid verification cannot persist an exchanged credential', () async { final transport = MockClient( (request) async => http.Response( diff --git a/mobile/test/shared/hosted/hosted_identity_service_test.dart b/mobile/test/shared/hosted/hosted_identity_service_test.dart index bf17d33d2e..16d6a2bc3e 100644 --- a/mobile/test/shared/hosted/hosted_identity_service_test.dart +++ b/mobile/test/shared/hosted/hosted_identity_service_test.dart @@ -139,7 +139,7 @@ void main() { ); expect(await AccountIdentityStore().read(), isNull); }); - test('distinct recovered keys block even if one matches binding', () async { + test('distinct recovered keys select the matching account binding', () async { (recovery['backups'] as List).add({ 'backup_id': 'two', 'nostr_pubkey': other.public, @@ -151,11 +151,8 @@ void main() { account: await client(), store: AccountIdentityStore(), ); - expect( - (await service.resolve()).state, - HostedIdentityState.recoveryBlocked, - ); - expect(await AccountIdentityStore().read(), isNull); + expect((await service.resolve()).state, HostedIdentityState.ready); + expect((await AccountIdentityStore().read())!.pubkey, key.public); }); test('empty recovery and unbound does not generate', () async { diff --git a/mobile/test/shared/hosted/hosted_multi_backup_recovery_test.dart b/mobile/test/shared/hosted/hosted_multi_backup_recovery_test.dart new file mode 100644 index 0000000000..a9e0869c62 --- /dev/null +++ b/mobile/test/shared/hosted/hosted_multi_backup_recovery_test.dart @@ -0,0 +1,327 @@ +import 'dart:convert'; + +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:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + // Public test vectors only; no user signing material. + final keys = [for (var i = 1; i <= 3; i++) nostr.Keys('$i'.padLeft(64, '0'))]; + Map backup(int index, {String? id}) => { + 'backup_id': id ?? 'backup-$index', + 'nostr_pubkey': keys[index].public, + 'secret_key': base64Url + .encode([...List.filled(31, 0), index + 1]) + .replaceAll('=', ''), + }; + + late String? bound; + late Map recovery; + late List listedKeys; + late AccountIdentityStore store; + late String communityJson; + late int recoveryCalls; + late int recoveryStatus; + Future Function()? duringRecovery; + + setUp(() { + bound = null; + listedKeys = [keys[0].public, keys[1].public]; + recovery = { + 'backups': [backup(0), backup(1)], + 'failed_backup_ids': [], + }; + communityJson = jsonEncode([ + {'name': 'Existing', 'nsec': keys[2].nsec, 'pubkey': keys[2].public}, + ]); + FlutterSecureStorage.setMockInitialValues({ + 'buzz_communities': communityJson, + }); + store = AccountIdentityStore(); + recoveryCalls = 0; + recoveryStatus = 200; + duringRecovery = null; + }); + + Future service({bool includeDevice = false}) async { + final expiry = DateTime.now() + .toUtc() + .add(const Duration(hours: 1)) + .toIso8601String(); + final account = BuilderlabAccountClient( + httpClient: MockClient((request) async { + final path = request.url.path; + if (path.endsWith('/exchange')) { + return http.Response( + jsonEncode({'session_credential': 'token', 'expires_at': expiry}), + 200, + ); + } + if (path.endsWith('/me')) { + return http.Response( + jsonEncode({'subject': 'a', 'expires_at': expiry}), + 200, + ); + } + if (path.endsWith('/current')) { + return http.Response( + jsonEncode({ + if (bound != null) 'identity': {'pubkey_hex': bound}, + }), + 200, + ); + } + if (path.endsWith('/status')) { + return http.Response( + jsonEncode({ + 'current_nostr_pubkey': bound, + 'backup_nostr_pubkeys': listedKeys, + }), + 200, + ); + } + if (path.endsWith('/recovery')) { + recoveryCalls++; + await duringRecovery?.call(); + return http.Response(jsonEncode(recovery), recoveryStatus); + } + return http.Response('{}', 404); + }), + ); + await account.exchange(code: 'code', verifier: 'a' * 43); + return HostedIdentityService( + account: account, + store: store, + deviceIdentities: includeDevice ? DeviceIdentitySource() : null, + canGenerateAfterLegacyReconciliation: () async => true, + ); + } + + Future expectCommunityPreserved() async { + expect( + await const FlutterSecureStorage().read(key: 'buzz_communities'), + communityJson, + ); + } + + test( + 'bound identity selects matching backup, persists receipt and resumes', + () async { + bound = keys[1].public; + final resolver = await service(); + final result = await resolver.resolve(selectedPubkey: keys[0].public); + expect(result.state, HostedIdentityState.ready); + expect(result.pubkey, keys[1].public); + final saved = (await store.read())!; + expect(saved.backup!.backupId, 'backup-1'); + expect( + saved.hasBackupFor( + backend: BuilderlabAccountClient.origin.origin, + subject: 'a', + ), + isTrue, + ); + expect((await resolver.resolve()).pubkey, keys[1].public); + expect(recoveryCalls, 1); + await expectCommunityPreserved(); + }, + ); + + test( + 'unbound distinct keys expose public choices and persist only selection', + () async { + final resolver = await service(includeDevice: true); + final pending = await resolver.resolve(); + expect(pending.state, HostedIdentityState.needsSelection); + expect(pending.choices.map((choice) => choice.pubkey), [ + keys[0].public, + keys[1].public, + ]); + expect(pending.choices.every((choice) => choice.fromCloud), isTrue); + expect(await store.read(), isNull); + final chosen = await resolver.resolve(selectedPubkey: keys[1].public); + expect(chosen.state, HostedIdentityState.needsBinding); + expect(chosen.pubkey, keys[1].public); + expect((await store.read())!.backup!.backupId, 'backup-1'); + await expectCommunityPreserved(); + }, + ); + + test( + 'selected unbound recovery resumes binding after restart without choosing again', + () async { + final first = await service(); + final selected = await first.resolve(selectedPubkey: keys[1].public); + expect(selected.state, HostedIdentityState.needsBinding); + final receipt = (await store.read())!.backup!; + final restarted = await service(); + final resumed = await restarted.resolve(); + expect(resumed.state, HostedIdentityState.needsBinding); + expect(resumed.pubkey, keys[1].public); + expect((await store.read())!.backup!.backupId, receipt.backupId); + expect(recoveryCalls, 1); + await expectCommunityPreserved(); + }, + ); + + test( + 'local key without a current-account receipt cannot bypass distinct backup conflict', + () async { + await store.create(keys[1].nsec); + final resolver = await service(); + expect((await resolver.resolve()).state, HostedIdentityState.mismatch); + await store.confirmBackup( + BackupReceipt( + backend: BuilderlabAccountClient.origin.origin, + subject: 'another-account', + pubkey: keys[1].public, + backupId: 'other-account-backup', + ), + ); + expect((await resolver.resolve()).state, HostedIdentityState.mismatch); + expect(recoveryCalls, 0); + expect((await store.read())!.pubkey, keys[1].public); + }, + ); + + test( + 'receipt does not bypass conflict when selected key is no longer listed', + () async { + final resolver = await service(); + await resolver.resolve(selectedPubkey: keys[1].public); + listedKeys = [keys[0].public]; + expect((await resolver.resolve()).state, HostedIdentityState.mismatch); + expect((await store.read())!.pubkey, keys[1].public); + expect(recoveryCalls, 1); + }, + ); + + test( + 'unknown or removed selection asks again without selecting another key', + () async { + final resolver = await service(); + expect( + (await resolver.resolve(selectedPubkey: keys[2].public)).state, + HostedIdentityState.needsSelection, + ); + recovery['backups'] = [backup(0)]; + expect( + (await resolver.resolve(selectedPubkey: keys[1].public)).state, + HostedIdentityState.needsSelection, + ); + expect(await store.read(), isNull); + }, + ); + + test( + 'duplicate backups of one key do not create an ambiguous choice', + () async { + recovery['backups'] = [backup(0), backup(0, id: 'duplicate')]; + final result = await (await service()).resolve(); + expect(result.state, HostedIdentityState.needsBinding); + expect(result.pubkey, keys[0].public); + }, + ); + + test('missing bound backup never accepts a different selected key', () async { + bound = keys[2].public; + expect( + (await (await service()).resolve(selectedPubkey: keys[0].public)).state, + HostedIdentityState.mismatch, + ); + expect(await store.read(), isNull); + }); + + test('different device key still requires explicit cloud adoption', () async { + bound = keys[1].public; + final resolver = await service(includeDevice: true); + final pending = await resolver.resolve(); + expect(pending.state, HostedIdentityState.needsSelection); + expect(pending.choices.single.pubkey, keys[1].public); + expect(await store.read(), isNull); + expect( + (await resolver.resolve(selectedPubkey: keys[1].public)).state, + HostedIdentityState.ready, + ); + await expectCommunityPreserved(); + }); + + test('partial recovery blocks even a valid matching bound backup', () async { + bound = keys[1].public; + recovery['failed_backup_ids'] = ['unreadable']; + expect( + (await (await service()).resolve()).state, + HostedIdentityState.recoveryBlocked, + ); + expect(await store.read(), isNull); + }); + + test( + 'malformed extra backup blocks before saving the valid bound backup', + () async { + bound = keys[0].public; + (recovery['backups'] as List)[1]['secret_key'] = 'invalid'; + await expectLater( + (await service()).resolve(), + throwsA(AccountFailure.invalidResponse), + ); + expect(await store.read(), isNull); + }, + ); + + test( + 'recovery failure and empty recovery never authorize generation', + () async { + final resolver = await service(); + recoveryStatus = 403; + await expectLater(resolver.resolve(), throwsA(isA())); + expect(await store.read(), isNull); + recoveryStatus = 200; + recovery['backups'] = []; + expect( + (await resolver.resolve()).state, + HostedIdentityState.needsAuthoritativeAbsence, + ); + expect(await store.read(), isNull); + }, + ); + + test( + 'occupied account slot is never replaced by a recovered choice', + () async { + final resolver = await service(); + duringRecovery = () async { + await store.create(keys[2].nsec); + }; + await expectLater( + resolver.resolve(selectedPubkey: keys[1].public), + throwsA(AccountIdentityFailure.occupied), + ); + expect((await store.read())!.pubkey, keys[2].public); + await expectCommunityPreserved(); + }, + ); + + test('cancelled recovery cannot persist the selected identity', () async { + var current = true; + final resolver = await service(); + duringRecovery = () async { + current = false; + }; + await expectLater( + resolver.resolve( + selectedPubkey: keys[1].public, + isCurrent: () => current, + ), + throwsA(AccountFailure.unavailable), + ); + expect(await store.read(), isNull); + }); +} diff --git a/mobile/test/shared/hosted/self_hosted_entry_test.dart b/mobile/test/shared/hosted/self_hosted_entry_test.dart new file mode 100644 index 0000000000..67bc9b4eb5 --- /dev/null +++ b/mobile/test/shared/hosted/self_hosted_entry_test.dart @@ -0,0 +1,312 @@ +import 'dart:convert'; + +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/hosted/account_identity_store.dart'; +import 'package:buzz/shared/hosted/device_identity_source.dart'; +import 'package:buzz/shared/hosted/self_hosted_entry.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const relay = 'wss://community.example'; + // Public test vectors, never real user signing material. + final key = nostr.Keys('1'.padLeft(64, '0')); + final other = nostr.Keys('2'.padLeft(64, '0')); + late _Storage storage; + late List probes; + late SelfHostedEntry entry; + var current = true; + var refuseProbe = false; + + SelfHostedEntry makeEntry() => SelfHostedEntry( + storage: storage, + identities: DeviceIdentitySource(storage: storage), + accountStore: AccountIdentityStore(storage: storage), + probe: (community) async { + // The durable key must already exist before any network operation. + expect(storage.data.values, contains(community.nsec)); + probes.add(community); + if (refuseProbe) throw StateError('Connection refused'); + }, + ); + + setUp(() { + storage = _Storage(); + probes = []; + current = true; + refuseProbe = false; + entry = makeEntry(); + }); + + Future prepare({ + String address = relay, + String? nsec, + String? savedPubkey, + bool create = false, + }) => entry.prepare( + relay: address, + name: 'Community', + nsec: nsec, + savedPubkey: savedPubkey, + create: create, + isCurrent: () => current, + ); + + void expectUntouched() { + expect(storage.writes, 0); + expect(probes, isEmpty); + } + + test( + 'reserved hosted domains refuse imported and generated identities', + () async { + for (final address in [ + 'wss://team.communities.buzz.xyz', + 'wss://team.communities.public.builderlabstaging.xyz', + 'wss://BUILDERLAB.XYZ.', + ]) { + await expectLater( + prepare(address: address, nsec: key.nsec), + throwsStateError, + ); + await expectLater( + prepare(address: address, create: true), + throwsStateError, + ); + } + expectUntouched(); + expect(storage.data, isEmpty); + }, + ); + + test('invalid addresses and invalid nsec never persist or probe', () async { + for (final address in [ + 'ws://community.example', + 'wss://user@community.example', + 'wss://community.example/path', + 'wss://community.example?token=value', + 'wss://community.example#fragment', + ]) { + await expectLater( + prepare(address: address, nsec: key.nsec), + throwsFormatException, + ); + } + for (final nsec in ['invalid', key.public, key.npub]) { + await expectLater( + prepare(nsec: nsec), + throwsA(AccountIdentityFailure.invalidKey), + ); + } + expectUntouched(); + expect(storage.data, isEmpty); + }); + + test( + 'failed probe retains generated key across a new service and retry', + () async { + refuseProbe = true; + await expectLater(prepare(create: true), throwsStateError); + expect(storage.writes, 1); + final persisted = Map.of(storage.data); + final first = probes.single; + expect(first.pubkey, AccountIdentityStore.publicKeyForNsec(first.nsec!)); + refuseProbe = false; + entry = makeEntry(); + final retried = await prepare(create: true); + expect(retried.pubkey, first.pubkey); + expect(retried.nsec, first.nsec); + expect(storage.data, persisted); + expect(storage.writes, 1); + expect(probes, hasLength(2)); + }, + ); + + test('existing local identity prevents generating a replacement', () async { + storage.data['buzz_nsec'] = key.nsec; + final before = Map.of(storage.data); + await expectLater( + prepare(create: true), + throwsA(AccountIdentityFailure.absent), + ); + expectUntouched(); + expect(storage.data, before); + }); + + test( + 'explicit saved identity reuses key and preserves community records', + () async { + final raw = jsonEncode([ + {'name': 'Saved', 'nsec': key.nsec, 'pubkey': key.public}, + ]); + storage.data['buzz_communities'] = raw; + storage.data['buzz_nsec'] = other.nsec; + final community = await prepare(savedPubkey: key.public); + expect(community.nsec, key.nsec); + expect(community.pubkey, key.public); + expect(community.relayUrl, relay); + expect(storage.data['buzz_communities'], raw); + expect(storage.data['buzz_nsec'], other.nsec); + expect(storage.writes, 1); + expect(probes, [community]); + }, + ); + + test( + 'account-only saved identity blocks generation and supports explicit reuse', + () async { + final accountStore = AccountIdentityStore(storage: storage); + await accountStore.create(key.nsec); + final before = Map.of(storage.data); + final writesBefore = storage.writes; + final choices = await entry.choices(); + expect(choices.map((choice) => choice.pubkey), [key.public]); + await expectLater( + prepare(create: true), + throwsA(AccountIdentityFailure.absent), + ); + expect(storage.writes, writesBefore); + expect(probes, isEmpty); + expect(storage.data, before); + final community = await prepare(savedPubkey: key.public); + expect(community.pubkey, key.public); + expect(community.nsec, key.nsec); + expect( + storage.data[AccountIdentityStore.storageKey], + before[AccountIdentityStore.storageKey], + ); + }, + ); + + test( + 'account and community choices deduplicate the same saved identity', + () async { + await AccountIdentityStore(storage: storage).create(key.nsec); + storage.data['buzz_nsec'] = key.nsec; + expect((await entry.choices()).map((choice) => choice.pubkey), [ + key.public, + ]); + }, + ); + + test('unreadable account key never permits new generation', () async { + storage.data[AccountIdentityStore.storageKey] = 'broken'; + await expectLater( + prepare(create: true), + throwsA(AccountIdentityFailure.unreadable), + ); + expectUntouched(); + }); + + test( + 'different import cannot replace retained key after a failed probe', + () async { + refuseProbe = true; + await expectLater(prepare(nsec: key.nsec), throwsStateError); + final before = Map.of(storage.data); + await expectLater( + prepare(nsec: other.nsec), + throwsA(AccountIdentityFailure.occupied), + ); + expect(storage.data, before); + expect(storage.writes, 1); + expect(probes, hasLength(1)); + }, + ); + + test( + 'missing explicit saved identity never falls back to generation', + () async { + await expectLater( + prepare(savedPubkey: key.public, create: true), + throwsA(AccountIdentityFailure.absent), + ); + expectUntouched(); + }, + ); + + test('cancellation before work performs no read, save or probe', () async { + current = false; + await expectLater(prepare(nsec: key.nsec), throwsStateError); + expect(storage.reads, 0); + expectUntouched(); + }); + + test('cancellation during read prevents durable save and probe', () async { + storage.onRead = () { + current = false; + }; + await expectLater(prepare(nsec: key.nsec), throwsStateError); + expectUntouched(); + expect(storage.data, isEmpty); + }); + + test( + 'cancellation after durable save keeps key and prevents probe', + () async { + storage.onWrite = () { + current = false; + }; + await expectLater(prepare(nsec: key.nsec), throwsStateError); + expect(storage.writes, 1); + expect(storage.data.values, contains(key.nsec)); + expect(probes, isEmpty); + current = true; + storage.onWrite = null; + expect((await prepare()).pubkey, key.public); + expect(storage.writes, 1); + }, + ); + + test('unconfirmed durable write cannot reach the relay probe', () async { + storage.dropWrites = true; + await expectLater( + prepare(nsec: key.nsec), + throwsA(AccountIdentityFailure.unreadable), + ); + expect(probes, isEmpty); + expect(storage.data, isEmpty); + }); +} + +class _Storage extends Fake implements FlutterSecureStorage { + final data = {}; + int writes = 0; + int reads = 0; + bool dropWrites = false; + void Function()? onRead; + void Function()? onWrite; + + @override + Future read({ + required String key, + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async { + reads++; + onRead?.call(); + return data[key]; + } + + @override + Future write({ + required String key, + required String? value, + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async { + writes++; + if (!dropWrites && value != null) data[key] = value; + onWrite?.call(); + } +} diff --git a/mobile/test/shared/identity_transfer/nsec_import_sheet_test.dart b/mobile/test/shared/identity_transfer/nsec_import_sheet_test.dart new file mode 100644 index 0000000000..5e87ef648a --- /dev/null +++ b/mobile/test/shared/identity_transfer/nsec_import_sheet_test.dart @@ -0,0 +1,72 @@ +import 'package:buzz/shared/hosted/account_identity_store.dart'; +import 'package:buzz/shared/identity_transfer/nsec_import_sheet.dart'; +import 'package:buzz/shared/utils/string_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + final keys = nostr.Keys('1'.padLeft(64, '0')); + + testWidgets( + 'paste requires public preview then confirmation before secure save', + (tester) async { + String? imported; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NsecImportSheet( + onImport: (nsec) async { + imported = nsec; + }, + ), + ), + ), + ); + await tester.enterText(find.byType(TextField), keys.nsec); + await tester.tap(find.text('Preview identity')); + await tester.pumpAndSettle(); + expect(find.text(fullNpub(keys.public)!), findsOneWidget); + expect(imported, isNull); + await tester.tap(find.text('Import this identity')); + await tester.pumpAndSettle(); + expect(imported, keys.nsec); + await tester.pump(); + }, + ); + + testWidgets('file import previews and occupied identity stays unchanged', ( + tester, + ) async { + var attempts = 0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NsecImportSheet( + pickFile: () async => keys.nsec, + onImport: (nsec) async { + attempts++; + throw AccountIdentityFailure.occupied; + }, + ), + ), + ), + ); + await tester.tap(find.text('Choose nsec file')); + await tester.pumpAndSettle(); + expect(find.text(fullNpub(keys.public)!), findsOneWidget); + await tester.tap(find.text('Import this identity')); + await tester.pumpAndSettle(); + expect( + find.textContaining('already has a different identity'), + findsOneWidget, + ); + expect(attempts, 1); + await tester.enterText(find.byType(TextField), 'not a key'); + await tester.pump(); + expect(find.text('Import this identity'), findsNothing); + await tester.tap(find.text('Preview identity')); + await tester.pump(); + expect(find.text('Enter a valid nsec private key.'), findsOneWidget); + }); +} diff --git a/mobile/test/shared/identity_transfer/nsec_transfer_test.dart b/mobile/test/shared/identity_transfer/nsec_transfer_test.dart new file mode 100644 index 0000000000..256ed440d3 --- /dev/null +++ b/mobile/test/shared/identity_transfer/nsec_transfer_test.dart @@ -0,0 +1,180 @@ +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/hosted/account_identity_store.dart'; +import 'package:buzz/shared/identity_transfer/nsec_transfer.dart'; +import 'package:buzz/shared/security/sensitive_action_authorizer.dart'; +import 'dart:convert'; +import 'package:file_selector/file_selector.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final keys = nostr.Keys('1'.padLeft(64, '0')); + final other = nostr.Keys('2'.padLeft(64, '0')); + setUp(() => FlutterSecureStorage.setMockInitialValues({})); + + for (final platform in [TargetPlatform.android, TargetPlatform.iOS]) { + test( + 'native $platform import validates text and preserves cancellation/error', + () async { + debugDefaultTargetPlatformOverride = platform; + const channel = MethodChannel('buzz/identity_file'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + addTearDown(() { + debugDefaultTargetPlatformOverride = null; + messenger.setMockMethodCallHandler(channel, null); + }); + String? response = '${keys.nsec}\n'; + var failRead = false; + messenger.setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'import'); + expect(call.arguments, isNull); + if (failRead) throw PlatformException(code: 'import_failed'); + return response; + }); + expect(await NsecTransfer.pickFile(), keys.nsec); + response = null; + expect(await NsecTransfer.pickFile(), isNull); + response = keys.public; + await expectLater( + NsecTransfer.pickFile(), + throwsA(AccountIdentityFailure.invalidKey), + ); + response = 'x' * 1025; + await expectLater( + NsecTransfer.pickFile(), + throwsA(AccountIdentityFailure.invalidKey), + ); + failRead = true; + await expectLater( + NsecTransfer.pickFile(), + throwsA(isA()), + ); + }, + ); + } + + test('paste parsing rejects wrong key type, invalid scalar and checksum', () { + expect(NsecTransfer.preview(' ${keys.nsec}\n'), keys.public); + for (final value in [ + keys.public, + 'nsec1bad', + nostr.Nip19.encode(prefix: nostr.Nip19Prefix.nsec, data: '0' * 64), + ]) { + expect( + () => NsecTransfer.preview(value), + throwsA(AccountIdentityFailure.invalidKey), + ); + } + }); + + test('file import validates size, UTF-8, and key type', () async { + XFile file(List bytes) => XFile.fromData(Uint8List.fromList(bytes)); + expect( + await NsecTransfer.readFile(file(utf8.encode('${keys.nsec}\n'))), + keys.nsec, + ); + for (final bytes in [ + utf8.encode('x' * 1025), + [0xff], + utf8.encode(keys.public), + ]) { + await expectLater( + NsecTransfer.readFile(file(bytes)), + throwsA(AccountIdentityFailure.invalidKey), + ); + } + }); + + test( + 'manual import is create-only, preserves receipt and occupied keys', + () async { + final store = AccountIdentityStore(); + final receipt = BackupReceipt( + backend: 'https://app.builderlab.xyz', + subject: 'a', + pubkey: keys.public, + backupId: 'backup', + ); + await store.create(keys.nsec, recoveredBackup: receipt); + final restored = await NsecTransfer.importAccount( + ' ${keys.nsec}\n', + store, + ); + expect(restored.backup?.backupId, 'backup'); + await expectLater( + NsecTransfer.importAccount(other.nsec, store), + throwsA(AccountIdentityFailure.occupied), + ); + expect(await store.withKeys((k) async => k.public), keys.public); + expect((await store.read())?.backup?.backupId, 'backup'); + }, + ); + + test( + 'manual import fences cancelled operations before secure write', + () async { + final store = AccountIdentityStore(); + await expectLater( + NsecTransfer.importAccount( + keys.nsec, + store, + checkCurrent: () => throw StateError('cancelled'), + ), + throwsStateError, + ); + expect(await store.read(), isNull); + }, + ); + + test( + 'export respects biometric policy, auth denial and selected identity', + () async { + final community = Community.create( + name: 'Test', + relayUrl: 'wss://test.invalid', + nsec: keys.nsec, + pubkey: keys.public, + sensitiveActionPolicy: SensitiveActionPolicy.enabled, + ); + var shares = 0; + var current = true; + var result = DeviceAuthResult.cancelled; + Future export() => NsecTransfer.exportCommunity( + community: community, + authorize: (biometricOnly) async { + expect(biometricOnly, isTrue); + return result; + }, + isCurrent: () => current, + share: (nsec) async { + expect(nsec, keys.nsec); + shares++; + return true; + }, + ); + expect(await export(), isFalse); + result = DeviceAuthResult.success; + current = false; + expect(await export(), isFalse); + expect(shares, 0); + current = true; + expect(await export(), isTrue); + expect(shares, 1); + await expectLater( + NsecTransfer.exportCommunity( + community: community.copyWith(nsec: other.nsec), + authorize: (_) async => + fail('must not authorize a mismatched identity'), + isCurrent: () => true, + share: (_) async => fail('must not share a mismatched identity'), + ), + throwsA(AccountIdentityFailure.mismatch), + ); + }, + ); +} diff --git a/mobile/test/shared/security/sensitive_action_authorizer_test.dart b/mobile/test/shared/security/sensitive_action_authorizer_test.dart index 940b9f12ea..a4a2f3290b 100644 --- a/mobile/test/shared/security/sensitive_action_authorizer_test.dart +++ b/mobile/test/shared/security/sensitive_action_authorizer_test.dart @@ -35,7 +35,7 @@ void main() { expect(result, DeviceAuthResult.success); verify( () => authentication.authenticate( - localizedReason: 'Confirm sending your Buzz identity to desktop', + localizedReason: 'Confirm exporting your Buzz identity', authMessages: any(named: 'authMessages'), biometricOnly: false, sensitiveTransaction: true, diff --git a/mobile/tool/hpke_android/README.md b/mobile/tool/hpke_android/README.md new file mode 100644 index 0000000000..ba4aee2ba9 --- /dev/null +++ b/mobile/tool/hpke_android/README.md @@ -0,0 +1,23 @@ +# Android HPKE verification + +Activate the repository Hermit environment and use a Gradle-supported JDK (21). +The Android SDK and Flutter dependencies must already be installed. + +```sh +mobile/android/gradlew -p mobile/android :app:testDebugUnitTest \ + --tests 'xyz.block.buzz.mobile.HostedHpke*Test' +python3 mobile/tool/hpke_android/check_interop.py +``` + +The focused JVM tests cover the published RFC 9180 P-256/HKDF-SHA256/AES-256-GCM +base-mode known-answer vector, point/input rejection, fresh encapsulation, +registered Flutter codec dispatch, plaintext buffer clearing and redacted errors. + +The macOS interoperability check invokes the **actual compiled Android sender** +with the repository's public test-only backend fixture. Apple CryptoKit decrypts +its output with the fixture recipient key; changed ciphertext and AAD must fail. +`--classes PATH` selects separately compiled classes, including isolated mutation +builds. Never supply real identities or backups to these verification tools. + +These checks do not prove the full hosted network flow, Android document-picker +UI, or Android platform-provider behavior; those need device workflow evidence. diff --git a/mobile/tool/hpke_android/Seal.java b/mobile/tool/hpke_android/Seal.java new file mode 100644 index 0000000000..cab8e54555 --- /dev/null +++ b/mobile/tool/hpke_android/Seal.java @@ -0,0 +1,20 @@ +import java.util.Base64; +import xyz.block.buzz.mobile.HostedHpkeBackup; + +/** Seals test-only fixture bytes with the actual compiled Android sender. */ +public final class Seal { + private static byte[] hex(String value) { + byte[] result = new byte[value.length() / 2]; + for (int i = 0; i < result.length; i++) { + result[i] = (byte) Integer.parseInt(value.substring(i * 2, i * 2 + 2), 16); + } + return result; + } + public static void main(String[] args) { + var sealed = HostedHpkeBackup.INSTANCE.seal( + hex(args[0]), hex(args[1]), hex(args[2])); + var base64 = Base64.getUrlEncoder().withoutPadding(); + System.out.println(base64.encodeToString(sealed.get("enc"))); + System.out.println(base64.encodeToString(sealed.get("ciphertext"))); + } +} diff --git a/mobile/tool/hpke_android/check_interop.py b/mobile/tool/hpke_android/check_interop.py new file mode 100644 index 0000000000..3088250e8c --- /dev/null +++ b/mobile/tool/hpke_android/check_interop.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""After :app:compileDebugKotlin, decrypt the Android output using Apple HPKE. + +Requires macOS Swift/CryptoKit, Java 17+, and the Gradle-resolved Kotlin stdlib. +Only the repository's public test fixture is used. All artifacts are temporary. +""" +import argparse +import json +import os +from pathlib import Path +import subprocess +import tempfile + +root = Path(__file__).resolve().parents[3] +cache = Path(os.environ.get("GRADLE_USER_HOME", Path.home() / ".gradle")) +stdlib = sorted((cache / "caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib").glob("*/*/kotlin-stdlib-[0-9]*.jar")) +stdlib = [p for p in stdlib if not p.name.endswith(("-sources.jar", "-javadoc.jar"))][-1] +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("--classes", type=Path, default=root / "mobile/build/app/tmp/kotlin-classes/debug") +classes = parser.parse_args().classes +fixture = json.loads((root / "desktop/src-tauri/src/testdata/hpke_nsec_backup_v1.json").read_text()) +with tempfile.TemporaryDirectory(prefix="buzz-android-hpke-") as temp: + classpath = os.pathsep.join(map(str, (classes, stdlib, temp))) + subprocess.run(["javac", "--release", "17", "-cp", classpath, "-d", temp, str(Path(__file__).with_name("Seal.java"))], check=True) + output = subprocess.check_output([ + "java", "-cp", classpath, "Seal", fixture["recipient_public_key_sec1_hex"], + fixture["nostr_secret_key_hex"], fixture["aad_hex"], + ], text=True).splitlines() + fixture["envelope"].update(enc=output[0], ciphertext=output[1]) + sealed = Path(temp) / "android.json" + sealed.write_text(json.dumps(fixture)) + executable = str(Path(temp) / "interop") + subprocess.run([ + "swiftc", str(root / "mobile/ios/Runner/HostedHpkeBackup.swift"), + str(root / "mobile/tool/onboarding/hpke_interop.swift"), "-o", executable, + ], check=True) + subprocess.run([executable, str(sealed)], check=True) + # Authentication must reject changes to the actual Android ciphertext and AAD. + for field in ("aad_hex", "ciphertext"): + changed = json.loads(sealed.read_text()) + if field == "aad_hex": + changed[field] = "00" + changed[field][2:] + else: + cipher = changed["envelope"][field] + changed["envelope"][field] = ("B" if cipher[0] == "A" else "A") + cipher[1:] + bad = Path(temp) / "tampered.json" + bad.write_text(json.dumps(changed)) + result = subprocess.run([executable, str(bad)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if result.returncode == 0: + raise RuntimeError(f"Accepted modified {field}") + print("Android sender → CryptoKit receiver: decrypt and tamper rejection passed")