diff --git a/.github/workflows/_ci-clients.yml b/.github/workflows/_ci-clients.yml index bb62f49e9b..b6673f74d3 100644 --- a/.github/workflows/_ci-clients.yml +++ b/.github/workflows/_ci-clients.yml @@ -135,6 +135,21 @@ jobs: - name: Test if: matrix.lane == 'test' run: cd mobile && flutter test --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example --total-shards=3 --shard-index=${{ matrix.shard }} + - name: Test configured authentication domains + if: matrix.lane == 'checks' + run: >- + cd mobile && flutter test + --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example + --dart-define=BUZZ_AUTH_DOMAINS=buzz.xyz,builderlab.xyz,builderlabstaging.xyz + test/shared/auth/community_entry_requirements_test.dart + test/features/invites/invite_join_provider_test.dart + test/features/onboarding/onboarding_entry_routes_test.dart + test/shared/hosted/hosted_environment_test.dart + test/shared/hosted/self_hosted_entry_test.dart + test/features/onboarding/hosted_app_gate_environment_test.dart + test/features/onboarding/hosted_journeys_test.dart + test/features/onboarding/hosted_communities_test.dart + test/features/pairing/pairing_hosted_handoff_test.dart - name: Test mobile without push configuration if: matrix.lane == 'checks' run: cd mobile && flutter test test/shared/push/push_unconfigured_build_test.dart @@ -212,6 +227,7 @@ jobs: -configuration Debug -destination "platform=iOS Simulator,id=$device" \ -parallel-testing-enabled NO \ -only-testing:RunnerTests/BuzzPushNavigationBridgeTests \ + -only-testing:RunnerTests/IdentityFilePluginTests \ CODE_SIGNING_ALLOWED=NO test - name: Build complete unsigned iOS release if: matrix.lane == 'release' diff --git a/crates/buzz-db/src/store/community.rs b/crates/buzz-db/src/store/community.rs index 46d7db6487..71199ffdae 100644 --- a/crates/buzz-db/src/store/community.rs +++ b/crates/buzz-db/src/store/community.rs @@ -291,6 +291,40 @@ impl Db { .transpose() } + /// Active communities the identity has joined, regardless of role. + /// Operator-plane callers must authenticate before exposing this cross-tenant read. + #[datastore_span(name = "list_communities_joined_by", system = "postgresql")] + pub async fn list_communities_joined_by(&self, pubkey: &str) -> Result> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let rows = sqlx::query( + r#" + SELECT c.id, c.host FROM communities c + JOIN relay_members rm ON rm.community_id = c.id + WHERE rm.pubkey = $1 AND c.archived_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM community_deletion_requests request + WHERE request.community_id = c.id AND request.stage <> 'aborted' + ) + ORDER BY c.created_at ASC, c.host ASC + "#, + ) + .bind(pubkey.to_ascii_lowercase()) + .fetch_all(&mut *connection) + .await?; + rows.into_iter() + .map(|row| { + Ok(CommunityRecord { + id: CommunityId::from_uuid(row.try_get("id")?), + host: row.try_get("host")?, + }) + }) + .collect() + } + /// Lists communities where `owner_pubkey` currently holds the `owner` role. /// /// This is an operator-plane helper, not a tenant-scoped data-plane read: @@ -1587,6 +1621,28 @@ mod postgres_tests { crate::relay_members::max_communities_per_owner() ); assert!(owned.can_create); + + // Discovery includes every membership role, while management remains owner-only. + let community_d = CommunityId::from_uuid(make_community(&db.pool).await); + db.add_relay_member(community_d, owner, "member", None) + .await + .expect("member D"); + let joined = db + .list_communities_joined_by(&owner.to_uppercase()) + .await + .expect("joined"); + let ids: Vec<_> = joined.iter().map(|row| row.id).collect(); + assert_eq!(ids.len(), 3); + assert!(ids.contains(&community_a)); + assert!(ids.contains(&community_c)); + assert!(ids.contains(&community_d)); + assert!(!ids.contains(&community_b)); + sqlx::query("UPDATE communities SET archived_at = NOW() WHERE id = $1") + .bind(community_d.as_uuid()) + .execute(&db.pool) + .await + .unwrap(); + assert_eq!(db.list_communities_joined_by(owner).await.unwrap().len(), 2); } #[tokio::test] diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index fff7699c52..92771f1683 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -41,6 +41,12 @@ pub struct ListCommunitiesQuery { owner_pubkey: String, } +/// Query parameters for joined-community discovery by an authenticated operator. +#[derive(Debug, Deserialize)] +pub struct JoinedCommunitiesQuery { + member_pubkey: String, +} + /// Query parameters for `GET /operator/communities/availability`. #[derive(Debug, Deserialize)] pub struct CommunityAvailabilityQuery { @@ -688,6 +694,42 @@ pub async fn delete_community( )) } +/// Return active memberships for an operator-supplied pubkey. +/// The operator is trusted to select the identity; this route does not bind it to a user session. +pub async fn list_joined_communities( + State(state): State>, + headers: HeaderMap, + RawQuery(raw_query): RawQuery, + Query(query): Query, +) -> Result, (StatusCode, Json)> { + authorize_operator_request( + &state, + &headers, + "GET", + "/operator/communities/joined", + raw_query.as_deref(), + None, + ) + .await?; + let pubkey = validate_pubkey_hex(&query.member_pubkey).ok_or_else(|| { + api_error( + StatusCode::BAD_REQUEST, + "invalid member_pubkey: expected 64-char hex pubkey", + ) + })?; + let communities = state + .db + .list_communities_joined_by(&pubkey) + .await + .map_err(|e| internal_error(&format!("list joined communities: {e}")))?; + Ok(Json(serde_json::json!({ + "member_pubkey": pubkey, + "communities": communities.into_iter().map(|row| serde_json::json!({ + "community_id": row.id.to_string(), "host": row.host, + })).collect::>() + }))) +} + /// List communities where a pubkey currently holds the `owner` role. pub async fn list_owned_communities( State(state): State>, diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 5970f26f6e..49ed25223d 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -349,6 +349,10 @@ pub fn build_router(state: Arc) -> Router { "/operator/communities", get(api::operator::list_owned_communities).post(api::operator::provision_community), ) + .route( + "/operator/communities/joined", + get(api::operator::list_joined_communities), + ) .route( "/operator/listener/pubkeys", post(api::operator::register_listener_pubkeys) diff --git a/docs/admin/README.md b/docs/admin/README.md index 12fb236e5a..2ffe30f1be 100644 --- a/docs/admin/README.md +++ b/docs/admin/README.md @@ -48,6 +48,13 @@ RELAY_OPERATOR_PUBKEYS=<64-char hex pubkey>[,<64-char hex pubkey>...] with a `WARN` and provisioning requests fail closed at request time until the origin is set — the admin console is unaffected. +`GET /operator/communities/joined?member_pubkey=<64-char hex pubkey>` returns +active memberships for an operator-selected identity, excluding archived and +deleting communities. Like the provisioning routes, it requires the configured +operator origin and an allowlisted NIP-98 signature over the full request URL. +The relay authenticates the operator, not a Builderlab end-user session; the +operator is responsible for choosing the appropriate member pubkey. + #### Auto-discovery via NIP-11 When `BUZZ_ADMIN_HOST` is set, the relay advertises the admin API origin in its diff --git a/mobile/README.md b/mobile/README.md index c2e7e6f4aa..af91cf591f 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -5,6 +5,36 @@ Flutter mobile client for Buzz. See [VISION_MOBILE.md](../VISION_MOBILE.md) for intended behavior and architecture. +## Optional Builderlab login + +`BUZZ_AUTH_DOMAINS` is a comma-separated build-time environment setting, passed +with Flutter's `--dart-define` (shell environment variables alone are not read): + +```bash +flutter run --dart-define=BUZZ_AUTH_DOMAINS=communities.buzz.xyz,communities.public.builderlabstaging.xyz +``` + +Unset or empty means **Builderlab features are disabled**: no account login, +community creation, backup/recovery UI, reconnect prompts, or background account +restoration. QR/code pairing, community links and invites use Nostr-only entry. +Saved account keys and sessions are retained for a later enabled build. + +A nonempty list enables Builderlab features and requires account setup only for +matching domains. Those features need their backend endpoints. Normal relay key +authentication remains in place. `BUZZ_BUILDERLAB_URL` selects the backend; setting +it alone does not enable Builderlab features. + +Entries match the domain itself and its subdomains, case-insensitively. Use DNS +names, not URLs or wildcards. The list does not add trusted account servers: +configured domains outside the supported Builderlab realm cannot use hosted +setup. `BUZZ_BUILDERLAB_URL` still selects the trusted account environment. + +## Hosted relay authentication + +`--dart-define=BUZZ_HOSTED_RELAY_AUTH=true` enables Builderlab relay assertions +and reconnect sign-in **only for `BUZZ_AUTH_DOMAINS` matches**. It defaults off: configure backend assertion issuance +before enabling mobile authentication, and enable relay enforcement last. + ## Setup Use the Flutter SDK pinned by the repository. Activate Hermit from the repo diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 9121acbc15..ff71d174f7 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -18,7 +18,10 @@ + android:icon="@mipmap/ic_launcher" + android:fullBackupContent="@xml/backup_rules" + android:dataExtractionRules="@xml/data_extraction_rules" + tools:targetApi="31"> + + + + + + + { + 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/main/res/xml/backup_rules.xml b/mobile/android/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000000..72ecad2572 --- /dev/null +++ b/mobile/android/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/mobile/android/app/src/main/res/xml/data_extraction_rules.xml b/mobile/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000000..8d14fc46fc --- /dev/null +++ b/mobile/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + 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 5da4c943b3..142fe15921 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ 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 */; }; DAAB00000000000000000001 /* NativeTabler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAAB00000000000000000002; }; 4A71C0192F40D00100A17E02 /* MeAvatarLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01A2F40D00100A17E02 /* MeAvatarLoader.swift */; }; C0AF00000000000000000001 /* NativeConfirmationDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AF00000000000000000002 /* NativeConfirmationDialog.swift */; }; @@ -58,6 +60,7 @@ AB1200012F40400100A17E01 /* NativeMessageMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1200112F40400100A17E01 /* NativeMessageMenu.swift */; }; AB1200022F40400100A17E01 /* NativeReactionDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1200122F40400100A17E01 /* NativeReactionDetails.swift */; }; AB1200032F40400100A17E01 /* NativeMessageImageLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1200132F40400100A17E01 /* NativeMessageImageLoaderTests.swift */; }; + AB1200032F40400100A17E09 /* IdentityFilePluginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1200132F40400100A17E09 /* IdentityFilePluginTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -95,6 +98,8 @@ /* 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 = ""; }; DAAB00000000000000000002 /* NativeTabler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTabler.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 = ""; }; @@ -161,6 +166,7 @@ AB1200112F40400100A17E01 /* NativeMessageMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMessageMenu.swift; sourceTree = ""; }; AB1200122F40400100A17E01 /* NativeReactionDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeReactionDetails.swift; sourceTree = ""; }; AB1200132F40400100A17E01 /* NativeMessageImageLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMessageImageLoaderTests.swift; sourceTree = ""; }; + AB1200132F40400100A17E09 /* IdentityFilePluginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityFilePluginTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -197,6 +203,7 @@ children = ( 331C807B294A618700263BE5 /* RunnerTests.swift */, AB1200132F40400100A17E01 /* NativeMessageImageLoaderTests.swift */, + AB1200132F40400100A17E09 /* IdentityFilePluginTests.swift */, BZZ0000000000000000002C /* BuzzCommunicationNotificationTests.swift */, 331C80A0294A618700263BE5 /* Fixtures */, ); @@ -274,6 +281,8 @@ AB1200112F40400100A17E01 /* NativeMessageMenu.swift */, AB1200122F40400100A17E01 /* NativeReactionDetails.swift */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + BBAA00000000000000000002 /* HostedHpkeBackup.swift */, + BBAA00000000000000000012 /* IdentityFilePlugin.swift */, BZZ00000000000000000024 /* PushNativeState.swift */, BZZ00000000000000000027 /* PushEndpointGrantStore.swift */, BZZ0000000000000000002A /* PushSnapshotBridge.swift */, @@ -601,6 +610,7 @@ files = ( 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, AB1200032F40400100A17E01 /* NativeMessageImageLoaderTests.swift in Sources */, + AB1200032F40400100A17E09 /* IdentityFilePluginTests.swift in Sources */, BZZ0000000000000000002B /* BuzzCommunicationNotificationTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -613,6 +623,8 @@ AB1200012F40400100A17E01 /* NativeMessageMenu.swift in Sources */, 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 fb88d746b0..67ec31d951 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -1,3 +1,4 @@ +import AuthenticationServices import AVFoundation import BuzzPushKit import DeclaredAgeRange @@ -45,6 +46,9 @@ 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? override func application( @@ -64,6 +68,27 @@ 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 + guard call.method == "seal" else { result(FlutterMethodNotImplemented); return } + guard #available(iOS 17.0, *) else { + result(FlutterError(code: "unsupported", message: "Backup requires iOS 17 or later", details: nil)); return + } + guard let args = call.arguments as? [String: Any], + let recipient = args["recipient"] as? FlutterStandardTypedData, + let plaintext = args["plaintext"] as? FlutterStandardTypedData, + let aad = args["aad"] as? FlutterStandardTypedData else { + result(FlutterError(code: "invalid_input", message: nil, details: nil)); return + } + do { + let sealed = try HostedHpkeBackup.seal(recipient: recipient.data, plaintext: plaintext.data, aad: aad.data) + result(["enc": FlutterStandardTypedData(bytes: sealed.enc), "ciphertext": FlutterStandardTypedData(bytes: sealed.ciphertext)]) + } catch { + result(FlutterError(code: "backup_unavailable", message: nil, details: nil)) + } + } huddleMediaPlugin = HuddleMediaPlugin(messenger: messenger) if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "BuzzIosNavigationBar") { registrar.register(IosNavigationBarFactory(messenger: messenger, parent: registrar.viewController), withId: "buzz/ios_navigation_bar") @@ -1087,3 +1112,66 @@ extension BuzzPushNavigationTarget { return arguments } } + + +/// Keeps the authentication callback scoped to the system browser session. +private final class BuzzBrowserAuthBridge: NSObject, ASWebAuthenticationPresentationContextProviding { + private let channel: FlutterMethodChannel + private var session: ASWebAuthenticationSession? + private var pending: FlutterResult? + private var attemptID: UUID? + + init(messenger: FlutterBinaryMessenger) { + channel = FlutterMethodChannel(name: "buzz/browser_auth", binaryMessenger: messenger) + super.init() + channel.setMethodCallHandler { [weak self] call, result in + guard let self else { return } + if call.method == "cancel" { + self.session?.cancel() + self.finish(nil, code: "cancelled") + result(nil) + return + } + guard call.method == "start" else { result(FlutterMethodNotImplemented); return } + guard self.session == nil, + let args = call.arguments as? [String: String], + let raw = args["url"], let url = URL(string: raw), + url.scheme == "https", + ["app.builderlab.xyz", "app.builderlabstaging.xyz"].contains(url.host ?? ""), + url.path == "/api/goose/v1/auth/login", url.user == nil, url.password == nil, + url.port == nil, url.fragment == nil + else { result(FlutterError(code: "unavailable", message: nil, details: nil)); return } + self.pending = result + let attemptID = UUID() + self.attemptID = attemptID + let session = ASWebAuthenticationSession(url: url, callbackURLScheme: "xyz.block.buzz.mobile") { [weak self] callback, error in + DispatchQueue.main.async { + guard self?.attemptID == attemptID else { return } + let cancelled = (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin + self?.finish(callback?.absoluteString, code: cancelled ? "cancelled" : "unavailable") + } + } + session.presentationContextProvider = self + session.prefersEphemeralWebBrowserSession = false + #if DEBUG + session.prefersEphemeralWebBrowserSession = args["ephemeral"] == "true" + #endif + self.session = session + if !session.start() { self.finish(nil, code: "unavailable") } + } + } + + private func finish(_ callback: String?, code: String) { + let result = pending + attemptID = nil + pending = nil + session = nil + if let callback { result?(callback) } + else { result?(FlutterError(code: code, message: nil, details: nil)) } + } + + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows }.first(where: { $0.isKeyWindow }) ?? ASPresentationAnchor() + } +} diff --git a/mobile/ios/Runner/HostedHpkeBackup.swift b/mobile/ios/Runner/HostedHpkeBackup.swift new file mode 100644 index 0000000000..0b742eae1d --- /dev/null +++ b/mobile/ios/Runner/HostedHpkeBackup.swift @@ -0,0 +1,26 @@ +import CryptoKit +import Foundation + +/// RFC 9180 base-mode sealing only. Enrollment, key ownership, durable envelope +/// storage and upload are enforced by the hosted coordinator, never by a link. +/// The caller must pass AAD built from the authenticated enrollment contract. +@available(iOS 17.0, macOS 14.0, *) +enum HostedHpkeBackup { + enum Failure: Error { case invalidInput } + + static func seal(recipient: Data, plaintext: Data, aad: Data) throws -> (enc: Data, ciphertext: Data) { + guard recipient.count == 65, recipient.first == 4, + plaintext.count == 32, !aad.isEmpty + else { throw Failure.invalidInput } + let publicKey = try P256.KeyAgreement.PublicKey(x963Representation: recipient) + var sender = try HPKE.Sender( + recipientKey: publicKey, + ciphersuite: .P256_SHA256_AES_GCM_256, + info: Data("buzz/nsec-backup/v1".utf8) + ) + let ciphertext = try sender.seal(plaintext, authenticating: aad) + guard sender.encapsulatedKey.count == 65, ciphertext.count == 48 + else { throw Failure.invalidInput } + return (sender.encapsulatedKey, ciphertext) + } +} diff --git a/mobile/ios/Runner/IdentityFilePlugin.swift b/mobile/ios/Runner/IdentityFilePlugin.swift new file mode 100644 index 0000000000..d909ea7061 --- /dev/null +++ b/mobile/ios/Runner/IdentityFilePlugin.swift @@ -0,0 +1,187 @@ +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? + private weak var activePicker: UIDocumentPickerViewController? + + init(messenger: FlutterBinaryMessenger) { + channel = FlutterMethodChannel(name: "buzz/identity_file", binaryMessenger: messenger) + super.init() + Self.removeInterruptedExports() + channel.setMethodCallHandler { [weak self] call, result in + guard let self else { + result(FlutterError(code: "import_failed", message: nil, details: nil)) + return + } + if call.method == "prepareExport" { + self.prepareExport(call.arguments, result: result) + return + } + guard call.method == "import" else { + result(FlutterMethodNotImplemented) + return + } + self.pick(result: result) + } + } + + /// Write the share source with complete protection from its first byte. + /// Dart owns normal cleanup; startup handles a process killed during sharing. + private func prepareExport(_ arguments: Any?, result: FlutterResult) { + guard let arguments = arguments as? [String: Any], + let text = arguments["text"] as? String, + !text.isEmpty, text.utf8.count <= 1024, + let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + else { + result(FlutterError(code: "export_failed", message: nil, details: nil)) + return + } + let directory = root.appendingPathComponent("buzz-identity-\(UUID().uuidString)") + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + let file = directory.appendingPathComponent("buzz-identity.nsec") + try Data(text.utf8).write(to: file, options: [.atomic, .completeFileProtection]) + result(file.path) + } catch { + try? FileManager.default.removeItem(at: directory) + result(FlutterError(code: "export_failed", message: nil, details: nil)) + } + } + + /// A terminated share never reaches Dart's finally. Retire its sandbox copy + /// on the next launch; active shares cannot exist while this engine starts. + private static func removeInterruptedExports() { + let manager = FileManager.default + // path_provider_foundation resolves getTemporaryDirectory to Caches. + let roots = manager.urls(for: .cachesDirectory, in: .userDomainMask) + + [manager.temporaryDirectory] + for root in roots { + do { + let entries = try manager.contentsOfDirectory( + at: root, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + for entry in entries where entry.lastPathComponent.hasPrefix("buzz-identity-") { + try manager.removeItem(at: entry) + } + } catch { + // No path or key material in diagnostics. Retry on the next launch. + NSLog("Buzz could not clean up an interrupted identity export") + } + } + } + + 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() + activePicker = picker + top.present(picker, animated: true) + picker.presentationController?.delegate = self + } + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard controller === activePicker else { return } + 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) { + guard controller === activePicker else { return } + finish(nil) + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + guard presentationController.presentedViewController === activePicker else { return } + finish(nil) + } + + private func finish(_ value: Any?) { + let result = pending + pending = nil + requestID = nil + activePicker = nil + result?(value) + } +} diff --git a/mobile/ios/RunnerTests/IdentityFilePluginTests.swift b/mobile/ios/RunnerTests/IdentityFilePluginTests.swift new file mode 100644 index 0000000000..b2a10df7a2 --- /dev/null +++ b/mobile/ios/RunnerTests/IdentityFilePluginTests.swift @@ -0,0 +1,182 @@ +import Flutter +import UIKit +import XCTest +@testable import Buzz + +/// Actual UIKit picker presentation and method-channel dispatch, followed by +/// synthetic document-provider delegate callbacks. Never persists an identity. +final class IdentityFilePluginTests: XCTestCase { + @MainActor + private func makeWindow() throws -> (UIWindow, UIViewController) { + let scene = try XCTUnwrap(UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive }) + let parent = UIViewController() + let window = UIWindow(windowScene: scene) + window.rootViewController = parent + window.makeKeyAndVisible() + parent.loadViewIfNeeded() + return (window, parent) + } + + @MainActor + private func picker(in parent: UIViewController) async throws -> UIDocumentPickerViewController { + let presented = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in + parent.presentedViewController is UIDocumentPickerViewController + }, object: nil) + await fulfillment(of: [presented], timeout: 5) + return try XCTUnwrap(parent.presentedViewController as? UIDocumentPickerViewController) + } + + @MainActor + private func settle(_ controller: UIViewController) async { + if let transition = controller.transitionCoordinator { + await withCheckedContinuation { continuation in + transition.animate(alongsideTransition: nil) { _ in continuation.resume() } + } + } + } + + @MainActor + private func dismiss(_ parent: UIViewController) async { + await withCheckedContinuation { continuation in + parent.dismiss(animated: false) { continuation.resume() } + } + } + + @MainActor + func testPickerResolvesPresenterAtRequestAndCancelsExactlyOnce() async throws { + let messenger = IdentityFileTestMessenger() + // A previous process died with a share open. Startup must remove only its + // export directory, while preserving unrelated temporary documents. + let manager = FileManager.default + // Matches path_provider_foundation 2.6.0 getTemporaryPath(). + let exportRoot = try XCTUnwrap(manager.urls(for: .cachesDirectory, in: .userDomainMask).first) + // Exercise the production method-channel writer, not a lookalike fixture. + let exporter = IdentityFilePlugin(messenger: messenger) + var exportedPath: String? + messenger.invoke("prepareExport", arguments: ["text": "synthetic-export-not-a-key\n"]) { + exportedPath = $0 as? String + } + let exported = URL(fileURLWithPath: try XCTUnwrap(exportedPath)) + let interrupted = exported.deletingLastPathComponent() + XCTAssertEqual(interrupted.deletingLastPathComponent().standardizedFileURL, exportRoot.standardizedFileURL) + XCTAssertEqual(try String(contentsOf: exported, encoding: .utf8), "synthetic-export-not-a-key\n") + #if !targetEnvironment(simulator) + // Simulator does not implement iOS Data Protection attributes. + let attributes = try manager.attributesOfItem(atPath: exported.path) + XCTAssertEqual(attributes[.protectionKey] as? FileProtectionType, .complete) + #endif + withExtendedLifetime(exporter) {} + let unrelated = exportRoot.appendingPathComponent("unrelated-\(UUID().uuidString)") + try Data("keep".utf8).write(to: unrelated) + defer { + try? manager.removeItem(at: interrupted) + try? manager.removeItem(at: unrelated) + } + // Initialize before this window exists: registrar startup timing must not + // permanently prevent later user-triggered presentation. + let plugin = IdentityFilePlugin(messenger: messenger) + XCTAssertFalse(manager.fileExists(atPath: interrupted.path)) + XCTAssertTrue(manager.fileExists(atPath: unrelated.path)) + let (window, parent) = try makeWindow() + defer { window.isHidden = true } + var responses = 0 + messenger.invokeImport { value in + responses += 1 + XCTAssertNil(value) + } + let picker = try await picker(in: parent) + await settle(picker) + XCTAssertFalse(picker.allowsMultipleSelection) + XCTAssertEqual(responses, 0) + var busy: FlutterError? + messenger.invokeImport { busy = $0 as? FlutterError } + XCTAssertEqual(busy?.code, "import_busy") + XCTAssertNil(busy?.details) + let attachment = XCTAttachment(image: UIGraphicsImageRenderer(bounds: window.bounds).image { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + }) + attachment.name = "Identity document picker presented" + attachment.lifetime = .keepAlways + add(attachment) + plugin.documentPickerWasCancelled(picker) + plugin.documentPickerWasCancelled(picker) + XCTAssertEqual(responses, 1) + await dismiss(parent) + messenger.invokeImport { value in + responses += 1 + XCTAssertNil(value) + } + let next = try await self.picker(in: parent) + await settle(next) + // Late callbacks from the previous picker must not finish the new request. + plugin.documentPickerWasCancelled(picker) + XCTAssertEqual(responses, 1) + plugin.documentPicker(picker, didPickDocumentsAt: []) + XCTAssertEqual(responses, 1) + plugin.documentPickerWasCancelled(next) + XCTAssertEqual(responses, 2) + await dismiss(parent) + } + + @MainActor + func testSyntheticDocumentsReadInPlaceAndRejectInvalidOrOversize() async throws { + let messenger = IdentityFileTestMessenger() + let plugin = IdentityFilePlugin(messenger: messenger) + let (window, parent) = try makeWindow() + defer { window.isHidden = true } + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let cases: [(Data, String?)] = [ + (Data("synthetic-nsec-file-not-an-identity\n".utf8), "synthetic-nsec-file-not-an-identity\n"), + (Data(repeating: 97, count: 1024), String(repeating: "a", count: 1024)), + (Data(repeating: 97, count: 1025), nil), + (Data([0xc3, 0x28]), nil), + (Data(), nil), + ] + for (index, entry) in cases.enumerated() { + let url = directory.appendingPathComponent("fixture-\(index).nsec") + try entry.0.write(to: url) + let completed = expectation(description: "file result \(index)") + messenger.invokeImport { value in + if let expected = entry.1 { XCTAssertEqual(value as? String, expected) } + else { + let error = value as? FlutterError + XCTAssertEqual(error?.code, "import_failed") + XCTAssertNil(error?.message) + XCTAssertNil(error?.details) + } + completed.fulfill() + } + let picker = try await picker(in: parent) + await settle(picker) + // A synthetic delegate URL, not a claim that Files/iCloud selected it. + plugin.documentPicker(picker, didPickDocumentsAt: [url]) + await fulfillment(of: [completed], timeout: 5) + XCTAssertEqual(try Data(contentsOf: url), entry.0, "Original document must remain untouched") + await dismiss(parent) + } + } +} + +private final class IdentityFileTestMessenger: NSObject, FlutterBinaryMessenger { + private var handler: FlutterBinaryMessageHandler? + func send(onChannel channel: String, message: Data?) {} + func send(onChannel channel: String, message: Data?, binaryReply callback: FlutterBinaryReply?) { callback?(nil) } + func setMessageHandlerOnChannel(_ channel: String, binaryMessageHandler handler: FlutterBinaryMessageHandler?) -> FlutterBinaryMessengerConnection { + self.handler = handler + return 1 + } + func cleanUpConnection(_ connection: FlutterBinaryMessengerConnection) { handler = nil } + func invokeImport(reply: @escaping (Any?) -> Void) { + invoke("import", arguments: nil, reply: reply) + } + func invoke(_ method: String, arguments: Any?, reply: @escaping (Any?) -> Void) { + let codec = FlutterStandardMethodCodec.sharedInstance() + handler?(codec.encode(FlutterMethodCall(methodName: method, arguments: arguments))) { data in + reply(data.flatMap { codec.decodeEnvelope($0) }) + } + } +} diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 75d6dfd9d4..d45f5c5aff 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'hosted_app_gate.dart'; +import 'onboarding_app_entry.dart'; import 'shared/widgets/ios_navigation_metrics.dart'; import 'shared/community/paired_community_landing.dart'; import 'shared/community/community_loading_surface.dart'; @@ -28,7 +30,6 @@ import 'features/pairing/pairing_page.dart'; import 'features/pairing/pairing_provider.dart'; import 'features/channels/agent_activity/observer_subscription.dart'; import 'features/channels/channel_detail_page.dart'; -import 'features/channels/deep_link_dispatcher.dart'; import 'shared/widgets/app_route_observer.dart'; import 'features/profile/user_status_cache_provider.dart'; import 'features/profile/settings_profile_header.dart'; @@ -414,7 +415,8 @@ class App extends HookConsumerWidget { loading: () => const _SplashScreen(), error: (_, _) => const PairingPage(), data: (state) => switch (state.status) { - AuthStatus.authenticated => DeepLinkDispatcher( + AuthStatus.authenticated => HostedAppGate( + authenticated: true, child: HomePage( mePageBuilder: (_, action) => MePage(newChatAction: action), settingsPageBuilder: _buildSettingsPage, @@ -424,9 +426,9 @@ class App extends HookConsumerWidget { hasUnreadSolo: hasUnreadSolo, ), ), - _ => const DeepLinkDispatcher( - dispatchMessageLinks: false, - child: PairingPage(), + _ => const HostedAppGate( + authenticated: false, + child: OnboardingAppEntry(), ), }, ), diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index a3642c8e20..08c5376c63 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -44,7 +44,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'; @@ -350,7 +350,7 @@ class ChannelsPage extends HookConsumerWidget { opaque: false, transitionDuration: Duration.zero, reverseTransitionDuration: Duration.zero, - pageBuilder: (_, _, _) => _CommunitySwitcherPage( + pageBuilder: (_, _, _) => CommunitySwitcherPage( arrivingCommunity: arrivingCommunity, destination: measureCommunityAvatar(), prepareLanding: prepareCommunityLanding, @@ -381,7 +381,7 @@ class ChannelsPage extends HookConsumerWidget { late final _CommunitySwitcherRoute route; route = _CommunitySwitcherRoute( onTransitionProgress: onSettingsTransitionProgress, - builder: (_) => _CommunitySwitcherPage( + builder: (_) => CommunitySwitcherPage( destination: destination, prepareLanding: prepareCommunityLanding, onFlightChanged: (flying) { @@ -396,6 +396,25 @@ class ChannelsPage extends HookConsumerWidget { }); } + final showImportedCommunities = ref.watch(communityGridLandingProvider); + useEffect(() { + if (!showImportedCommunities) return null; + var cancelled = false; + Future revealImportedCommunities() async { + // Let the hosted sheet and pushed Add route finish dismissing first. + do { + await WidgetsBinding.instance.endOfFrame; + if (cancelled || !context.mounted) return; + } while (ModalRoute.of(context)?.isCurrent == false); + if (!ref.read(communityGridLandingProvider)) return; + ref.read(communityGridLandingProvider.notifier).clear(); + openCommunityGrid(); + } + + unawaited(revealImportedCommunities()); + return () => cancelled = true; + }, [showImportedCommunities]); + void openCommunitySwitcher() { // Freeze the menu shape for this presentation. If a cold permission // lookup is still pending, the next opening uses its resolved result. diff --git a/mobile/lib/features/channels/channels_page/community_switcher.dart b/mobile/lib/features/channels/channels_page/community_switcher.dart index bd295d1480..8a258e7521 100644 --- a/mobile/lib/features/channels/channels_page/community_switcher.dart +++ b/mobile/lib/features/channels/channels_page/community_switcher.dart @@ -62,8 +62,10 @@ class _CommunityFlight { final String? previousCommunityId; } -class _CommunitySwitcherPage extends HookConsumerWidget { - const _CommunitySwitcherPage({ +/// Shared community management surface for app entry and switching. +class CommunitySwitcherPage extends HookConsumerWidget { + const CommunitySwitcherPage({ + super.key, required this.destination, this.arrivingCommunity, required this.prepareLanding, @@ -247,7 +249,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/channels/deep_link_dispatcher.dart b/mobile/lib/features/channels/deep_link_dispatcher.dart index 9d3c0fdc8a..177163ebbd 100644 --- a/mobile/lib/features/channels/deep_link_dispatcher.dart +++ b/mobile/lib/features/channels/deep_link_dispatcher.dart @@ -26,11 +26,16 @@ class DeepLinkDispatcher extends ConsumerStatefulWidget { final DeepLinkDestinationBuilder? destinationBuilder; final bool dispatchMessageLinks; + /// App composition handles account onboarding without feature imports here. + /// Return true only after the destination has retained the link durably. + final Future Function(BuildContext, BuzzDeepLink)? onboardingHandler; + const DeepLinkDispatcher({ super.key, required this.child, this.destinationBuilder, this.dispatchMessageLinks = true, + this.onboardingHandler, }); @override @@ -39,6 +44,7 @@ class DeepLinkDispatcher extends ConsumerStatefulWidget { class _DeepLinkDispatcherState extends ConsumerState { bool _preparingInvite = false; + bool _handlingOnboarding = false; @override void initState() { @@ -65,7 +71,14 @@ class _DeepLinkDispatcherState extends ConsumerState { } void _maybeDispatch(BuzzDeepLink? link) { - if (link == null || _preparingInvite) return; + if (link == null || _preparingInvite || _handlingOnboarding) return; + if (widget.onboardingHandler != null && + (link is InviteDeepLink || + link is NostrBindDeepLink || + link is AddCommunityDeepLink)) { + unawaited(_dispatchOnboarding(link)); + return; + } if (link is InviteDeepLink) { _maybeDispatchInvite(link); return; @@ -82,6 +95,46 @@ class _DeepLinkDispatcherState extends ConsumerState { _dispatchNavigableLink(link); } + Future _dispatchOnboarding(BuzzDeepLink link) async { + _handlingOnboarding = true; + var consumed = false; + try { + final handled = await widget.onboardingHandler!(context, link); + if (!mounted) return; + if (handled) { + if (ref.read(pendingDeepLinkProvider) == link) { + ref.read(pendingDeepLinkProvider.notifier).consume(); + consumed = true; + } + } else if (link is InviteDeepLink) { + _maybeDispatchInvite(link); + } else if (ref.read(pendingDeepLinkProvider) == link) { + // No fallback owns this link. It must not block later entry attempts. + ref.read(pendingDeepLinkProvider.notifier).consume(); + consumed = true; + } + } catch (_) { + if (mounted) { + if (ref.read(pendingDeepLinkProvider) == link) { + ref.read(pendingDeepLinkProvider.notifier).consume(); + consumed = true; + } + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + const SnackBar( + content: Text( + 'Could not open setup. Re-open the link to try again.', + ), + ), + ); + } + } finally { + _handlingOnboarding = false; + if (mounted && (consumed || ref.read(pendingDeepLinkProvider) != link)) { + _maybeDispatch(ref.read(pendingDeepLinkProvider)); + } + } + } + Future _dispatchNotificationLink(MessageDeepLink link) async { final preparation = await ref .read(pendingDeepLinkProvider.notifier) diff --git a/mobile/lib/features/invites/invite_create_provider.dart b/mobile/lib/features/invites/invite_create_provider.dart index 88c06f847b..1e0ba6aa2f 100644 --- a/mobile/lib/features/invites/invite_create_provider.dart +++ b/mobile/lib/features/invites/invite_create_provider.dart @@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:share_plus/share_plus.dart'; +import '../../shared/hosted/hosted_relay_auth.dart'; import '../../shared/community/community_membership_provider.dart'; import '../../shared/utils/string_utils.dart'; import '../../shared/relay/relay.dart'; @@ -277,7 +278,7 @@ class RelayCommunityInviteActions implements CommunityInviteActions { /// Supplies the HTTP client used to mint community invite links. final communityInviteHttpClientProvider = Provider((ref) { - final client = http.Client(); + final client = ref.watch(hostedHttpClientFactoryProvider)(); ref.onDispose(client.close); return client; }); diff --git a/mobile/lib/features/invites/invite_join_provider.dart b/mobile/lib/features/invites/invite_join_provider.dart index a665dded1b..b7a6a7731d 100644 --- a/mobile/lib/features/invites/invite_join_provider.dart +++ b/mobile/lib/features/invites/invite_join_provider.dart @@ -4,6 +4,8 @@ import 'package:http/http.dart' as http; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; +import '../../shared/hosted/hosted_relay_auth.dart'; +import '../../shared/hosted/community_entry_requirements.dart'; import '../../shared/auth/auth.dart'; import '../../shared/deeplink/deep_link.dart'; import '../../shared/relay/relay_provider.dart'; @@ -11,7 +13,7 @@ import '../../shared/relay/relay_session.dart'; import '../../shared/relay/relay_validation.dart'; final inviteJoinHttpClientProvider = Provider((ref) { - final client = http.Client(); + final client = ref.watch(hostedHttpClientFactoryProvider)(); ref.onDispose(client.close); return client; }); @@ -171,6 +173,7 @@ class InviteJoinNotifier extends Notifier { requiresFreshInvite: false, ); try { + CommunityEntryRequirements.requireSelfHosted(invite.relayUrl); final communities = await ref.read(communityListProvider.future); final existing = _existingCommunity(communities, invite.relayUrl); if (existing != null) { diff --git a/mobile/lib/features/onboarding/hosted_community_selection.dart b/mobile/lib/features/onboarding/hosted_community_selection.dart new file mode 100644 index 0000000000..6917d925cd --- /dev/null +++ b/mobile/lib/features/onboarding/hosted_community_selection.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/hosted/hosted_flow.dart'; +import '../../shared/theme/grid.dart'; + +/// Public community choices only; identity material remains in secure storage. +class HostedCommunitySelection extends HookConsumerWidget { + const HostedCommunitySelection({super.key, required this.flow}); + final HostedFlow flow; + + @override + Widget build(BuildContext context, WidgetRef ref) { + useListenable(flow); + return Material( + type: MaterialType.transparency, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final community in flow.communities) + CheckboxListTile( + key: ValueKey(community.relayUrl), + title: Text(community.name), + subtitle: Text(Uri.parse(community.relayUrl).host), + value: flow.selectedRelays.contains(community.relayUrl), + onChanged: (value) => + flow.selectCommunity(community.relayUrl, value ?? false), + ), + const SizedBox(height: Grid.sm), + FilledButton( + onPressed: flow.selectedRelays.isEmpty + ? null + : flow.addSelectedCommunities, + child: const Text('Continue'), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/onboarding/hosted_connect_page.dart b/mobile/lib/features/onboarding/hosted_connect_page.dart new file mode 100644 index 0000000000..d0494e3eb8 --- /dev/null +++ b/mobile/lib/features/onboarding/hosted_connect_page.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/hosted/hosted_flow.dart'; +import '../../shared/widgets/modal_presentation.dart'; +import 'hosted_connect_sheet.dart'; + +/// Presents setup with the same modal chrome as the app's other sheets. +class HostedConnectPage extends HookConsumerWidget { + const HostedConnectPage({ + super.key, + required this.flow, + required this.onClose, + }); + final HostedFlow flow; + final VoidCallback onClose; + + @override + Widget build(BuildContext context, WidgetRef ref) { + useEffect(() { + var active = true; + ModalRoute? sheetRoute; + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!active || !context.mounted) return; + while (active && + context.mounted && + flow.stage != HostedFlowStage.complete && + flow.stage != HostedFlowStage.idle) { + if (!context.mounted) return; + await showBuzzModalBottomSheet( + context: context, + title: 'Connect to Builderlab', + useRootNavigator: true, + isScrollControlled: true, + useSafeArea: true, + builder: (sheetContext) { + sheetRoute = ModalRoute.of(sheetContext); + return HostedConnectSheet(flow: flow); + }, + ); + if (!active || !context.mounted) return; + if (flow.stage != HostedFlowStage.profile) { + onClose(); + return; + } + // Dismissing an optional profile is a skip. Re-present the continuing + // batch so a later error or choice never ends up behind a closed sheet. + flow.finishProfile(); + await WidgetsBinding.instance.endOfFrame; + } + }); + return () { + active = false; + final route = sheetRoute; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (route != null && route.isActive) { + route.navigator?.removeRoute(route); + } + }); + }; + }, [flow]); + return const SizedBox.shrink(); + } +} diff --git a/mobile/lib/features/onboarding/hosted_connect_sheet.dart b/mobile/lib/features/onboarding/hosted_connect_sheet.dart new file mode 100644 index 0000000000..9e0f1bb965 --- /dev/null +++ b/mobile/lib/features/onboarding/hosted_connect_sheet.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/hosted/hosted_flow.dart'; +import '../../shared/hosted/hosted_flow_problem_message.dart'; +import '../../shared/utils/string_utils.dart'; +import '../../shared/theme/grid.dart'; +import '../../shared/theme/theme_extensions.dart'; +import 'hosted_community_selection.dart'; +import 'hosted_join_policy_view.dart'; +import 'hosted_profile_setup.dart'; + +/// Presents the app-scoped real flow without retaining credentials or keys. +class HostedConnectSheet extends HookConsumerWidget { + const HostedConnectSheet({super.key, required this.flow}); + final HostedFlow flow; + + @override + Widget build(BuildContext context, WidgetRef ref) { + useListenable(flow); + final working = flow.stage == HostedFlowStage.working; + final message = switch (flow.stage) { + HostedFlowStage.idle => 'Continue connecting your account.', + HostedFlowStage.working => + flow.addingCommunityName == null + ? 'Securing your account and finding communities…' + : 'Adding ${flow.addingCommunityName}…', + HostedFlowStage.chooseCommunities => 'Choose your communities', + HostedFlowStage.communityConflict => + '${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.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.profile => + 'What should people in this community call you? You can add a photo in Settings.', + HostedFlowStage.acceptPolicy => + 'Review this community’s terms and privacy policy before joining.', + HostedFlowStage.signIn => 'Complete sign-in in your browser.', + HostedFlowStage.browserReturned => + 'Continue setup in your browser. Open your community in Buzz when it is ready.', + HostedFlowStage.complete => 'Your community is ready.', + HostedFlowStage.blocked => hostedFlowProblemMessage(flow.problem), + }; + return Padding( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + Grid.sm, + Grid.gutter, + Grid.lg, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(message, style: context.textTheme.bodyLarge), + const SizedBox(height: Grid.sm), + for (final choice in flow.identityChoices) + Padding( + padding: const EdgeInsets.only(bottom: Grid.sm), + child: OutlinedButton( + onPressed: () => flow.chooseIdentity(choice.pubkey), + child: Padding( + padding: const EdgeInsets.all(Grid.sm), + child: Column( + children: [ + Text(choice.label), + Text(fullNpub(choice.pubkey) ?? choice.pubkey), + ], + ), + ), + ), + ), + if (flow.stage == HostedFlowStage.profile && + flow.saveProfile != null) + HostedProfileSetup( + save: flow.saveProfile!, + skip: flow.finishProfile, + ), + if (flow.stage == HostedFlowStage.acceptPolicy && + flow.joinPolicy != null) + HostedJoinPolicyView( + key: ValueKey(flow.joinPolicy!.version), + policy: flow.joinPolicy!, + onAccept: flow.acceptJoinPolicy, + ), + if (flow.stage == HostedFlowStage.chooseCommunities) + HostedCommunitySelection(flow: flow), + if (flow.stage == HostedFlowStage.communityConflict) + FilledButton( + onPressed: flow.skipConflictingCommunity, + child: const Text('Keep saved identity and skip'), + ), + if (flow.canChangeCommunities) + OutlinedButton( + onPressed: flow.backToCommunityChoices, + child: const Text('Back to choices'), + ), + if ((flow.stage == HostedFlowStage.blocked || + flow.stage == HostedFlowStage.communityConflict) && + flow.addedCommunityCount > 0) + Text( + '${flow.addedCommunityCount} ' + '${flow.addedCommunityCount == 1 ? "community" : "communities"} added and kept.' + '${flow.canRetry && flow.stage == HostedFlowStage.blocked ? " Retry continues the remaining additions." : ""}', + ), + if (working) const Center(child: CircularProgressIndicator()), + if (!working && + flow.stage != HostedFlowStage.profile && + flow.stage != HostedFlowStage.acceptPolicy && + flow.stage != HostedFlowStage.chooseIdentity && + flow.stage != HostedFlowStage.chooseCommunities && + flow.stage != HostedFlowStage.communityConflict && + flow.stage != HostedFlowStage.complete && + flow.stage != HostedFlowStage.browserReturned && + flow.canRetry) + FilledButton( + onPressed: flow.retry, + child: Text( + flow.stage == HostedFlowStage.signIn + ? 'Start login again' + : 'Try again', + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/onboarding/hosted_join_policy_view.dart b/mobile/lib/features/onboarding/hosted_join_policy_view.dart new file mode 100644 index 0000000000..8900e49ae0 --- /dev/null +++ b/mobile/lib/features/onboarding/hosted_join_policy_view.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import '../../shared/hosted/hosted_join_policy.dart'; +import '../../shared/theme/grid.dart'; + +/// Consent is explicit and scoped to the policy version shown on this screen. +class HostedJoinPolicyView extends HookWidget { + const HostedJoinPolicyView({ + super.key, + required this.policy, + required this.onAccept, + }); + final HostedJoinPolicy policy; + final VoidCallback onAccept; + + @override + Widget build(BuildContext context) { + final ageConfirmed = useState(false); + return Material( + type: MaterialType.transparency, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (policy.terms != null) + ExpansionTile( + title: const Text('Terms of service'), + children: [ + Padding( + padding: const EdgeInsets.all(Grid.sm), + child: SelectableText(policy.terms!), + ), + ], + ), + if (policy.privacy != null) + ExpansionTile( + title: const Text('Privacy policy'), + children: [ + Padding( + padding: const EdgeInsets.all(Grid.sm), + child: SelectableText(policy.privacy!), + ), + ], + ), + if (policy.ageRequired) + CheckboxListTile( + value: ageConfirmed.value, + onChanged: (value) => ageConfirmed.value = value ?? false, + title: const Text('I am 18 or older'), + ), + const SizedBox(height: Grid.sm), + FilledButton( + onPressed: !policy.ageRequired || ageConfirmed.value + ? onAccept + : null, + child: const Text('Accept and join'), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/onboarding/hosted_profile_setup.dart b/mobile/lib/features/onboarding/hosted_profile_setup.dart new file mode 100644 index 0000000000..307a7e281d --- /dev/null +++ b/mobile/lib/features/onboarding/hosted_profile_setup.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../shared/theme/grid.dart'; + +/// Reuses the account flow's scoped profile writer; skipping publishes nothing. +class HostedProfileSetup extends HookConsumerWidget { + const HostedProfileSetup({super.key, required this.save, required this.skip}); + final Future Function(String) save; + final VoidCallback skip; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final name = useTextEditingController(); + useListenable(name); + final busy = useState(false); + final error = useState(false); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: name, + maxLength: 100, + enabled: !busy.value, + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration(labelText: 'Display name'), + ), + if (error.value) + const Text('We could not save your name. Try again or skip for now.'), + const SizedBox(height: Grid.sm), + FilledButton( + onPressed: busy.value || name.text.trim().isEmpty + ? null + : () async { + if (name.text.trim().isEmpty) return; + busy.value = true; + error.value = false; + try { + await save(name.text.trim()); + } catch (_) { + if (context.mounted) error.value = true; + } finally { + if (context.mounted) busy.value = false; + } + }, + child: Text(busy.value ? 'Saving…' : 'Continue'), + ), + TextButton( + onPressed: busy.value ? null : skip, + child: const Text('Skip for now'), + ), + ], + ); + } +} diff --git a/mobile/lib/features/onboarding/hosted_reconnect_banner.dart b/mobile/lib/features/onboarding/hosted_reconnect_banner.dart new file mode 100644 index 0000000000..9ce55baf70 --- /dev/null +++ b/mobile/lib/features/onboarding/hosted_reconnect_banner.dart @@ -0,0 +1,79 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/hosted/builderlab_account.dart'; +import '../../shared/hosted/hosted_flow.dart'; +import '../../shared/hosted/hosted_relay_auth.dart'; +import '../../shared/relay/relay_provider.dart'; +import '../../shared/relay/relay_session.dart'; +import '../../shared/theme/grid.dart'; +import '../../shared/theme/app_theme.dart'; + +/// Leaves cached content and community switching available while disconnected. +class HostedReconnectBanner extends HookConsumerWidget { + const HostedReconnectBanner({super.key, required this.flow}); + final HostedFlow flow; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final auth = ref.watch(hostedRelayAuthProvider); + useListenable(auth); + final config = ref.watch(relayConfigProvider); + if (auth.failedRelay != config.baseUrl || + auth.failedPubkey != pubkeyFromNsec(config.nsec) || + auth.failure == null) { + return const SizedBox.shrink(); + } + final signIn = auth.failure == AccountFailure.signedOut; + final changeAccount = auth.failure == AccountFailure.denied; + return Positioned( + left: Grid.sm, + right: Grid.sm, + bottom: 0, + child: SafeArea( + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(Radii.lg), + child: Padding( + padding: const EdgeInsets.all(Grid.sm), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + signIn + ? 'Sign in to Builderlab to reconnect' + : auth.failure == AccountFailure.denied + ? 'This account cannot reconnect to this community with its saved key.' + : 'Could not reconnect to Builderlab.', + ), + if (changeAccount) + TextButton( + onPressed: () => unawaited( + ref.read(relaySessionProvider.notifier).reconnect(), + ), + child: const Text('Retry'), + ), + TextButton( + onPressed: () => unawaited( + signIn || changeAccount + ? flow.reconnectAccount(changeAccount: changeAccount) + : ref.read(relaySessionProvider.notifier).reconnect(), + ), + child: Text( + changeAccount + ? 'Use a different account' + : signIn + ? 'Sign in' + : 'Retry', + ), + ), + ], + ), + ), + ), + ), + ); + } +} 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..8e92841a49 --- /dev/null +++ b/mobile/lib/features/onboarding/self_hosted_entry_sheet.dart @@ -0,0 +1,166 @@ +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; + final storage = ref.read(communityStorageProvider); + 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); + // Admission can return early after cancellation. Check the durable + // record before retiring the only retry copy of a generated key. + final saved = await storage.loadAll(); + if (saved.any( + (item) => item.id == community.id && item.nsec == community.nsec, + )) { + await service.complete(community); + } + 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?.choices ?? 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.', + ), + const SizedBox(height: Grid.sm), + if (choices.hasError || choices.data?.complete == false) + const Text( + 'Some saved identities could not be read. You can use an available identity or import a key.', + ), + 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) ...[ + 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 && choices.data?.complete == true) + FilledButton( + onPressed: busy.value ? null : () => select(create: true), + child: const Text('Create an identity and join'), + ), + if (saved.isEmpty && choices.data?.complete == true) + 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 88e7157ec9..acf0e1d791 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -1,4 +1,9 @@ import 'dart:async'; +import '../../shared/hosted/hosted_flow_provider.dart'; +import '../../shared/hosted/hosted_flow.dart'; +import '../../shared/hosted/hosted_community_creation.dart'; +import '../../shared/hosted/hosted_environment.dart'; +import '../../shared/hosted/hosted_flow_problem_message.dart'; import 'dart:math' show sqrt2; import 'dart:ui' show ImageFilter; @@ -12,6 +17,9 @@ import 'package:buzz/shared/theme/buzz_icons.dart'; import '../../shared/security/sensitive_action_authorizer.dart'; import '../../shared/error_haptic.dart'; +import '../../shared/deeplink/deep_link.dart'; +import '../../shared/deeplink/community_link_input.dart'; +import '../../shared/deeplink/pending_deep_link_provider.dart'; import '../../shared/community/community.dart'; import '../../shared/community/community_loading_surface.dart'; import '../../shared/theme/theme.dart'; @@ -25,6 +33,7 @@ part 'pairing_page/onboarding_background.dart'; part 'pairing_page/onboarding_colors.dart'; part 'pairing_page/onboarding_glass_button.dart'; part 'pairing_page/pairing_welcome_view.dart'; +part 'pairing_page/community_link_form.dart'; part 'pairing_page/sas_verification_view.dart'; part 'pairing_page/pairing_code_entry.dart'; part 'pairing_page/pairing_error_shake.dart'; @@ -34,11 +43,37 @@ class PairingPage extends HookConsumerWidget { /// (user is already authenticated with at least one community). final bool addingCommunity; final bool identityRecoveryOnly; + final VoidCallback? onAlreadyUseBuzz; + final VoidCallback? onWelcomeBack; + final bool connectionLanding; + final bool existingUser; + final bool enteringCommunityLink; + final bool creatingCommunity; + final ValueChanged? onSubmitCommunity; + final VoidCallback? onCommunityLink; + final VoidCallback? onCreateCommunity; + final VoidCallback? onBuilderlab; + final String? accountStatus; + final Widget? setupContent; + final String builderlabLabel; const PairingPage({ super.key, this.addingCommunity = false, this.identityRecoveryOnly = false, + this.onAlreadyUseBuzz, + this.onWelcomeBack, + this.connectionLanding = false, + this.existingUser = false, + this.enteringCommunityLink = false, + this.creatingCommunity = false, + this.onSubmitCommunity, + this.onCommunityLink, + this.onCreateCommunity, + this.onBuilderlab, + this.accountStatus, + this.setupContent, + this.builderlabLabel = 'Connect to Builderlab', }); @override @@ -71,6 +106,14 @@ class PairingPage extends HookConsumerWidget { Future handleScannerResult(String? code) async { if (code != null && context.mounted) { + if (connectionLanding && !existingUser) { + final uri = Uri.tryParse(code.trim()); + final link = uri == null ? null : parseBuzzDeepLink(uri); + if (link is InviteDeepLink || link is AddCommunityDeepLink) { + ref.read(pendingDeepLinkProvider.notifier).open(uri!); + return; + } + } if (identityRecoveryOnly && Uri.tryParse(code)?.queryParameters['mode'] != 'recover') { ScaffoldMessenger.of(context).showSnackBar( @@ -222,6 +265,23 @@ class PairingPage extends HookConsumerWidget { appBar: pairingAppBar, body: SafeArea( child: _PairingWelcomeView( + onAlreadyUseBuzz: onAlreadyUseBuzz, + onWelcomeBack: onWelcomeBack, + connectionLanding: connectionLanding, + existingUser: existingUser, + enteringCommunityLink: enteringCommunityLink, + creatingCommunity: creatingCommunity, + onSubmitCommunity: onSubmitCommunity, + onCommunityLink: onCommunityLink, + onCreateCommunity: onCreateCommunity, + onOpenCommunity: (uri) { + FocusManager.instance.primaryFocus?.unfocus(); + ref.read(pendingDeepLinkProvider.notifier).open(uri); + }, + onBuilderlab: onBuilderlab, + accountStatus: accountStatus, + setupContent: setupContent, + builderlabLabel: builderlabLabel, codeController: codeController, isBusy: isBusy, pairingCodeExpanded: pairingCodeExpanded.value, diff --git a/mobile/lib/features/pairing/pairing_page/community_link_form.dart b/mobile/lib/features/pairing/pairing_page/community_link_form.dart new file mode 100644 index 0000000000..39f0fd5171 --- /dev/null +++ b/mobile/lib/features/pairing/pairing_page/community_link_form.dart @@ -0,0 +1,125 @@ +part of '../pairing_page.dart'; + +class _CommunityLinkForm extends HookConsumerWidget { + const _CommunityLinkForm({ + required this.onOpen, + required this.onBack, + this.onCreate, + }); + + final ValueChanged? onCreate; + + final ValueChanged onOpen; + final VoidCallback? onBack; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final controller = useTextEditingController(); + final error = useState(null); + + final flow = HostedEnvironment.enabled + ? ref.watch(hostedFlowProvider) + : null; + useListenable(flow); + final ownsFlow = ref.watch(hostedInlineEntryProvider); + final busy = + flow != null && + ownsFlow && + !flow.accountOnly && + (flow.stage == HostedFlowStage.working || + flow.stage == HostedFlowStage.signIn); + final failed = + flow != null && ownsFlow && !flow.accountOnly && flow.problem != null; + + void submit() { + if (busy || (failed && !flow.canRetry)) return; + final create = onCreate; + if (create != null) { + final name = controller.text.trim().toLowerCase(); + if (!HostedCreateCommunity.validName(name)) { + error.value = + 'Use letters, numbers and single hyphens (up to 63 characters).'; + return; + } + error.value = null; + FocusManager.instance.primaryFocus?.unfocus(); + create(name); + return; + } + final link = parseCommunityLinkInput(controller.text); + if (link == null) { + error.value = 'Enter a valid community address or invite link.'; + return; + } + error.value = null; + ref.read(hostedInlineEntryProvider.notifier).setActive(true); + onOpen(link); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + key: Key( + onCreate != null + ? 'community-create-input' + : 'community-link-input', + ), + controller: controller, + enabled: !busy, + keyboardType: TextInputType.url, + textInputAction: TextInputAction.go, + autocorrect: false, + enableSuggestions: false, + style: context.textTheme.bodyMedium?.copyWith( + color: context._onboardingInk, + ), + cursorColor: context._onboardingInk, + decoration: InputDecoration( + labelText: onCreate != null + ? 'Community address' + : 'Community link or address', + helperText: onCreate != null + ? '.${HostedEnvironment.communitySuffix}' + : null, + helperStyle: TextStyle(color: context._onboardingMutedInk), + labelStyle: TextStyle(color: context._onboardingInk), + hintText: onCreate != null + ? 'my-community' + : 'community.example.com', + hintStyle: TextStyle(color: context._onboardingMutedInk), + errorText: + error.value ?? + (failed ? hostedFlowProblemMessage(flow.problem) : null), + filled: true, + fillColor: context._onboardingInputSurface, + enabledBorder: context._inputBorder, + focusedBorder: context._inputBorder.copyWith( + borderSide: BorderSide(color: context._onboardingInk), + ), + ), + onChanged: (_) { + error.value = null; + if (failed) unawaited(flow.leave()); + }, + onSubmitted: (_) => submit(), + ), + const SizedBox(height: Grid.xs), + FilledButton( + style: context._onboardingGlassButtonStyle, + onPressed: busy || (failed && !flow.canRetry) ? null : submit, + child: Text(busy ? 'Connecting…' : 'Continue'), + ), + const SizedBox(height: Grid.xxs), + TextButton( + style: context._onboardingGhostButtonStyle, + onPressed: () { + unawaited(flow?.leave()); + 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 0485049ef1..9369855aa5 100644 --- a/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart +++ b/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart @@ -8,6 +8,20 @@ class _PairingWelcomeView extends StatelessWidget { final VoidCallback onScan; final VoidCallback onTogglePairingCode; final VoidCallback onConnect; + final VoidCallback? onAlreadyUseBuzz; + final VoidCallback? onWelcomeBack; + final bool connectionLanding; + final bool existingUser; + final bool enteringCommunityLink; + final bool creatingCommunity; + final ValueChanged? onSubmitCommunity; + final VoidCallback? onCommunityLink; + final VoidCallback? onCreateCommunity; + final VoidCallback? onBuilderlab; + final String? accountStatus; + final Widget? setupContent; + final String builderlabLabel; + final ValueChanged onOpenCommunity; const _PairingWelcomeView({ required this.codeController, @@ -17,6 +31,20 @@ class _PairingWelcomeView extends StatelessWidget { required this.onScan, required this.onTogglePairingCode, required this.onConnect, + required this.onOpenCommunity, + this.onAlreadyUseBuzz, + this.onWelcomeBack, + this.connectionLanding = false, + this.existingUser = false, + this.enteringCommunityLink = false, + this.creatingCommunity = false, + this.onSubmitCommunity, + this.onCommunityLink, + this.onCreateCommunity, + this.onBuilderlab, + this.accountStatus, + this.setupContent, + this.builderlabLabel = 'Connect to Builderlab', }); @override @@ -26,6 +54,221 @@ class _PairingWelcomeView extends StatelessWidget { ? Duration.zero : const Duration(milliseconds: 220); + final selectedPage = creatingCommunity + ? 4 + : enteringCommunityLink + ? 3 + : existingUser + ? 2 + : 1; + Widget actions(int page) => page == 3 || page == 4 + ? _CommunityLinkForm( + onOpen: onOpenCommunity, + onBack: onWelcomeBack, + onCreate: page == 4 ? onSubmitCommunity : null, + ) + : Column( + children: [ + if (page == 1 && accountStatus != null) ...[ + Text( + accountStatus!, + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: context._onboardingMutedInk, + ), + ), + const SizedBox(height: Grid.xs), + ], + if (page == 2) ...[ + Text( + connectionLanding + ? 'Paste the pairing code from your desktop app.' + : 'Scan the QR code from your desktop app\nor paste a pairing code to connect.', + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: context._onboardingMutedInk, + ), + ), + const SizedBox(height: Grid.xs), + ], + if (page == 1) ...[ + if (HostedEnvironment.enabled) ...[ + FilledButton( + style: context._onboardingGlassButtonStyle, + onPressed: onBuilderlab, + child: Text(builderlabLabel), + ), + const SizedBox(height: Grid.xxs), + ], + FilledButton( + style: context._onboardingGlassButtonStyle, + onPressed: onCommunityLink, + child: const Text('Enter a community link'), + ), + if (HostedEnvironment.enabled && onCreateCommunity != null) ...[ + const SizedBox(height: Grid.xxs), + FilledButton( + style: context._onboardingGlassButtonStyle, + onPressed: isBusy ? null : onCreateCommunity, + child: const Text('Create a community'), + ), + ], + const SizedBox(height: Grid.xxs), + FilledButton( + style: context._onboardingGlassButtonStyle, + onPressed: isBusy ? null : onScan, + child: const Text('Scan a QR code'), + ), + const SizedBox(height: Grid.xxs), + TextButton( + style: context._onboardingGhostButtonStyle, + onPressed: isBusy ? null : onAlreadyUseBuzz, + child: const Text('Use pairing code'), + ), + ] else ...[ + if (!connectionLanding) ...[ + FilledButton( + style: context._onboardingGlassButtonStyle, + onPressed: isBusy ? null : onScan, + child: isBusy && !pairingCodeExpanded + ? SizedBox( + width: 20, + height: 20, + child: BuzzLoadingIndicator( + size: 20, + color: context._onboardingInk, + semanticLabel: 'Opening scanner', + ), + ) + : const Text('Scan a QR code'), + ), + const SizedBox(height: Grid.xxs), + TextButton( + style: context._onboardingGhostButtonStyle, + onPressed: isBusy ? null : onTogglePairingCode, + child: Text( + pairingCodeExpanded + ? 'Hide pairing code' + : 'Use pairing code', + ), + ), + ], + AnimatedSwitcher( + duration: revealDuration, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) { + return SizeTransition( + sizeFactor: animation, + alignment: Alignment.topCenter, + child: FadeTransition(opacity: animation, child: child), + ); + }, + child: (connectionLanding || pairingCodeExpanded) + ? Column( + key: const ValueKey('pairing-code-fields'), + children: [ + const SizedBox(height: Grid.twelve), + TextField( + controller: codeController, + style: context.textTheme.bodyMedium?.copyWith( + color: context._onboardingInk, + ), + cursorColor: context._onboardingInk, + decoration: InputDecoration( + filled: true, + fillColor: context._onboardingInputSurface, + hintText: 'nostrpair://... or buzz://...', + hintStyle: context.textTheme.bodyMedium + ?.copyWith( + color: context._onboardingMutedInk, + ), + prefixIcon: Icon( + BuzzIcons.link, + color: context._onboardingInk, + ), + enabledBorder: context._inputBorder, + disabledBorder: context._inputBorder, + focusedBorder: context._inputBorder.copyWith( + borderSide: BorderSide( + color: context._onboardingInk, + ), + ), + isDense: true, + ), + autocorrect: false, + enableSuggestions: false, + enabled: !isBusy, + contextMenuBuilder: (context, editableTextState) { + return AdaptiveTextSelectionToolbar.editableText( + editableTextState: editableTextState, + ); + }, + ), + const SizedBox(height: Grid.twelve), + SizedBox( + width: double.infinity, + child: FilledButton( + style: context._onboardingGlassButtonStyle, + onPressed: isBusy ? null : onConnect, + child: isBusy + ? SizedBox( + width: 20, + height: 20, + child: BuzzLoadingIndicator( + size: 20, + color: context._onboardingInk, + semanticLabel: 'Connecting', + ), + ) + : const Text('Connect'), + ), + ), + ], + ) + : const SizedBox( + key: ValueKey('pairing-code-fields-hidden'), + ), + ), + ], + if (page == 2 && onWelcomeBack != null) + TextButton( + style: context._onboardingGhostButtonStyle, + onPressed: onWelcomeBack, + child: const Text('Back'), + ), + if ((!connectionLanding || page == selectedPage) && + errorMessage != null) ...[ + const SizedBox(height: Grid.twelve), + Container( + padding: const EdgeInsets.all(Grid.twelve), + decoration: BoxDecoration( + color: context.colors.errorContainer, + borderRadius: BorderRadius.circular(Radii.md), + ), + child: Row( + children: [ + Icon( + BuzzIcons.triangleAlert, + size: 16, + color: context.colors.onErrorContainer, + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: Text( + errorMessage!, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onErrorContainer, + ), + ), + ), + ], + ), + ), + ], + ], + ); + return Padding( padding: const EdgeInsets.symmetric( horizontal: Grid.gutter, @@ -70,165 +313,48 @@ class _PairingWelcomeView extends StatelessWidget { ), ), const SizedBox(height: Grid.md), - Text( - 'Scan the QR code from your desktop app\nor paste a pairing code to connect.', - textAlign: TextAlign.center, - style: context.textTheme.bodyMedium?.copyWith( - color: context._onboardingMutedInk, - ), - ), - const SizedBox(height: Grid.xs), ConstrainedBox( constraints: const BoxConstraints(maxWidth: 440), child: SizedBox( width: double.infinity, - child: Column( - children: [ - FilledButton( - style: context._onboardingGlassButtonStyle, - onPressed: isBusy ? null : onScan, - child: isBusy && !pairingCodeExpanded - ? SizedBox( - width: 20, - height: 20, - child: BuzzLoadingIndicator( - size: 20, - color: context._onboardingInk, - semanticLabel: 'Opening scanner', - ), - ) - : const Text('Scan a QR code'), - ), - const SizedBox(height: Grid.xxs), - TextButton( - style: context._onboardingGhostButtonStyle, - onPressed: isBusy ? null : onTogglePairingCode, - child: Text( - pairingCodeExpanded - ? 'Hide pairing code' - : 'Use pairing code', - ), - ), - AnimatedSwitcher( - duration: revealDuration, - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - transitionBuilder: (child, animation) { - return SizeTransition( - sizeFactor: animation, - alignment: Alignment.topCenter, - child: FadeTransition( - opacity: animation, - child: child, - ), - ); - }, - child: pairingCodeExpanded - ? Column( - key: const ValueKey('pairing-code-fields'), - children: [ - const SizedBox(height: Grid.twelve), - TextField( - controller: codeController, - style: context.textTheme.bodyMedium - ?.copyWith( - color: context._onboardingInk, - ), - cursorColor: context._onboardingInk, - decoration: InputDecoration( - filled: true, - fillColor: - context._onboardingInputSurface, - hintText: - 'nostrpair://... or buzz://...', - hintStyle: context.textTheme.bodyMedium - ?.copyWith( - color: - context._onboardingMutedInk, - ), - prefixIcon: Icon( - BuzzIcons.link, - color: context._onboardingInk, - ), - enabledBorder: context._inputBorder, - disabledBorder: context._inputBorder, - focusedBorder: context._inputBorder - .copyWith( - borderSide: BorderSide( - color: context._onboardingInk, - ), - ), - isDense: true, - ), - autocorrect: false, - enableSuggestions: false, - enabled: !isBusy, - contextMenuBuilder: - (context, editableTextState) { - return AdaptiveTextSelectionToolbar.editableText( - editableTextState: - editableTextState, - ); - }, - ), - const SizedBox(height: Grid.twelve), - SizedBox( - width: double.infinity, - child: FilledButton( - style: - context._onboardingGlassButtonStyle, - onPressed: isBusy ? null : onConnect, - child: isBusy - ? SizedBox( - width: 20, - height: 20, - child: BuzzLoadingIndicator( - size: 20, - color: context._onboardingInk, - semanticLabel: 'Connecting', - ), - ) - : const Text('Connect'), - ), - ), - ], - ) - : const SizedBox( - key: ValueKey('pairing-code-fields-hidden'), - ), - ), - if (errorMessage != null) ...[ - const SizedBox(height: Grid.twelve), - Container( - padding: const EdgeInsets.all(Grid.twelve), - decoration: BoxDecoration( - color: context.colors.errorContainer, - borderRadius: BorderRadius.circular(Radii.md), - ), - child: Row( + child: setupContent != null + ? setupContent! + : !connectionLanding + ? actions(2) + : ClipRect( + child: Stack( + alignment: Alignment.bottomCenter, children: [ - Icon( - BuzzIcons.triangleAlert, - size: 16, - color: context.colors.onErrorContainer, - ), - const SizedBox(width: Grid.xxs), - Expanded( - child: Text( - errorMessage!, - style: context.textTheme.bodySmall - ?.copyWith( - color: - context.colors.onErrorContainer, + for (var page = 1; page < 5; page++) + ExcludeFocus( + excluding: page != selectedPage, + child: ExcludeSemantics( + excluding: page != selectedPage, + child: IgnorePointer( + ignoring: page != selectedPage, + child: AnimatedSlide( + key: ValueKey('welcome-panel-$page'), + offset: Offset( + page == selectedPage + ? 0 + : page == 1 + ? -1 + : 1, + 0, + ), + duration: revealDuration, + curve: Curves.easeInOutCubic, + child: SizedBox( + width: double.infinity, + child: actions(page), + ), ), + ), + ), ), - ), ], ), ), - ], - ], - ), ), ), ], diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart index 510158344b..c426c85494 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 '../../shared/hosted/hosted_flow_provider.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,19 +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; - } - - // Send complete only after credentials are validated. - _sendComplete(true); - - // Store as community and switch to it. final community = Community.create( name: Community.nameFromUrl(relayUrl), relayUrl: relayUrl, @@ -759,15 +748,31 @@ class PairingNotifier extends Notifier { ? SensitiveActionPolicy.enabled : SensitiveActionPolicy.disabledByUser, ); - await ref - .read(authProvider.notifier) - .authenticateWithCommunity(community); - if (pairingGeneration != _pairingGeneration || - state.status != PairingStatus.storing || - _sendIdentityToSource) { + bool current() => + pairingGeneration == _pairingGeneration && + state.status == PairingStatus.storing && + !_sendIdentityToSource; + if (HostedEnvironment.requiresAuth(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(community, shouldContinue: current); + if (!current()) return; + // A validated transfer is not successful admission until storage succeeds. + _sendComplete(true); _cleanup(); state = state.copyWith(status: PairingStatus.success); ref.read(pairedCommunityLandingProvider.notifier).request(community); @@ -864,13 +869,27 @@ class PairingNotifier extends Notifier { try { final community = _parseLegacyInput(rawInput); + if (HostedEnvironment.requiresAuth(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); if (generation != _pairingGeneration) return; await ref .read(authProvider.notifier) - .authenticateWithCommunity(community); + .authenticateWithCommunity( + community, + shouldContinue: () => generation == _pairingGeneration, + ); if (generation != _pairingGeneration) return; state = PairingState( status: PairingStatus.success, 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 new file mode 100644 index 0000000000..27833b2955 --- /dev/null +++ b/mobile/lib/hosted_app_gate.dart @@ -0,0 +1,188 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +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'; +import 'shared/hosted/hosted_community_connection.dart'; +import 'shared/hosted/hosted_flow.dart'; +import 'shared/hosted/hosted_environment.dart'; +import 'features/onboarding/hosted_reconnect_banner.dart'; + +import 'shared/hosted/hosted_flow_provider.dart'; + +/// Composition seam shared by signed-in and signed-out application states. +class HostedAppGate extends HookConsumerWidget { + const HostedAppGate({ + super.key, + required this.child, + required this.authenticated, + }); + final Widget child; + final bool authenticated; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final flow = HostedEnvironment.enabled + ? ref.watch(hostedFlowProvider) + : null; + useListenable(flow); + useEffect(() { + if (flow == null) return null; + void onFlowChanged() { + if (flow.stage == HostedFlowStage.complete) { + ref.read(hostedInlineEntryProvider.notifier).setActive(false); + } + } + + flow.addListener(onFlowChanged); + unawaited(flow.initialize()); + return () => flow.removeListener(onFlowChanged); + }, [flow]); + final inlineEntry = ref.watch(hostedInlineEntryProvider); + final visible = + flow != null && + flow.stage != HostedFlowStage.idle && + flow.stage != HostedFlowStage.complete; + // Native browser authentication presents its own alert over the current + // screen. Keep that screen in place while checking the session/opening it. + final openingSignIn = + flow != null && + (flow.stage == HostedFlowStage.signIn || + (flow.accountOnly && + flow.stage == HostedFlowStage.working && + flow.account.account == null)); + final inlineAccountStatus = + flow != null && + (!authenticated || inlineEntry) && + flow.accountOnly && + (flow.stage == HostedFlowStage.working || + (flow.stage == HostedFlowStage.blocked && + !flow.canChangeCommunities)); + return DeepLinkDispatcher( + dispatchMessageLinks: authenticated && !visible, + onboardingHandler: (routeContext, link) async { + final relay = link.relayUrl; + if (relay != null) { + final host = Uri.parse(relay).host.toLowerCase(); + final requiresAuth = HostedEnvironment.requiresAuth(host); + if (link is InviteDeepLink && !requiresAuth) return false; + final communities = await ref + .read(communityStorageProvider) + .loadAll(); + final existing = communities + .where( + (item) => relayIdentity(item.relayUrl) == relayIdentity(relay), + ) + .firstOrNull; + if (existing != null && + !(existing.starterSetupIncomplete && requiresAuth)) { + await ref + .read(communityListProvider.notifier) + .switchCommunity(existing.id); + if (context.mounted) { + ref.read(communityEntryCompletionProvider.notifier).complete(); + } + return true; + } + if (!requiresAuth && link is AddCommunityDeepLink) { + if (routeContext.mounted) { + final joined = await showSelfHostedEntry( + routeContext, + relay: link.relayUrl, + name: link.name, + ); + if (joined && context.mounted) { + ref.read(communityEntryCompletionProvider.notifier).complete(); + } + } + return true; + } + if (requiresAuth && + !HostedEnvironment.supportsRelay(Uri.parse(relay))) { + if (routeContext.mounted) { + await showDialog( + context: routeContext, + builder: (context) => AlertDialog( + title: const Text('Community setup unavailable'), + content: const Text( + 'This community needs a different or unsupported Builderlab environment. Use an app build configured for this community.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + ], + ), + ); + } + return true; + } + } + if (flow == null) return false; + await flow.start(link); + return true; + }, + child: Stack( + fit: StackFit.expand, + children: [ + child, + if (flow != null) ...[ + if (authenticated && !visible) HostedReconnectBanner(flow: flow), + if (flow.stage == HostedFlowStage.signIn && + authenticated && + !inlineEntry) + Positioned( + left: Grid.sm, + right: Grid.sm, + bottom: 0, + child: SafeArea( + child: Material( + color: Colors.transparent, + child: Wrap( + alignment: WrapAlignment.center, + children: [ + TextButton( + onPressed: flow.retry, + child: const Text('Start login again'), + ), + TextButton( + onPressed: flow.leave, + child: Text( + flow.reconnecting + ? 'Cancel sign-in' + : 'Leave setup', + ), + ), + ], + ), + ), + ), + ), + if (visible && + !openingSignIn && + !inlineAccountStatus && + (!inlineEntry || + (flow.accountOnly && + flow.stage == HostedFlowStage.profile) || + 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 new file mode 100644 index 0000000000..dbf3e3541d --- /dev/null +++ b/mobile/lib/onboarding_app_entry.dart @@ -0,0 +1,167 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +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 'shared/hosted/hosted_flow_provider.dart'; +import 'shared/hosted/hosted_flow.dart'; +import 'shared/hosted/hosted_environment.dart'; +import 'shared/hosted/hosted_community_creation.dart'; +import 'shared/hosted/hosted_flow_problem_message.dart'; + +/// Navigation over the existing branded welcome surface. +class OnboardingAppEntry extends HookConsumerWidget { + const OnboardingAppEntry({super.key, this.addingCommunity = false}); + final bool addingCommunity; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final existingUser = useState(false); + final creating = useState(false); + final enteringCommunityLink = useState(false); + final flow = HostedEnvironment.enabled + ? ref.watch(hostedFlowProvider) + : null; + useListenable(flow); + useEffect(() { + if (flow == null) return null; + Future restore() async { + await flow.initialize(); + 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 != null && + flow.stage != HostedFlowStage.complete) { + unawaited(flow.leave()); + } + }); + }; + }, [flow]); + final inlineSetup = + flow != null && + !flow.accountOnly && + flow.stage != HostedFlowStage.signIn && + !(flow.stage == HostedFlowStage.working && + flow.account.account == null) && + 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 != null && + flow.accountOnly && + (flow.stage == HostedFlowStage.working || + flow.stage == HostedFlowStage.signIn); + final connected = flow?.account.account != null; + final problem = flow != null && flow.accountOnly ? flow.problem : null; + final status = flow == null + ? null + : problem != null + ? hostedFlowProblemMessage(problem) + : busy + ? 'Connecting to Builderlab…' + : flow.noCommunitiesToAdd + ? 'No communities available to add. Scan a QR code or use an invite link.' + : connected + ? 'Connected to Builderlab.' + : null; + void back() { + unawaited(flow?.leave()); + existingUser.value = false; + creating.value = false; + enteringCommunityLink.value = false; + FocusManager.instance.primaryFocus?.unfocus(); + ref.read(pairingProvider.notifier).reset(); + } + + return PopScope( + canPop: + !existingUser.value && + !enteringCommunityLink.value && + !creating.value && + !inlineSetup, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) back(); + }, + child: PairingPage( + connectionLanding: true, + addingCommunity: addingCommunity, + existingUser: existingUser.value, + enteringCommunityLink: enteringCommunityLink.value, + creatingCommunity: creating.value, + onSubmitCommunity: flow == null + ? null + : (name) { + creating.value = false; + unawaited(flow.start(HostedCreateCommunity(name))); + }, + onCommunityLink: () => enteringCommunityLink.value = true, + onCreateCommunity: flow == null || busy + ? null + : () => creating.value = true, + onBuilderlab: flow == null + ? null + : signingIn + ? () => unawaited(flow.leave()) + : busy + ? null + : () => unawaited(flow.signInAccount()), + accountStatus: status, + setupContent: flow != null && 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' + : '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 9dc17e1e74..d82e856bcc 100644 --- a/mobile/lib/shared/auth/auth_provider.dart +++ b/mobile/lib/shared/auth/auth_provider.dart @@ -1,8 +1,10 @@ +import '../hosted/hosted_relay_auth.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; import '../community/community.dart'; import '../community/community_provider.dart'; +import '../hosted/community_entry_requirements.dart'; enum AuthStatus { unknown, unauthenticated, authenticated } @@ -56,21 +58,34 @@ class AuthNotifier extends AsyncNotifier { /// Authenticate with a community. Saves it and switches to it. /// Writes to storage directly to avoid circular dependency with community /// providers. - Future authenticateWithCommunity(Community community) { + Future authenticateWithCommunity( + Community community, { + bool Function()? shouldContinue, + bool activate = true, + }) { return ref.read(communityTransitionProvider).runExclusive(() async { - await ref.read(communityTransitionProvider).run(); + if (shouldContinue != null && !shouldContinue()) return; + await CommunityEntryRequirements.verify( + community, + ref.read(builderlabAccountProvider), + ); + if (shouldContinue != null && !shouldContinue()) return; + 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/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index d16084c3ee..535bc95d48 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -1,3 +1,4 @@ +import '../hosted/hosted_relay_auth.dart'; import 'dart:async'; import 'dart:developer' as developer; import 'dart:math'; @@ -6,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; import '../auth/auth_provider.dart'; +import '../hosted/community_entry_requirements.dart'; import '../push/dev_push_lease.dart'; import '../push/push_bridge.dart'; import '../push/push_lease_revocation_outbox.dart'; @@ -253,6 +255,10 @@ class CommunityListNotifier extends AsyncNotifier> { /// Add a community. If one with the same relay URL already exists, update /// its credentials instead. Returns the effective community ID. Future addCommunity(Community community) async { + await CommunityEntryRequirements.verify( + community, + ref.read(builderlabAccountProvider), + ); final storage = ref.read(communityStorageProvider); final current = state.value ?? []; diff --git a/mobile/lib/shared/community/community_storage.dart b/mobile/lib/shared/community/community_storage.dart index cf846c8a82..9ced276b0b 100644 --- a/mobile/lib/shared/community/community_storage.dart +++ b/mobile/lib/shared/community/community_storage.dart @@ -2,6 +2,8 @@ import 'dart:convert'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import '../storage/secure_storage.dart'; + import 'community.dart'; class CommunityStorage { @@ -19,7 +21,7 @@ class CommunityStorage { final FlutterSecureStorage _secure; CommunityStorage({FlutterSecureStorage? secure}) - : _secure = secure ?? const FlutterSecureStorage(); + : _secure = secure ?? communitySecureStorage; /// Load all communities. On first call, migrates legacy single-community /// credentials if present. diff --git a/mobile/lib/shared/community/paired_community_landing.dart b/mobile/lib/shared/community/paired_community_landing.dart index 0acd6b99a9..776f06770e 100644 --- a/mobile/lib/shared/community/paired_community_landing.dart +++ b/mobile/lib/shared/community/paired_community_landing.dart @@ -2,6 +2,18 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'community.dart'; +/// Opens the existing community grid after a hosted account import completes. +final communityGridLandingProvider = + NotifierProvider(CommunityGridLanding.new); + +class CommunityGridLanding extends Notifier { + @override + bool build() => false; + + void request() => state = true; + void clear() => state = false; +} + /// A one-shot request to reveal an imported community through its avatar. final pairedCommunityLandingProvider = NotifierProvider( diff --git a/mobile/lib/shared/deeplink/community_link_input.dart b/mobile/lib/shared/deeplink/community_link_input.dart new file mode 100644 index 0000000000..3aa6edce75 --- /dev/null +++ b/mobile/lib/shared/deeplink/community_link_input.dart @@ -0,0 +1,36 @@ +import 'deep_link.dart'; + +/// Converts user-entered community addresses or invites to validated app links. +/// Other deep-link actions are deliberately excluded from this input. +Uri? parseCommunityLinkInput(String input) { + final raw = input.trim(); + if (raw.isEmpty || raw.length > 8192 || RegExp(r'\s').hasMatch(raw)) { + return null; + } + try { + final uri = Uri.parse(raw.contains('://') ? raw : 'https://$raw'); + if (parseInviteDeepLink(uri) != null || + parseAddCommunityDeepLink(uri) != null) { + return uri; + } + if (!{'https', 'wss'}.contains(uri.scheme) || + uri.host.isEmpty || + uri.userInfo.isNotEmpty || + uri.hasQuery || + uri.hasFragment || + (uri.path.isNotEmpty && uri.path != '/')) { + return null; + } + final link = Uri( + scheme: 'buzz', + host: 'add-community', + queryParameters: { + 'relay': uri.replace(scheme: 'wss', path: '').toString(), + 'name': uri.host, + }, + ); + return parseAddCommunityDeepLink(link) == null ? null : link; + } on FormatException { + return null; + } +} diff --git a/mobile/lib/shared/deeplink/deep_link.dart b/mobile/lib/shared/deeplink/deep_link.dart index c58acefd21..14c38489c6 100644 --- a/mobile/lib/shared/deeplink/deep_link.dart +++ b/mobile/lib/shared/deeplink/deep_link.dart @@ -8,10 +8,14 @@ library; import '../relay/relay_validation.dart'; +import '../hosted/hosted_environment.dart'; /// A parsed deep link supported by the app. sealed class BuzzDeepLink { const BuzzDeepLink(); + + /// Community addressed by entry links; other actions have no relay target. + String? get relayUrl => null; } /// A parsed relay invite link. @@ -21,6 +25,7 @@ sealed class BuzzDeepLink { /// handoff from the web landing page. class InviteDeepLink extends BuzzDeepLink { /// Relay URL normalized to the websocket scheme used by the app. + @override final String relayUrl; /// Invite code from the link. @@ -292,6 +297,8 @@ InviteDeepLink? parseInviteDeepLink(Uri uri) { /// Parse any supported Buzz deep link. BuzzDeepLink? parseBuzzDeepLink(Uri uri) => + parseNostrBindDeepLink(uri) ?? + parseAddCommunityDeepLink(uri) ?? parseInviteDeepLink(uri) ?? parseChannelDeepLink(uri) ?? parseMessageDeepLink(uri); @@ -352,3 +359,104 @@ EntityDeepLink? parseEntityDeepLink(Uri uri) { eventId: eventId?.toLowerCase(), ); } + +/// Web signup's challenge. Values are untrusted until authenticated verification. +class NostrBindDeepLink extends BuzzDeepLink { + final Map fields; + NostrBindDeepLink._(Map fields) + : fields = Map.unmodifiable(fields); + String get challengeId => fields['challenge_id']!; + String get nonce => fields['nonce']!; + DateTime get expiresAt => DateTime.parse(fields['expires_at']!); + @override + String toString() => 'NostrBindDeepLink(redacted)'; +} + +class AddCommunityDeepLink extends BuzzDeepLink { + @override + final String relayUrl; + final String name; + const AddCommunityDeepLink({required this.relayUrl, required this.name}); +} + +bool _webHandoffShape(Uri uri, String host, Set allowed) => + uri.scheme == 'buzz' && + uri.host == host && + uri.path.isEmpty && + !uri.hasFragment && + !uri.hasPort && + uri.userInfo.isEmpty && + uri.toString().length <= 8192 && + uri.queryParametersAll.keys.every(allowed.contains) && + uri.queryParametersAll.values.every((values) => values.length == 1); + +NostrBindDeepLink? parseNostrBindDeepLink(Uri uri) { + const fields = { + 'challenge_id', + 'nonce', + 'verification_code', + 'audience', + 'action', + 'protocol', + 'version', + 'origin', + 'expires_at', + 'return', + 'callback_url', + }; + if (!_webHandoffShape(uri, 'nostr-bind', fields) || + !fields.every(uri.queryParameters.containsKey)) { + return null; + } + final p = uri.queryParameters; + if (!RegExp( + r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', + ).hasMatch(p['challenge_id']!) || + !RegExp(r'^[A-Za-z0-9_-]{43}$').hasMatch(p['nonce']!) || + !RegExp(r'^[0-9]{6}$').hasMatch(p['verification_code']!) || + p['audience'] != 'buzz:nostr-identity' || + p['action'] != 'bind_nostr_identity' || + p['protocol'] != 'buzz-nostr-identity' || + p['version'] != '1' || + p['origin'] != HostedEnvironment.origin.toString() || + p['return'] != 'browser_fragment_v1' || + p['callback_url'] != + HostedEnvironment.origin.resolve('/buzz').toString() || + !RegExp(r'(Z|[+-][0-9]{2}:[0-9]{2})$').hasMatch(p['expires_at']!) || + DateTime.tryParse(p['expires_at']!) == null) { + return null; + } + return NostrBindDeepLink._(p); +} + +AddCommunityDeepLink? parseAddCommunityDeepLink(Uri uri) { + if (!_webHandoffShape(uri, 'add-community', {'relay', 'name'})) return null; + final relay = Uri.tryParse(uri.queryParameters['relay'] ?? ''); + final name = uri.queryParameters['name']; + if (relay == null || + relay.scheme != 'wss' || + relay.host.isEmpty || + relay.host.endsWith('.') || + relay.userInfo.isNotEmpty || + relay.hasQuery || + relay.hasFragment || + (relay.path.isNotEmpty && relay.path != '/') || + name == null || + name.trim().isEmpty || + name.length > 200) { + return null; + } + try { + validateInviteRelayUri(relay); + } on FormatException { + return null; + } + return AddCommunityDeepLink( + relayUrl: Uri( + scheme: 'wss', + host: relay.host, + port: relay.hasPort ? relay.port : null, + ).toString(), + name: name, + ); +} diff --git a/mobile/lib/shared/deeplink/pending_deep_link_provider.dart b/mobile/lib/shared/deeplink/pending_deep_link_provider.dart index 70f1a6a3b6..dec263753c 100644 --- a/mobile/lib/shared/deeplink/pending_deep_link_provider.dart +++ b/mobile/lib/shared/deeplink/pending_deep_link_provider.dart @@ -53,7 +53,8 @@ class PendingDeepLinkNotifier extends Notifier { void open(Uri uri) { final link = parseBuzzDeepLink(uri); if (link == null) { - debugPrint('deep-link: ignoring unsupported link: $uri'); + // Unknown links may contain OAuth codes or invite credentials. + debugPrint('deep-link: ignoring unsupported link'); return; } _enqueue(link); diff --git a/mobile/lib/shared/hosted/account_identity_store.dart b/mobile/lib/shared/hosted/account_identity_store.dart new file mode 100644 index 0000000000..76e8f39b91 --- /dev/null +++ b/mobile/lib/shared/hosted/account_identity_store.dart @@ -0,0 +1,359 @@ +import '../storage/serial_queue.dart'; +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import 'package:nostr/nostr.dart' as nostr; + +import '../storage/secure_storage.dart'; + +import 'hosted_environment.dart'; + +/// Safe failures: never include a secret or the storage/plugin exception. +enum AccountIdentityFailure { + unreadable, + invalidKey, + occupied, + absent, + mismatch, +} + +/// Evidence from a successful backup/recovery, not merely an attempted upload. +class BackupReceipt { + BackupReceipt({ + required this.backend, + required this.subject, + required this.pubkey, + required this.backupId, + }) { + final uri = Uri.tryParse(backend); + if (uri == null || + uri.scheme != 'https' || + uri.host.isEmpty || + uri.origin != backend || + subject.isEmpty || + backupId.isEmpty || + !RegExp(r'^[0-9a-f]{64}$').hasMatch(pubkey)) { + throw AccountIdentityFailure.mismatch; + } + } + + final String backend; + final String subject; + final String pubkey; + final String backupId; + + Map _json() => { + 'backend': backend, + 'subject': subject, + 'pubkey': pubkey, + 'backup_id': backupId, + }; + + static BackupReceipt _decode(dynamic value) => BackupReceipt( + backend: value['backend'] as String, + subject: value['subject'] as String, + pubkey: value['pubkey'] as String, + backupId: value['backup_id'] as String, + ); +} + +/// Public facts only. Signing material never enters observable UI state. +class AccountIdentitySnapshot { + const AccountIdentitySnapshot(this.pubkey, this.backup); + final String pubkey; + final BackupReceipt? backup; + + bool hasBackupFor({required String backend, required String subject}) => + // Retired walkthrough receipts must never authorize real admission. + !(backup?.backupId.startsWith('debug-simulated-backup:') ?? false) && + backup?.backend == backend && + backup?.subject == subject && + backup?.pubkey == pubkey; +} + +/// Create-only AccountKey slots per backend and authenticated subject. Exact-key retries +/// are idempotent; they never discard or replace an existing backup receipt. Does not choose or mutate legacy +/// community identities. Migration must explicitly resolve a candidate first. +/// +/// Key and recovered receipt share one secure record, so restart cannot observe +/// a saved recovered key without its receipt. All instances serialize within the +/// app isolate. Native extensions/other isolates must not write this slot: +/// FlutterSecureStorage does not provide a cross-process compare-and-set. +class AccountIdentityStore { + AccountIdentityStore({FlutterSecureStorage? storage, String? subject}) + : _storage = storage ?? accountSecureStorage, + _subject = subject, + _pairingPubkey = null; + + AccountIdentityStore._paired(this._storage, this._pairingPubkey) + : _subject = null; + + final String? _subject; + final String? _pairingPubkey; + + /// Retain a transferred key before account login without occupying any + /// account slot or replacing a previous pending transfer. + AccountIdentityStore forPairing(String pubkey) { + if (!RegExp(r'^[0-9a-f]{64}$').hasMatch(pubkey)) { + throw AccountIdentityFailure.invalidKey; + } + return AccountIdentityStore._paired(_storage, pubkey); + } + + /// Pin an operation to its authenticated account, never a mutable session. + AccountIdentityStore forSubject(String subject) { + if (subject.isEmpty) throw AccountIdentityFailure.mismatch; + return AccountIdentityStore(storage: _storage, subject: subject); + } + + String get _key => _pairingPubkey != null + ? '${storageKey}_pairing_$_pairingPubkey' + : _subject == null + ? storageKey + : '${storageKey}_subject_${base64Url.encode(utf8.encode(_subject))}'; + + static final storageKey = + 'buzz_account_identity_${HostedEnvironment.origin.host.replaceAll('.', '_')}_v1'; + static final _queue = SerialQueue(); + final FlutterSecureStorage _storage; + + Future _serialized(Future Function() operation) { + final result = _queue.run(() => operation()); + return result; + } + + Future read() => _serialized(() async { + final record = await _read(); + return record?.snapshot; + }); + + Future create( + String nsec, { + BackupReceipt? recoveredBackup, + void Function()? checkCurrent, + }) => _serialized(() async { + checkCurrent?.call(); + // Read before parsing/generating any candidate: corrupt != absent. + final existing = await _read(); + final keys = _keys(nsec); + if (existing != null) { + if (existing.pubkey != keys.public) throw AccountIdentityFailure.occupied; + if (recoveredBackup == null) return existing.snapshot; + if (existing.backup != null) { + if (jsonEncode(existing.backup!._json()) != + jsonEncode(recoveredBackup._json())) { + throw AccountIdentityFailure.mismatch; + } + return existing.snapshot; + } + } + if (recoveredBackup != null && recoveredBackup.pubkey != keys.public) { + throw AccountIdentityFailure.mismatch; + } + checkCurrent?.call(); + final record = _Record(nsec, keys.public, recoveredBackup); + await _write(record); + return record.snapshot; + }); + + /// Authorization runs under the identity writer lock and must not reenter it. + /// A completed but unacknowledged write is reused on retry, never regenerated. + Future generateIfAbsent({ + required Future Function() authorize, + }) => _serialized(() async { + final existing = await _read(); + if (existing != null) return existing.snapshot; + if (!await authorize()) return null; + final keys = nostr.Keys.generate(); + final record = _Record(keys.nsec, keys.public, null); + await _write(record); + return record.snapshot; + }); + + Future confirmBackup(BackupReceipt receipt) => + _serialized(() async { + final record = await _read(); + if (record == null) throw AccountIdentityFailure.absent; + if (record.pubkey != receipt.pubkey) { + throw AccountIdentityFailure.mismatch; + } + final updated = _Record(record.nsec, record.pubkey, receipt); + await _write(updated); + return updated.snapshot; + }); + + static bool _isIdentityKey(String key) => + key == storageKey || + key.startsWith('${storageKey}_subject_') || + key.startsWith('${storageKey}_pairing_'); + + AccountIdentityStore _atKey(String key) => key == storageKey + ? AccountIdentityStore(storage: _storage) + : key.startsWith('${storageKey}_pairing_') + ? forPairing(key.substring('${storageKey}_pairing_'.length)) + : forSubject( + utf8.decode( + base64Url.decode(key.substring('${storageKey}_subject_'.length)), + ), + ); + + // Android readAll decrypts unrelated entries too. An unrelated broken entry + // must not prevent reading the legacy/current slot or an explicitly named key. + Future> _identityKeys({String? pubkey}) async { + try { + return (await _storage.readAll()).keys.where(_isIdentityKey).toSet(); + } catch (_) { + // A partial inventory cannot establish absence or authorize generation. + if (pubkey == null) throw AccountIdentityFailure.unreadable; + return {storageKey, _key, forPairing(pubkey)._key}; + } + } + + /// Saved account keys offered for explicit reuse, including pre-scope keys. + Future> identities() => _serialized(() async { + final keys = await _identityKeys(); + final identities = {}; + for (final key in keys) { + final record = await _atKey(key)._read(); + if (record != null) identities[record.pubkey] = record.snapshot; + } + return identities.values.toList(); + }); + + /// Copy a chosen saved account key without deleting its original owner. + Future adoptSaved( + String pubkey, { + required void Function() checkCurrent, + }) async => create(await _savedNsec(pubkey), checkCurrent: checkCurrent); + + /// Supplies only the explicitly selected saved key to an admission service. + Future withSavedIdentity( + String pubkey, + Future Function(String nsec) operation, + ) async => operation(await _savedNsec(pubkey)); + + Future _savedNsec(String pubkey) => _serialized(() async { + try { + final keys = await _identityKeys(pubkey: pubkey); + for (final key in keys) { + final record = await _atKey(key)._read(); + if (record?.pubkey == pubkey) return record!.nsec; + } + } catch (_) { + throw AccountIdentityFailure.unreadable; + } + throw AccountIdentityFailure.absent; + }); + + /// Service-only signing/export boundary. Do not return keys from this closure + /// to a provider/widget. Avoid reentering this store inside the callback. + Future withKeys(Future Function(nostr.Keys keys) operation) => + _serialized(() async { + final record = await _read(); + if (record == null) throw AccountIdentityFailure.absent; + return operation(_keys(record.nsec)); + }); + + Future<_Record?> _read() async { + try { + final raw = await _storage.read(key: _key); + // Preserve the old record. Only a receipt for this exact account can + // authorize its automatic migration; other keys remain explicit choices. + if (raw == null && _subject != null) { + final legacy = await AccountIdentityStore(storage: _storage)._read(); + if (legacy?.backup?.subject == _subject && + legacy?.backup?.backend == HostedEnvironment.origin.origin) { + await _write(legacy!); + return legacy; + } + } + if (raw == null) return null; + final value = jsonDecode(raw) as Map; + if (value['version'] != 1) throw AccountIdentityFailure.unreadable; + final nsec = value['nsec'] as String; + final pubkey = _keys(nsec).public; + if (value['pubkey'] != pubkey || + (_pairingPubkey != null && _pairingPubkey != pubkey)) { + throw AccountIdentityFailure.unreadable; + } + final receipt = value['backup'] == null + ? null + : BackupReceipt._decode(value['backup']); + if (receipt != null && + (receipt.pubkey != pubkey || + (_subject != null && receipt.subject != _subject))) { + throw AccountIdentityFailure.unreadable; + } + return _Record(nsec, pubkey, receipt); + } catch (_) { + throw AccountIdentityFailure.unreadable; + } + } + + Future _write(_Record record) async { + if ((_pairingPubkey != null && record.pubkey != _pairingPubkey) || + (_subject != null && + record.backup != null && + record.backup!.subject != _subject)) { + throw AccountIdentityFailure.mismatch; + } + final raw = jsonEncode({ + 'version': 1, + 'nsec': record.nsec, + 'pubkey': record.pubkey, + 'backup': record.backup?._json(), + }); + try { + await _storage.write(key: _key, value: raw); + if (await _storage.read(key: _key) != raw) { + throw AccountIdentityFailure.unreadable; + } + } catch (_) { + // A write may have completed: caller must reread, never replace/recreate. + throw AccountIdentityFailure.unreadable; + } + } + + /// Validate a candidate without exposing signing material to the UI. + static String publicKeyForNsec(String nsec) => _keys(nsec).public; + + /// Rejects invalid secp256k1 scalars before constructing a signing key. + static nostr.Keys keysForHex(String hex) { + if (!RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(hex)) { + throw AccountIdentityFailure.invalidKey; + } + final scalar = BigInt.parse(hex, radix: 16); + final order = BigInt.parse( + 'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141', + radix: 16, + ); + if (scalar <= BigInt.zero || scalar >= order) { + throw AccountIdentityFailure.invalidKey; + } + return nostr.Keys(hex); + } + + static nostr.Keys _keys(String nsec) { + try { + final decoded = nostr.Nip19.decode(payload: nsec); + if (decoded.prefix != nostr.Nip19Prefix.nsec || + !RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(decoded.data)) { + throw AccountIdentityFailure.invalidKey; + } + return keysForHex(decoded.data); + } catch (_) { + throw AccountIdentityFailure.invalidKey; + } + } +} + +class _Record { + const _Record(this.nsec, this.pubkey, this.backup); + final String nsec; + final String pubkey; + final BackupReceipt? backup; + AccountIdentitySnapshot get snapshot => + AccountIdentitySnapshot(pubkey, backup); +} diff --git a/mobile/lib/shared/hosted/bounded_http_response.dart b/mobile/lib/shared/hosted/bounded_http_response.dart new file mode 100644 index 0000000000..36ec840709 --- /dev/null +++ b/mobile/lib/shared/hosted/bounded_http_response.dart @@ -0,0 +1,50 @@ +import 'dart:async'; + +import 'package:http/http.dart' as http; + +/// One deadline covers headers and the bounded body. Abort only this request, +/// leaving the shared client usable for sign-out and subsequent attempts. +Future boundedHttpResponse( + http.Client client, + http.Request source, +) async { + final abort = Completer(); + final request = + http.AbortableRequest( + source.method, + source.url, + abortTrigger: abort.future, + ) + ..followRedirects = false + ..headers.addAll(source.headers) + ..bodyBytes = source.bodyBytes; + StreamIterator>? body; + Future read() async { + final response = await client.send(request); + final iterator = body = StreamIterator(response.stream); + if (abort.isCompleted) { + await iterator.cancel(); + throw http.RequestAbortedException(); + } + final bytes = []; + while (await iterator.moveNext()) { + if (bytes.length + iterator.current.length > 65536) { + throw const FormatException('Response too large'); + } + bytes.addAll(iterator.current); + } + return http.Response.bytes( + bytes, + response.statusCode, + headers: response.headers, + ); + } + + try { + return await read().timeout(const Duration(seconds: 30)); + } finally { + abort.complete(); + // Do not let transport cleanup hold the account's serial queue hostage. + unawaited(body?.cancel()); + } +} diff --git a/mobile/lib/shared/hosted/browser_auth.dart b/mobile/lib/shared/hosted/browser_auth.dart new file mode 100644 index 0000000000..7e0c7cb896 --- /dev/null +++ b/mobile/lib/shared/hosted/browser_auth.dart @@ -0,0 +1,287 @@ +import '../storage/serial_queue.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:app_links/app_links.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import 'package:url_launcher/url_launcher.dart'; + +import '../storage/secure_storage.dart'; + +import 'hosted_environment.dart'; + +/// Safe to show as a generic retry prompt. Never carries a URL or server text. +enum BrowserAuthFailure { restartLogin, cancelled, unavailable, storage } + +class BrowserAuthCode { + const BrowserAuthCode(this.code, this.verifier, this.intent); + final String code; + final String verifier; + final Map intent; +} + +/// One app-scoped owner. Subscribe to [codes] and [failures] before [initialize]. +/// Callbacks bypass the community deep-link FIFO. A consumed code is never +/// replayed automatically: exchange failure requires a fresh browser login. +class BrowserAuth { + BrowserAuth({ + FlutterSecureStorage? storage, + Future Function(Uri)? openBrowser, + DateTime Function()? now, + }) : _storage = storage ?? accountSecureStorage, + _openBrowser = openBrowser ?? _openPlatformBrowser, + _now = now ?? DateTime.now; + + static const callback = 'xyz.block.buzz.mobile:/auth/callback'; + static final _attemptKey = + 'buzz_browser_auth_attempt_v1_${HostedEnvironment.origin.host}'; + static final _intentKey = + 'buzz_browser_auth_intent_v1_${HostedEnvironment.origin.host}'; + static const _channel = MethodChannel('buzz/browser_auth'); + final FlutterSecureStorage _storage; + final Future Function(Uri) _openBrowser; + final DateTime Function() _now; + final _codes = StreamController.broadcast(); + final _failures = StreamController.broadcast(); + Stream get codes => _codes.stream; + Stream get failures => _failures.stream; + StreamSubscription? _links; + final _queue = SerialQueue(); + bool _disposed = false; + bool _initialized = false; + int _generation = 0; + String? _activeState; + String? _consumedCallbackHash; + + Future _serial(Future Function() task) { + final result = _queue.run(() { + if (_disposed) throw BrowserAuthFailure.cancelled; + return task(); + }); + return result; + } + + Future initialize() async { + if (_initialized || _disposed) return; + _initialized = true; + final links = AppLinks(); + _links = links.uriLinkStream.listen((uri) { + if (uri.scheme == 'xyz.block.buzz.mobile') { + unawaited(receive(uri).catchError((Object _) {})); + } + }, onError: (Object _) => _fail(BrowserAuthFailure.unavailable)); + try { + final initial = await links.getInitialLink(); + if (initial != null && initial.scheme == 'xyz.block.buzz.mobile') { + await receive(initial); + } + } catch (_) { + _fail(BrowserAuthFailure.unavailable); + } + } + + /// Intent is opaque to authentication; callers validate its relay/challenge + /// before acting. Retained separately so missing state never loses navigation. + Future?> retainedIntent() => _serial(() async { + try { + final raw = await _storage.read(key: _intentKey); + if (raw == null) return null; + return Map.from(jsonDecode(raw) as Map); + } catch (_) { + throw BrowserAuthFailure.storage; + } + }); + + /// Persist setup intent before remote work or 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 { + if (jsonEncode(intent).length > 16384) throw BrowserAuthFailure.storage; + final random = Random.secure(); + String secret() => base64UrlEncode( + List.generate(32, (_) => random.nextInt(256)), + ).replaceAll('=', ''); + final verifier = secret(); + final state = secret(); + _activeState = state; + final record = jsonEncode({ + 'verifier': verifier, + 'state': state, + 'created': _now().toUtc().millisecondsSinceEpoch, + 'intent': intent, + }); + try { + await _storage.write(key: _intentKey, value: jsonEncode(intent)); + await _storage.write(key: _attemptKey, value: record); + if (await _storage.read(key: _attemptKey) != record) { + throw BrowserAuthFailure.storage; + } + } catch (_) { + throw BrowserAuthFailure.storage; + } + return HostedEnvironment.origin.replace( + path: '/api/goose/v1/auth/login', + queryParameters: { + 'type': 'cli', + 'product': 'buzz', + 'returnTo': callback, + 'state': state, + 'code_challenge': base64UrlEncode( + sha256.convert(ascii.encode(verifier)).bytes, + ).replaceAll('=', ''), + 'code_challenge_method': 'S256', + }, + ); + }); + if (_disposed || generation != _generation) return; + try { + final result = await _openBrowser(uri); + if (!_disposed && generation == _generation && result != null) { + await receive(Uri.parse(result)); + } + } on PlatformException catch (error) { + if (generation == _generation) { + _fail( + error.code == 'cancelled' + ? BrowserAuthFailure.cancelled + : BrowserAuthFailure.unavailable, + ); + } + } catch (_) { + if (generation == _generation) _fail(BrowserAuthFailure.unavailable); + } + } + + Future receive(Uri uri) { + final generation = _generation; + return _serial(() async { + if (generation != _generation) return; + void fail(BrowserAuthFailure failure) { + if (generation == _generation) _fail(failure); + } + + // Reject noncanonical destination and duplicate query fields before reading + // secrets. Neither invalid state nor malformed callbacks mutate the session. + final callbackHash = sha256 + .convert(utf8.encode(uri.toString())) + .toString(); + if (callbackHash == _consumedCallbackHash) return; + final query = uri.queryParametersAll; + if (uri.toString().split('?').first != callback || + uri.hasFragment || + query.values.any((v) => v.length != 1) || + query.keys.any((k) => k != 'state' && k != 'code' && k != 'error')) { + if (_activeState == null) fail(BrowserAuthFailure.restartLogin); + return; + } + try { + final saved = await _storage.read(key: _attemptKey); + if (saved == null) { + fail(BrowserAuthFailure.restartLogin); + return; + } + final record = jsonDecode(saved) as Map; + final expectedState = record['state']; + if (expectedState is! String || + !RegExp(r'^[A-Za-z0-9_-]{43}$').hasMatch(expectedState)) { + throw BrowserAuthFailure.storage; + } + final age = + _now().toUtc().millisecondsSinceEpoch - (record['created'] as int); + if (age < 0 || age > const Duration(minutes: 10).inMilliseconds) { + fail(BrowserAuthFailure.restartLogin); + return; + } + if (query['state']?.single != expectedState) { + if (_activeState != expectedState) { + fail(BrowserAuthFailure.restartLogin); + } + return; + } + if (generation != _generation) return; + final code = query['code']?.single; + if (query.containsKey('error') || + code == null || + code.isEmpty || + code.length > 4096) { + fail(BrowserAuthFailure.restartLogin); + return; + } + final verifier = record['verifier'] as String; + if (!RegExp(r'^[A-Za-z0-9_-]{43}$').hasMatch(verifier)) { + throw BrowserAuthFailure.storage; + } + final intent = Map.from(record['intent'] as Map); + await _storage.delete(key: _attemptKey); + if (await _storage.read(key: _attemptKey) != null) { + throw BrowserAuthFailure.storage; + } + _activeState = null; + _consumedCallbackHash = callbackHash; + if (!_disposed && generation == _generation) { + _codes.add(BrowserAuthCode(code, verifier, Map.unmodifiable(intent))); + } + } catch (_) { + fail(BrowserAuthFailure.storage); + } + }); + } + + Future cancel() async { + ++_generation; + _activeState = null; + if (defaultTargetPlatform == TargetPlatform.iOS) { + try { + await _channel.invokeMethod('cancel'); + } catch (_) { + /* No session may be active. */ + } + } + await _serial(() => _storage.delete(key: _attemptKey)); + } + + /// Call only after completing or explicitly abandoning the retained intent. + Future clearIntent() => _serial(() => _storage.delete(key: _intentKey)); + + void _fail(BrowserAuthFailure failure) { + if (!_disposed) _failures.add(failure); + } + + Future dispose() async { + _disposed = true; + ++_generation; + await _links?.cancel(); + await _codes.close(); + await _failures.close(); + } + + static Future _openPlatformBrowser(Uri uri) async { + if (defaultTargetPlatform == TargetPlatform.iOS) { + return _channel.invokeMethod('start', { + 'url': uri.toString(), + if (kDebugMode && + const bool.fromEnvironment('BUZZ_DEBUG_PRIVATE_LOGIN')) + 'ephemeral': 'true', + }); + } + if (defaultTargetPlatform == TargetPlatform.android && + await launchUrl(uri, mode: LaunchMode.inAppBrowserView)) { + return null; + } + throw BrowserAuthFailure.unavailable; + } +} diff --git a/mobile/lib/shared/hosted/builderlab_account.dart b/mobile/lib/shared/hosted/builderlab_account.dart new file mode 100644 index 0000000000..b97604fec5 --- /dev/null +++ b/mobile/lib/shared/hosted/builderlab_account.dart @@ -0,0 +1,353 @@ +import '../storage/serial_queue.dart'; +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import 'package:http/http.dart' as http; + +import '../storage/secure_storage.dart'; + +import 'hosted_environment.dart'; +import 'bounded_http_response.dart'; +import '../relay/relay_session.dart'; +import 'account_identity_store.dart'; + +/// Errors contain no response bodies, credentials, or recovered private keys. +/// Only [AccountFailure.signedOut] means "no session"; every other failure +/// leaves the account state unknown. +enum AccountFailure { + signedOut, + denied, + accountSetupRequired, + unavailable, + invalidResponse, + storage, + + /// Login completed for a different account than the stored session. + differentAccount, + + /// The server definitively refused this request (400/404/409/422); sending + /// it again unchanged cannot succeed. + rejected, +} + +class BuilderlabAccount { + const BuilderlabAccount({ + required this.subject, + required this.expiresAt, + this.needsAccountSetup = false, + }); + final bool needsAccountSetup; + final String subject; + final DateTime expiresAt; +} + +/// Builderlab's CLI and NIP-FA adapter use the same server-side CLI credential. +/// This client uses the native header, never a browser cookie. Browser handoff +/// is deliberately a separate boundary: an app link is not an account session. +/// +/// One session per device, scoped to its account subject. A login for another +/// subject is refused until the caller clears the session and every fact it +/// holds for the previous subject. +class BuilderlabAccountClient { + BuilderlabAccountClient({ + http.Client? httpClient, + FlutterSecureStorage? storage, + }) : _http = httpClient ?? http.Client(), + _storage = storage ?? defaultStorage; + + /// Device-only: the session never syncs or restores onto another device. + @visibleForTesting + static const defaultStorage = accountSecureStorage; + + static final origin = HostedEnvironment.origin; + static const _prefix = '/api/goose'; + + /// Sessions are scoped per origin so staging never reads production state. + static final _storageKey = + 'buzz_hosted_session_${origin.host.replaceAll('.', '_')}_v1'; + final http.Client _http; + final FlutterSecureStorage _storage; + String? _credential; + BuilderlabAccount? _account; + final _queue = SerialQueue(); + bool _disposed = false; + + BuilderlabAccount? get account => _account; + + /// Runs operations one at a time in call order, so a callback exchange that + /// arrives during startup restore waits instead of failing. + Future _serial(Future Function() operation) { + final result = _queue.run(() { + if (_disposed) throw AccountFailure.unavailable; + return operation(); + }); + return result; + } + + Future restore() => _serial(() async { + final record = await _readRecord(); + if (record == null) return null; + try { + final verified = await _verify(record.credential); + if (verified.subject != record.subject) { + throw AccountFailure.invalidResponse; + } + return _adopt(record.credential, verified); + } on AccountFailure catch (failure) { + if (failure == AccountFailure.signedOut) { + await _clear(); + return null; + } + rethrow; + } + }); + + /// Receives a one-use code only from a validated browser callback. PKCE is + /// verified by Builderlab; state and callback origin are checked by the caller. + Future exchange({ + required String code, + required String verifier, + }) => _serial(() async { + if (code.isEmpty || + !RegExp(r'^[A-Za-z0-9._~-]{43,128}$').hasMatch(verifier)) { + throw AccountFailure.invalidResponse; + } + final stored = await _readRecord(); + final result = await _request( + '/v1/auth/login/exchange', + body: {'code': code, 'code_verifier': verifier}, + ); + final credential = result['session_credential']; + if (credential is! String || + credential.isEmpty || + credential.length > 512) { + throw AccountFailure.invalidResponse; + } + final verified = await _verify(credential); + if (DateTime.tryParse( + result['expires_at'] is String ? result['expires_at'] as String : '', + ) != + verified.expiresAt) { + throw AccountFailure.invalidResponse; + } + if (stored != null && stored.subject != verified.subject) { + throw AccountFailure.differentAccount; + } + if (_disposed) throw AccountFailure.unavailable; + final record = jsonEncode({ + 'origin': origin.toString(), + 'credential': credential, + 'subject': verified.subject, + 'expires_at': verified.expiresAt.toIso8601String(), + }); + try { + await _storage.write(key: _storageKey, value: record); + if (await _storage.read(key: _storageKey) != record) { + throw AccountFailure.storage; + } + } catch (_) { + throw AccountFailure.storage; + } + return _adopt(credential, verified); + }); + + /// An unreadable record is a storage failure, never an absent session: its + /// subject is unknown, so only an explicit [clearLocalSession] may drop it. + Future<({String credential, String subject})?> _readRecord() async { + final String? saved; + try { + saved = await _storage.read(key: _storageKey); + } catch (_) { + throw AccountFailure.storage; + } + if (saved == null) return null; + try { + final value = jsonDecode(saved); + if (value is Map && + value['origin'] == origin.toString() && + value['credential'] is String && + value['subject'] is String) { + return ( + credential: value['credential'] as String, + subject: value['subject'] as String, + ); + } + } on FormatException { + // Falls through to the storage failure below. + } + throw AccountFailure.storage; + } + + BuilderlabAccount _adopt(String credential, BuilderlabAccount verified) { + if (_disposed) throw AccountFailure.unavailable; + _credential = credential; + return _account = verified; + } + + Future _verify(String credential) async { + final result = await _request('/v1/auth/me', credential: credential); + final subject = result['subject']; + final expiry = result['expires_at']; + final parsed = expiry is String ? DateTime.tryParse(expiry) : null; + if (subject is! String || subject.isEmpty || parsed == null) { + throw AccountFailure.invalidResponse; + } + if (!parsed.isAfter(DateTime.now().toUtc())) throw AccountFailure.signedOut; + 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 + /// storage owner, which must derive and verify the key before any persistence. + Future> currentIdentity() => + post('/v1/buzz/nostr-identities/current', {}); + + /// Authenticated JSON POST to a Buzz account route on the fixed origin. The + /// credential never leaves this client. A 401 clears the stored session only + /// when the session check also rejects it; otherwise it is [AccountFailure.denied]. + Future> post(String path, Map body) => + _post(path, body); + + Future> _post( + String path, + Map body, { + String? signingKey, + String? expectedSubject, + }) { + if (!RegExp(r'^/v1/buzz(/[a-z0-9_-]+)+$').hasMatch(path)) { + throw ArgumentError.value(path, 'path', 'not a /v1/buzz/ route'); + } + return _serial(() async { + final credential = _credential; + if (credential == null) throw AccountFailure.signedOut; + if (expectedSubject != null && _account?.subject != expectedSubject) { + throw AccountFailure.denied; + } + try { + return await _request( + path, + credential: credential, + body: body, + signingKey: signingKey, + ); + } 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 + // session check ends the session. + try { + await _verify(credential); + } on AccountFailure catch (failure) { + if (failure == AccountFailure.signedOut) await _clear(); + rethrow; + } + throw AccountFailure.denied; + }); + } + + /// Proves the saved key. The issuer may establish its first account binding; + /// conflicting or retired bindings are refused atomically by the backend. + Future> relayAssertion( + String relay, + String nsec, { + required String expectedSubject, + }) => _post( + '/v1/buzz/identity/assertions', + { + 'relay_url': relay, + 'nostr_pubkey': AccountIdentityStore.publicKeyForNsec(nsec), + }, + signingKey: nsec, + expectedSubject: expectedSubject, + ); + + Future clearLocalSession() => _serial(_clear); + Future _clear() async { + _credential = null; + _account = null; + try { + await _storage.delete(key: _storageKey); + } catch (_) { + throw AccountFailure.storage; + } + } + + Future> _request( + String path, { + String? credential, + Map? body, + String? signingKey, + }) async { + try { + final request = + http.Request( + body == null ? 'GET' : 'POST', + origin.replace(path: '$_prefix$path'), + ) + ..followRedirects = false + ..headers['Accept'] = 'application/json'; + if (credential != null) { + request.headers['X-BB-Session-Credential'] = credential; + } + if (body != null) { + request.headers['Content-Type'] = 'application/json'; + request.headers['Origin'] = origin.toString(); + request.body = jsonEncode(body); + if (signingKey != null) { + request.headers['Authorization'] = buildNip98AuthHeader( + method: 'POST', + url: request.url.toString(), + bodyBytes: request.bodyBytes, + nsec: signingKey, + ); + } + } + final response = await boundedHttpResponse(_http, request); + if (kDebugMode && response.statusCode >= 400) { + debugPrint( + 'Builderlab ${origin.host} ${request.method} $path: HTTP ${response.statusCode} contentType=${response.headers['content-type']?.split(';').first}', + ); + } + if (response.statusCode == 401) throw AccountFailure.signedOut; + if (response.statusCode == 403) throw AccountFailure.denied; + if (const {400, 404, 409, 422}.contains(response.statusCode)) { + throw AccountFailure.rejected; + } + if (response.statusCode < 200 || response.statusCode >= 300) { + throw AccountFailure.unavailable; + } + final value = jsonDecode(utf8.decode(response.bodyBytes)); + if (value is! Map) throw AccountFailure.invalidResponse; + return value; + } on AccountFailure { + rethrow; + } on FormatException { + throw AccountFailure.invalidResponse; + } catch (_) { + throw AccountFailure.unavailable; + } + } + + void dispose() { + _disposed = true; + _credential = null; + _account = null; + _http.close(); + } +} diff --git a/mobile/lib/shared/hosted/community_entry_requirements.dart b/mobile/lib/shared/hosted/community_entry_requirements.dart new file mode 100644 index 0000000000..cdac4b3033 --- /dev/null +++ b/mobile/lib/shared/hosted/community_entry_requirements.dart @@ -0,0 +1,68 @@ +import '../community/community.dart'; +import 'account_identity_store.dart'; +import 'builderlab_account.dart'; +import 'hosted_environment.dart'; + +/// The same hosted requirements apply to every new community, regardless of +/// whether its credentials came from an invite, key import, or verified pairing. +abstract final class CommunityEntryRequirements { + /// Nostr-only entry cannot bypass a configured account requirement. + static void requireSelfHosted(String relayUrl) { + if (HostedEnvironment.requiresAuth(Uri.parse(relayUrl).host)) { + throw StateError( + 'This community requires Builderlab sign-in and identity ' + 'backup. Open its link through community setup.', + ); + } + } + + static Future verify( + Community community, + BuilderlabAccountClient account, + ) async { + final uri = Uri.parse(community.relayUrl); + if (!HostedEnvironment.requiresAuth(uri.host)) return; + if (!HostedEnvironment.supportsRelay(uri)) throw AccountFailure.denied; + { + final session = account.account ?? await account.restore(); + if (session == null) throw AccountFailure.signedOut; + final nsec = community.nsec; + if (nsec == null) throw AccountIdentityFailure.absent; + final pubkey = AccountIdentityStore.publicKeyForNsec(nsec); + if (community.pubkey != pubkey) throw AccountIdentityFailure.mismatch; + await verifyHostedIdentity( + account: account, + store: AccountIdentityStore(), + subject: session.subject, + pubkey: pubkey, + ); + } + } + + static Future verifyHostedIdentity({ + required BuilderlabAccountClient account, + required AccountIdentityStore store, + required String subject, + required String pubkey, + }) async { + if (account.account?.subject != subject) { + throw AccountFailure.differentAccount; + } + final snapshot = await store.forSubject(subject).read(); + if (snapshot?.pubkey != pubkey || + !snapshot!.hasBackupFor( + backend: HostedEnvironment.origin.origin, + subject: subject, + )) { + throw AccountFailure.denied; + } + final binding = await account.currentIdentity(); + if (account.account?.subject != subject) { + throw AccountFailure.differentAccount; + } + final identity = binding['identity']; + if (identity is! Map || identity['pubkey_hex'] != pubkey) { + throw AccountFailure.denied; + } + } +} diff --git a/mobile/lib/shared/hosted/device_identity_source.dart b/mobile/lib/shared/hosted/device_identity_source.dart new file mode 100644 index 0000000000..aef15e53a0 --- /dev/null +++ b/mobile/lib/shared/hosted/device_identity_source.dart @@ -0,0 +1,94 @@ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import '../storage/secure_storage.dart'; + +import 'account_identity_store.dart'; + +/// Public identity choices; never contains private signing material. +class DeviceIdentityChoice { + const DeviceIdentityChoice(this.pubkey, this.label, {this.fromCloud = false}); + final String pubkey; + final String label; + final bool fromCloud; +} + +/// Read-only inventory, including legacy records hidden by a newer community list. +/// Do not call CommunityStorage.loadAll here: it migrates and deletes records. +class DeviceIdentitySource { + DeviceIdentitySource({FlutterSecureStorage? storage}) + : _storage = storage ?? communitySecureStorage; + final FlutterSecureStorage _storage; + + Future> choices() async => [ + for (final entry in (await _read()).entries) + DeviceIdentityChoice(entry.key, entry.value.label), + ]; + + /// Reread and validate the chosen key immediately before create-only adoption. + Future adopt( + String pubkey, + AccountIdentityStore store, { + required void Function() checkCurrent, + }) async { + final candidate = (await _read())[pubkey]; + if (candidate == null) throw AccountIdentityFailure.absent; + checkCurrent(); + 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 = {}; + final labels = >{}; + void add(dynamic nsec, dynamic advertised, String label) { + if (nsec == null) { + // Token-only legacy communities legitimately have no private key. + return; + } + if (nsec is! String) throw AccountIdentityFailure.unreadable; + final pubkey = AccountIdentityStore.publicKeyForNsec(nsec); + if (advertised != null && advertised != pubkey) { + throw AccountIdentityFailure.unreadable; + } + final names = labels.putIfAbsent(pubkey, () => {})..add(label); + result[pubkey] = (nsec: nsec, label: names.join(', ')); + } + + for (final key in ['buzz_communities', 'buzz_workspaces']) { + final raw = await _storage.read(key: key); + if (raw == null) continue; + final records = jsonDecode(raw) as List; + for (final record in records) { + final value = record as Map; + add( + value['nsec'], + value['pubkey'], + (value['name'] as String?)?.trim().isNotEmpty == true + ? (value['name'] as String).trim() + : 'Saved community', + ); + } + } + add( + await _storage.read(key: 'buzz_nsec'), + await _storage.read(key: 'buzz_pubkey'), + 'Previous device identity', + ); + return result; + } catch (_) { + throw AccountIdentityFailure.unreadable; + } + } +} diff --git a/mobile/lib/shared/hosted/hosted_admission.dart b/mobile/lib/shared/hosted/hosted_admission.dart new file mode 100644 index 0000000000..fa7e75a591 --- /dev/null +++ b/mobile/lib/shared/hosted/hosted_admission.dart @@ -0,0 +1,170 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../community/community.dart'; +import '../deeplink/deep_link.dart'; +import '../relay/relay_session.dart'; +import '../relay/relay_socket.dart'; +import 'account_identity_store.dart'; +import 'builderlab_account.dart'; +import 'community_entry_requirements.dart'; +import 'hosted_environment.dart'; +import 'hosted_communities.dart'; +import 'hosted_join_policy.dart'; +import 'hosted_relay_auth.dart'; +import '../relay/relay_provider.dart'; + +/// Builds a community only after scoped backup evidence and current binding. +/// The caller persists it through the existing community transition owner. +class HostedAdmission { + HostedAdmission({ + required this.account, + required this.store, + http.Client? client, + required this.relayAuth, + Future Function(HostedJoinPolicy)? confirmPolicy, + Future Function(Community)? probe, + }) : _confirmPolicy = confirmPolicy ?? ((_) async => false), + _http = client ?? http.Client(), + _probe = probe; + final HostedRelayAuth relayAuth; + final BuilderlabAccountClient account; + final AccountIdentityStore store; + final http.Client _http; + final Future Function(HostedJoinPolicy) _confirmPolicy; + final Future Function(Community)? _probe; + + Future prepare( + BuzzDeepLink link, + String subject, + String pubkey, { + bool Function()? isCurrent, + }) async { + void checkCurrent() { + if (isCurrent != null && !isCurrent()) throw AccountFailure.unavailable; + if (account.account?.subject != subject) { + throw AccountFailure.differentAccount; + } + } + + final store = this.store.forSubject(subject); + checkCurrent(); + final relay = link.relayUrl ?? (throw AccountFailure.invalidResponse); + final uri = Uri.parse(relay); + if (uri.scheme != 'wss' || + uri.userInfo.isNotEmpty || + uri.hasQuery || + uri.hasFragment || + (uri.path.isNotEmpty && uri.path != '/') || + !HostedEnvironment.supportsRelay(uri) || + (uri.hasPort && uri.port != 443)) { + throw AccountFailure.denied; + } + if (account.account?.subject != subject) { + throw AccountFailure.differentAccount; + } + await CommunityEntryRequirements.verifyHostedIdentity( + account: account, + store: store, + subject: subject, + pubkey: pubkey, + ); + checkCurrent(); + if (link is InviteDeepLink) { + try { + final receipt = await freshJoinPolicyReceipt( + client: _http, + origin: uri, + code: link.code, + confirm: _confirmPolicy, + checkCurrent: checkCurrent, + ); + final body = jsonEncode({ + 'code': link.code, + 'policy_receipt': ?receipt, + }); + final url = uri.replace(scheme: 'https', path: '/api/invites/claim'); + final headers = await store.withKeys( + (keys) async => { + ...await relayAuth.headers( + RelayConfig(baseUrl: relay, nsec: keys.nsec), + ), + 'Authorization': buildNip98AuthHeader( + method: 'POST', + url: url.toString(), + bodyBytes: utf8.encode(body), + nsec: keys.nsec, + ), + 'Content-Type': 'application/json', + }, + ); + checkCurrent(); + final request = http.Request('POST', url) + ..followRedirects = false + ..headers.addAll(headers) + ..body = body; + await readJoinResponse(_http, request); + checkCurrent(); + } on HostedInviteFailure catch (failure) { + if (failure == HostedInviteFailure.policyRequired) rethrow; + checkCurrent(); + // A lost claim response may outlive its code. AUTH alone also admits + // non-members on open relays; reconcile explicit membership first. + final List joined; + try { + joined = await loadHostedCommunities(account); + } on AccountFailure catch (discoveryFailure) { + if (discoveryFailure == AccountFailure.rejected || + discoveryFailure == AccountFailure.invalidResponse) { + throw failure; + } + rethrow; + } + checkCurrent(); + if (!joined.any( + (entry) => Uri.parse(entry.relayUrl).host == uri.host, + )) { + rethrow; + } + } + } + if (account.account?.subject != subject) { + throw AccountFailure.differentAccount; + } + final community = await store.withKeys((keys) async { + if (keys.public != pubkey) throw AccountIdentityFailure.mismatch; + return Community.create( + name: link is AddCommunityDeepLink ? link.name : uri.host, + relayUrl: relay, + pubkey: pubkey, + nsec: keys.nsec, + ); + }); + checkCurrent(); + await (_probe ?? _authenticateRelay)(community); + checkCurrent(); + return community; + } + + Future _authenticateRelay(Community community) async { + final socket = RelaySocket( + wsUrl: community.relayUrl, + nsec: community.nsec, + onMessage: (_) {}, + onConnected: () {}, + onDisconnected: (_) {}, + ); + try { + socket.headers = await relayAuth.headers( + RelayConfig(baseUrl: community.relayUrl, nsec: community.nsec), + ); + await socket.connect().timeout(const Duration(seconds: 30)); + if (socket.state != SocketState.connected) throw AccountFailure.denied; + } finally { + socket.dispose(); + } + } + + void dispose() => _http.close(); +} diff --git a/mobile/lib/shared/hosted/hosted_backup_service.dart b/mobile/lib/shared/hosted/hosted_backup_service.dart new file mode 100644 index 0000000000..29fc41b528 --- /dev/null +++ b/mobile/lib/shared/hosted/hosted_backup_service.dart @@ -0,0 +1,170 @@ +import '../storage/serial_queue.dart'; +import 'dart:async'; +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import '../storage/secure_storage.dart'; + +import 'account_identity_store.dart'; +import 'builderlab_account.dart'; +import 'hosted_environment.dart'; +import 'hpke_backup.dart'; + +enum HostedBackupFailure { + identityMismatch, + storage, + invalidResponse, + rejected, + unavailable, +} + +/// A pending ciphertext is durable before its first upload. Uncertain uploads +/// replay that same envelope; only confirmed storage produces an admission receipt. +class HostedBackupService { + HostedBackupService( + this.account, + this.identity, { + HpkeBackup? hpke, + FlutterSecureStorage? storage, + }) : _hpke = hpke ?? HpkeBackup(), + _storage = storage ?? accountSecureStorage; + + final BuilderlabAccountClient account; + final AccountIdentityStore identity; + final HpkeBackup _hpke; + final FlutterSecureStorage _storage; + static final _queue = SerialQueue(); + static String pendingKey(String subject, String pubkey) => + 'buzz_backup_pending_v2_${sha256.convert(utf8.encode(jsonEncode([HostedEnvironment.origin.origin, subject, pubkey])))}'; + + Future backup({bool Function()? isCurrent}) { + final result = _queue.run(() => _backup(isCurrent: isCurrent)); + return result; + } + + Future _backup({ + bool reenrolled = false, + bool Function()? isCurrent, + }) async { + void checkCurrent() { + if (isCurrent != null && !isCurrent()) throw AccountFailure.unavailable; + } + + checkCurrent(); + final subject = account.account?.subject; + final local = await identity.read(); + if (subject == null || local == null) { + throw HostedBackupFailure.identityMismatch; + } + final backend = HostedEnvironment.origin.origin; + final key = pendingKey(subject, local.pubkey); + if (local.hasBackupFor(backend: backend, subject: subject)) return; + void checkAccount() { + if (account.account?.subject != subject) { + throw HostedBackupFailure.identityMismatch; + } + } + + Map? envelope; + try { + final raw = await _storage.read(key: key); + if (raw != null) { + final record = jsonDecode(raw) as Map; + if (record['backend'] != backend || + record['subject'] != subject || + record['pubkey'] != local.pubkey) { + throw HostedBackupFailure.identityMismatch; + } + envelope = Map.from(record['envelope'] as Map); + HpkeBackup.associatedData(envelope); + if (envelope['nostr_pubkey'] != local.pubkey) { + throw HostedBackupFailure.identityMismatch; + } + } + } on HostedBackupFailure { + rethrow; + } catch (_) { + throw HostedBackupFailure.storage; + } + checkAccount(); + checkCurrent(); + if (envelope == null) { + final Map enrollment; + try { + enrollment = await account.post('/v1/buzz/nsec-backups/enrollment', {}); + } on AccountFailure catch (failure) { + if (failure == AccountFailure.rejected) { + throw HostedBackupFailure.unavailable; + } + rethrow; + } + checkAccount(); + if (enrollment['nostr_pubkey'] != local.pubkey) { + throw HostedBackupFailure.identityMismatch; + } + envelope = await identity.withKeys( + (keys) => _hpke.seal(enrollment: enrollment, secretHex: keys.secret), + ); + checkAccount(); + final raw = jsonEncode({ + 'backend': backend, + 'subject': subject, + 'pubkey': local.pubkey, + 'envelope': envelope, + }); + try { + await _storage.write(key: key, value: raw); + if (await _storage.read(key: key) != raw) { + throw HostedBackupFailure.storage; + } + } catch (_) { + throw HostedBackupFailure.storage; + } + } + checkAccount(); + checkCurrent(); + final pending = envelope!; + final Map result; + try { + result = await account.post('/v1/buzz/nsec-backups', pending); + } on AccountFailure catch (failure) { + if (failure != AccountFailure.rejected) rethrow; + checkAccount(); + checkCurrent(); + // Only a definitive rejection permits replacing the pending envelope. + // A timeout/5xx/unknown response always preserves it for exact replay. + if (reenrolled) throw HostedBackupFailure.rejected; + try { + await _storage.delete(key: key); + if (await _storage.read(key: key) != null) { + throw HostedBackupFailure.storage; + } + } catch (_) { + throw HostedBackupFailure.storage; + } + return _backup(reenrolled: true, isCurrent: isCurrent); + } + checkAccount(); + if (result['stored'] != true || + result['backup_id'] != pending['backup_id']) { + throw HostedBackupFailure.invalidResponse; + } + await identity.confirmBackup( + BackupReceipt( + backend: backend, + subject: subject, + pubkey: local.pubkey, + backupId: pending['backup_id'] as String, + ), + ); + // Receipt is authoritative after this point; a failed cleanup cannot cause + // another enrollment/upload on restart. + try { + await _storage.delete(key: key); + } catch (_) { + /* Retry is unnecessary once the receipt is durable. */ + } + } +} diff --git a/mobile/lib/shared/hosted/hosted_communities.dart b/mobile/lib/shared/hosted/hosted_communities.dart new file mode 100644 index 0000000000..daa9c257a4 --- /dev/null +++ b/mobile/lib/shared/hosted/hosted_communities.dart @@ -0,0 +1,51 @@ +import '../deeplink/deep_link.dart'; +import 'builderlab_account.dart'; +import 'hosted_environment.dart'; + +/// Validated, active communities joined by the signed-in Builderlab identity. +/// Discovery never grants admission; each target still goes through HostedAdmission. +Future> loadHostedCommunities( + BuilderlabAccountClient account, +) async { + final response = await account.post('/v1/buzz/communities/joined', {}); + final rows = response['communities']; + if (response['error'] != null || rows is! List) { + throw AccountFailure.invalidResponse; + } + final communities = {}; + for (final row in rows) { + if (row is! Map) throw AccountFailure.invalidResponse; + final archived = row['archived_at']; + if (archived != null) { + if (archived is! String || archived.isEmpty) { + throw AccountFailure.invalidResponse; + } + continue; + } + final host = row['normalized_host']; + final name = row['name']; + if (host is! String || name is! String || name.trim().isEmpty) { + throw AccountFailure.invalidResponse; + } + final relay = Uri.tryParse(host.contains('://') ? host : 'wss://$host'); + if (relay == null || + relay.scheme != 'wss' || + !RegExp(r'^[a-z0-9]+(?:[.-][a-z0-9]+)*$').hasMatch(relay.host) || + relay.userInfo.isNotEmpty || + relay.hasQuery || + relay.hasFragment || + (relay.path.isNotEmpty && relay.path != '/') || + (relay.hasPort && relay.port != 443)) { + throw AccountFailure.invalidResponse; + } + // A hosted listing can contain communities this build cannot enter. + // Never redirect the account session or discard otherwise usable entries. + if (!HostedEnvironment.supportsRelay(relay)) continue; + final canonical = Uri(scheme: 'wss', host: relay.host); + communities.putIfAbsent( + canonical.host, + () => AddCommunityDeepLink(relayUrl: canonical.toString(), name: name), + ); + } + return List.unmodifiable(communities.values); +} diff --git a/mobile/lib/shared/hosted/hosted_community_connection.dart b/mobile/lib/shared/hosted/hosted_community_connection.dart new file mode 100644 index 0000000000..e61fdf793b --- /dev/null +++ b/mobile/lib/shared/hosted/hosted_community_connection.dart @@ -0,0 +1,65 @@ +import '../community/community.dart'; +import '../community/community_storage.dart'; +import '../deeplink/deep_link.dart'; +import 'builderlab_account.dart'; +import 'hosted_admission.dart'; + +/// A saved community uses another identity. Only explicit skip/leave is safe. +class SavedCommunityIdentityConflict implements Exception { + const SavedCommunityIdentityConflict(); +} + +/// Canonical community identity shared by discovery and deep-link entry. +({bool secure, String host, int port}) relayIdentity(String value) { + final uri = Uri.parse(value); + final secure = uri.scheme == 'wss' || uri.scheme == 'https'; + return ( + secure: secure, + host: uri.host.toLowerCase(), + port: uri.hasPort ? uri.port : (secure ? 443 : 80), + ); +} + +/// Reuses existing records and their ids before the normal admission/persistence +/// boundary. A different existing key is never replaced. +Future connectHostedCommunity({ + required BuzzDeepLink link, + required String subject, + required String pubkey, + required bool Function() isCurrent, + required CommunityStorage storage, + required HostedAdmission admission, + required Future Function(Community, bool Function()) persist, + required Future Function(Community, bool Function()) + recoverStarterChannels, +}) async { + final relay = link.relayUrl ?? (throw AccountFailure.invalidResponse); + final communities = await storage.loadAll(); + final existing = communities + .where((item) => relayIdentity(item.relayUrl) == relayIdentity(relay)) + .firstOrNull; + if (existing != null && existing.pubkey != pubkey) { + throw const SavedCommunityIdentityConflict(); + } + if (!isCurrent()) return; + // Saved membership survives a partially completed invite setup. Revalidate + // identity and probe the relay, but never reclaim its consumed invite. + final admissionLink = existing != null && link is InviteDeepLink + ? AddCommunityDeepLink(relayUrl: relay, name: existing.name) + : link; + final prepared = await admission.prepare( + admissionLink, + subject, + pubkey, + isCurrent: isCurrent, + ); + final community = existing == null + ? prepared.copyWith(starterSetupIncomplete: link is InviteDeepLink) + : existing.copyWith(nsec: prepared.nsec); + if (!isCurrent()) return; + await persist(community, isCurrent); + if (!isCurrent()) return; + if (community.starterSetupIncomplete) { + await recoverStarterChannels(community, isCurrent); + } +} diff --git a/mobile/lib/shared/hosted/hosted_community_creation.dart b/mobile/lib/shared/hosted/hosted_community_creation.dart new file mode 100644 index 0000000000..3c64ce7092 --- /dev/null +++ b/mobile/lib/shared/hosted/hosted_community_creation.dart @@ -0,0 +1,62 @@ +import '../deeplink/deep_link.dart'; +import 'builderlab_account.dart'; +import 'hosted_environment.dart'; + +enum HostedCreationFailure { rejected } + +/// Internal setup intent; no external deep link can request provisioning. +class HostedCreateCommunity extends AddCommunityDeepLink { + HostedCreateCommunity(String name) + : super( + name: name, + relayUrl: 'wss://$name.${HostedEnvironment.communitySuffix}', + ) { + if (!validName(name)) throw ArgumentError.value(name, 'name'); + } + + static bool validName(String name) => + name.length <= 63 && RegExp(r'^[a-z0-9]+(?:-[a-z0-9]+)*$').hasMatch(name); + + /// The server reconciles the same owner/name on replay, including a lost + /// response. Keep the name stable and validate ownership before admission. + Future provision( + BuilderlabAccountClient account, + String pubkey, + bool Function() isCurrent, + ) async { + final subject = account.account?.subject; + void check() { + if (!isCurrent() || + subject == null || + account.account?.subject != subject) { + throw AccountFailure.unavailable; + } + } + + check(); + final Map response; + try { + response = await account.post('/v1/buzz/communities', {'name': name}); + } on AccountFailure catch (failure) { + check(); + if (failure == AccountFailure.rejected) { + throw HostedCreationFailure.rejected; + } + rethrow; + } + check(); + if (response['error'] != null) throw AccountFailure.invalidResponse; + return _validate(response['community'], pubkey); + } + + AddCommunityDeepLink _validate(dynamic row, String pubkey) { + if (row is! Map || + row['normalized_host'] != Uri.parse(relayUrl).host || + row['owner_pubkey'] != pubkey || + row['name'] != name) { + throw AccountFailure.invalidResponse; + } + if (row['archived_at'] != null) throw HostedCreationFailure.rejected; + return AddCommunityDeepLink(relayUrl: relayUrl, name: name); + } +} diff --git a/mobile/lib/shared/hosted/hosted_environment.dart b/mobile/lib/shared/hosted/hosted_environment.dart new file mode 100644 index 0000000000..a3ca701e53 --- /dev/null +++ b/mobile/lib/shared/hosted/hosted_environment.dart @@ -0,0 +1,83 @@ +/// Trusted build configuration, never supplied by a deep link or relay. +abstract final class HostedEnvironment { + /// Account UI and startup work are opt-in, independently of the backend URL. + static bool get enabled => authDomains.isNotEmpty; + + static final Uri origin = _origin(); + static const _communityAccounts = { + 'communities.buzz.xyz': 'https://app.builderlab.xyz', + 'communities.public.builderlabstaging.xyz': + 'https://app.builderlabstaging.xyz', + }; + + /// Creation and admission use the same trusted build-specific host mapping. + static String get communitySuffix => _communityAccounts.entries + .singleWhere((entry) => Uri.parse(entry.value) == origin) + .key; + + /// Comma-separated DNS domains requiring Builderlab authentication. + /// Empty builds keep Nostr-only entry available, including hosted QR pairing. + static final List authDomains = List.unmodifiable( + const String.fromEnvironment('BUZZ_AUTH_DOMAINS') + .split(',') + .map( + (domain) => + domain.trim().toLowerCase().replaceFirst(RegExp(r'\.+$'), ''), + ) + .where((domain) => domain.isNotEmpty) + .map((domain) { + if (!RegExp( + r'^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$', + ).hasMatch(domain)) { + throw StateError( + 'BUZZ_AUTH_DOMAINS must contain DNS names, not URLs or wildcards', + ); + } + return domain; + }), + ); + + /// Matches a configured domain and its subdomains, never a string suffix. + static bool requiresAuth(String host) { + final normalized = host.toLowerCase().replaceFirst(RegExp(r'\.+$'), ''); + return authDomains.any( + (domain) => normalized == domain || normalized.endsWith('.$domain'), + ); + } + + /// Explicit community-to-account mapping. Never infer an account endpoint + /// from arbitrary relay metadata or send this build's session across realms. + static Uri? accountOriginFor(Uri relay) { + if (!['wss', 'https'].contains(relay.scheme) || + relay.userInfo.isNotEmpty || + relay.hasQuery || + relay.hasFragment || + (relay.path.isNotEmpty && relay.path != '/') || + (relay.hasPort && relay.port != 443)) { + return null; + } + final host = relay.host.toLowerCase(); + for (final entry in _communityAccounts.entries) { + if (host.endsWith('.${entry.key}')) return Uri.parse(entry.value); + } + return null; + } + + /// Whether this build can use its account session for this relay. + static bool supportsRelay(Uri relay) => accountOriginFor(relay) == origin; + + static Uri _origin() { + const configured = String.fromEnvironment( + 'BUZZ_BUILDERLAB_URL', + defaultValue: 'https://app.builderlab.xyz', + ); + final uri = Uri.parse(configured); + if (!{ + 'https://app.builderlab.xyz', + 'https://app.builderlabstaging.xyz', + }.contains(configured)) { + throw StateError('Unsupported hosted account environment'); + } + return uri; + } +} diff --git a/mobile/lib/shared/hosted/hosted_flow.dart b/mobile/lib/shared/hosted/hosted_flow.dart new file mode 100644 index 0000000000..e57affb470 --- /dev/null +++ b/mobile/lib/shared/hosted/hosted_flow.dart @@ -0,0 +1,852 @@ +import '../storage/serial_queue.dart'; +import 'dart:async'; + +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'; +import 'builderlab_account.dart'; +import 'hosted_identity_service.dart'; +import 'hosted_backup_service.dart'; +import 'hpke_backup.dart'; +import 'hosted_environment.dart'; +import 'hosted_communities.dart'; +import 'hosted_community_creation.dart'; +import 'hosted_community_connection.dart'; +import 'web_connect_binding.dart'; +import 'hosted_join_policy.dart'; + +enum HostedFlowStage { + idle, + working, + signIn, + chooseIdentity, + chooseCommunities, + communityConflict, + acceptPolicy, + profile, + blocked, + browserReturned, + complete, +} + +enum HostedFlowProblem { + retry, + denied, + accountSetupRequired, + differentAccount, + identityUnreadable, + backupUnavailable, + invalidResponse, + requestRejected, + identityMismatch, + identityUnavailable, + backupRequired, + bindingRequired, + unsupportedEnvironment, + invitePolicyRequired, + inviteExpired, + inviteExhausted, + inviteInvalid, + communityCreationRejected, +} + +/// Resolves only after relay admission is proven and local persistence completes. +/// Implementations must check [isCurrent] immediately before durable mutations. +typedef HostedCommunityAdmission = + Future Function( + BuzzDeepLink target, + String subject, + String pubkey, + bool Function() isCurrent, + ); + +/// App-scoped owner: callback subscriptions must outlive any individual page. +/// Only public progress reaches widgets; pending auth secrets remain in BrowserAuth. +class HostedFlow extends ChangeNotifier { + HostedFlow({ + required this.account, + required this.browser, + required this.store, + required this.identity, + required this.enterCommunity, + Future Function(Uri)? openWeb, + }) : _openWeb = + openWeb ?? + ((uri) => launchUrl(uri, mode: LaunchMode.externalApplication)); + final BuilderlabAccountClient account; + final BrowserAuth browser; + final AccountIdentityStore store; + final HostedIdentityService identity; + final HostedCommunityAdmission enterCommunity; + bool get canRetry => + problem == null || + problem == HostedFlowProblem.retry || + problem == HostedFlowProblem.invitePolicyRequired; + final Future Function(Uri) _openWeb; + HostedFlowStage stage = HostedFlowStage.idle; + HostedFlowProblem? problem; + List identityChoices = const []; + BuzzDeepLink? _target; + bool accountOnly = false; + String? _pairedPubkey; + SensitiveActionPolicy? pairedSensitiveActionPolicy; + List communities = const []; + Set selectedRelays = const {}; + final Set _addedRelays = {}; + bool _addingCommunities = false; + String? _batchSubject; + String? _conflictingRelay; + bool get canChangeCommunities => + accountOnly && + account.account?.subject == _batchSubject && + (stage == HostedFlowStage.blocked || + stage == HostedFlowStage.communityConflict) && + communities.isNotEmpty; + String? addingCommunityName; + int get addedCommunityCount => _addedRelays.length; + final _queue = SerialQueue(); + StreamSubscription? _codes; + StreamSubscription? _failures; + bool _disposed = false; + int _generation = 0; + + int _nextGeneration() { + _cancelPolicy(); + _cancelProfile(); + return ++_generation; + } + + Completer? _profileCompletion; + Future Function(String)? saveProfile; + + Future requestProfile(Future Function(String) save) async { + if (_disposed) return; + final completion = Completer(); + _profileCompletion = completion; + saveProfile = (name) async { + if (!identical(_profileCompletion, completion) || + completion.isCompleted) { + throw AccountFailure.unavailable; + } + await save(name); + if (identical(_profileCompletion, completion)) finishProfile(); + }; + _set(HostedFlowStage.profile); + await completion.future; + if (identical(_profileCompletion, completion)) { + _profileCompletion = null; + saveProfile = null; + _set(HostedFlowStage.working); + } + } + + void finishProfile() { + final completion = _profileCompletion; + if (completion != null && !completion.isCompleted) completion.complete(); + } + + void _cancelProfile() { + final completion = _profileCompletion; + _profileCompletion = null; + saveProfile = null; + if (completion != null && !completion.isCompleted) completion.complete(); + } + + HostedJoinPolicy? joinPolicy; + Completer? _policyConsent; + + Future confirmJoinPolicy(HostedJoinPolicy policy) async { + if (_disposed) return false; + joinPolicy = policy; + final consent = Completer(); + _policyConsent = consent; + _set(HostedFlowStage.acceptPolicy); + final accepted = await consent.future; + if (identical(_policyConsent, consent)) { + _policyConsent = null; + joinPolicy = null; + if (accepted) _set(HostedFlowStage.working); + } + return accepted; + } + + void acceptJoinPolicy() { + final consent = _policyConsent; + if (consent != null && !consent.isCompleted) consent.complete(true); + } + + void _cancelPolicy() { + final consent = _policyConsent; + _policyConsent = null; + joinPolicy = null; + if (consent != null && !consent.isCompleted) consent.complete(false); + } + + bool reconnecting = false; + + Future? _initialization; + Future initialize() => _initialization ??= _initialize(); + + Future _initialize() async { + if (_codes != null) return; + _codes = browser.codes.listen((code) { + final generation = _generation; + _enqueue(() async { + reconnecting = code.intent['type'] == 'reconnect'; + accountOnly = reconnecting || code.intent['type'] == 'account'; + if (accountOnly) { + _target = null; + } else { + _restorePairing(code.intent); + _target = _decode(code.intent); + if (_target == null) throw BrowserAuthFailure.restartLogin; + } + _set(HostedFlowStage.working); + await account.exchange(code: code.code, verifier: code.verifier); + if (_disposed || generation != _generation) return; + if (reconnecting) { + await browser.clearIntent(); + if (_disposed || generation != _generation) return; + _set(HostedFlowStage.complete); + reconnecting = false; + } else { + await _continue(generation); + } + }); + }); + _failures = browser.failures.listen((failure) { + if (_disposed) return; + if (!accountOnly && _target == null) { + if (_generation == 0) { + _set(HostedFlowStage.blocked, HostedFlowProblem.retry); + } + return; + } + if (stage != HostedFlowStage.signIn) return; + if (failure == BrowserAuthFailure.cancelled) { + unawaited(leave()); + } else { + _set(HostedFlowStage.blocked, HostedFlowProblem.retry); + } + }); + await _enqueue(() async { + final generation = _generation; + await browser.initialize(); + if (_disposed || generation != _generation) return; + if (_target == null) { + final retained = await browser.retainedIntent(); + if (_disposed || generation != _generation) return; + if (retained != null && _target == null) { + if (retained['type'] == 'reconnect') { + reconnecting = true; + accountOnly = true; + _set(HostedFlowStage.idle); + } else if (retained['type'] == 'account') { + accountOnly = true; + _set(HostedFlowStage.idle); + } else { + _restorePairing(retained); + _target = _decode(retained); + } + if (_target != null && stage == HostedFlowStage.idle) { + _set(HostedFlowStage.signIn); + } + } + } + }); + } + + /// Reauthenticate an existing community without enrollment, backup or discovery. + Future reconnectAccount({bool changeAccount = false}) async { + await initialize(); + final generation = _nextGeneration(); + reconnecting = true; + accountOnly = true; + _target = null; + _set(HostedFlowStage.signIn); + await _enqueue(() async { + if (_disposed || generation != _generation) return; + if (changeAccount) await account.clearLocalSession(); + if (_disposed || generation != _generation) return; + await browser.start({'type': 'reconnect'}); + }); + } + + /// Explicit Connect completes identity setup and discovers joined communities. + /// Passive startup restore only checks the saved session. + Future signInAccount({bool openBrowser = true}) { + if (_target != null || + stage == HostedFlowStage.working || + stage == HostedFlowStage.signIn) { + return Future.value(); + } + final generation = openBrowser ? _nextGeneration() : _generation; + if (openBrowser) { + reconnecting = false; + accountOnly = true; + _clearCommunityBatch(); + _set(HostedFlowStage.working); + } + return _enqueue(() async { + if (!openBrowser) { + if (_target != null || stage != HostedFlowStage.idle) return; + } + final restored = await account.restore(); + if (_disposed || generation != _generation) return; + if (!openBrowser) { + // Mounting the welcome page must not create/bind/back up a key. + _set(HostedFlowStage.idle); + } else if (restored != null) { + await _continue(generation); + } else { + _set(HostedFlowStage.signIn); + await browser.start({'type': 'account'}); + } + }); + } + + /// 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 = _nextGeneration(); + void check() { + if (!isCurrent() || generation != _generation || _disposed) { + throw AccountFailure.unavailable; + } + } + + check(); + await store.forPairing(pubkey).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) { + throw ArgumentError('Unsupported hosted target'); + } + final generation = _nextGeneration(); + reconnecting = false; + accountOnly = false; + _clearCommunityBatch(); + _target = target; + _set(HostedFlowStage.working); + return _enqueue(() async { + if (generation != _generation) return; + if (!_acceptTarget(target)) return; + // Login may already be complete. Retain the destination before any + // remote work so restart can reconcile using the same saved identity. + await browser.retainIntent(_intent(target)); + if (_disposed || generation != _generation) return; + await _resume(generation, _intent(target)); + }); + } + + Future _resume(int generation, Map intent) async { + final restored = await account.restore(); + if (_disposed || generation != _generation) return; + if (restored == null) { + _set(HostedFlowStage.signIn); + await browser.start(intent); + } else { + await _continue(generation); + } + } + + Future retry() async { + if (reconnecting) return reconnectAccount(); + final target = _target; + if (target == null && !accountOnly) return; + final generation = _nextGeneration(); + try { + await browser.cancel(); + if (_disposed || generation != _generation) return; + if (target != null) { + await start(target, resumePairing: _pairedPubkey != null); + } else { + _set(HostedFlowStage.working); + await _enqueue(() => _resume(generation, {'type': 'account'})); + } + } catch (_) { + if (!_disposed && generation == _generation) { + _set(HostedFlowStage.blocked, HostedFlowProblem.retry); + } + } + } + + /// Resume only an offered public identity; reread local and cloud facts first. + Future chooseIdentity(String pubkey) { + if ((_target == null && !accountOnly) || + stage != HostedFlowStage.chooseIdentity || + !identityChoices.any((item) => item.pubkey == pubkey)) { + return Future.value(); + } + final generation = _nextGeneration(); + _set(HostedFlowStage.working); + return _enqueue(() => _continue(generation, selectedPubkey: pubkey)); + } + + Future _continue(int generation, {String? selectedPubkey}) async { + final target = _target; + final session = account.account; + if ((target == null && !accountOnly) || session == null) { + throw AccountFailure.signedOut; + } + final store = this.store.forSubject(session.subject); + if (target != null && !_acceptTarget(target)) return; + if (target == null && _batchSubject != session.subject) { + _clearCommunityBatch(); + _batchSubject = session.subject; + } + var resolved = await identity.resolve( + isCurrent: _isCurrent(generation), + selectedPubkey: selectedPubkey ?? _pairedPubkey, + ); + 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; + if (resolved.state == HostedIdentityState.needsBinding && + resolved.pubkey != null) { + final response = await account.post( + '/v1/buzz/nostr-identities/challenge', + {'origin': HostedEnvironment.origin.toString()}, + ); + if (_disposed || generation != _generation) return; + final fields = { + 'audience': 'buzz:nostr-identity', + 'action': 'bind_nostr_identity', + 'protocol': 'buzz-nostr-identity', + 'version': '1', + 'return': 'browser_fragment_v1', + 'callback_url': HostedEnvironment.origin + .resolve('/buzz') + .toString(), + }; + for (final key in [ + 'challenge_id', + 'nonce', + 'verification_code', + 'origin', + 'expires_at', + ]) { + final value = response[key]; + if (value is! String) throw AccountFailure.invalidResponse; + fields[key] = value; + } + final request = parseNostrBindDeepLink( + Uri(scheme: 'buzz', host: 'nostr-bind', queryParameters: fields), + ); + if (response['error'] != null || request == null) { + throw AccountFailure.invalidResponse; + } + await WebConnectBindingService().complete( + request: request, + subject: session.subject, + expectedPubkey: resolved.pubkey!, + withKeys: store.withKeys, + verify: _verifyBinding, + isCurrent: _isCurrent(generation), + ); + } else if (resolved.state == HostedIdentityState.needsBackup) { + await HostedBackupService( + account, + store, + ).backup(isCurrent: _isCurrent(generation)); + } else { + break; + } + if (_disposed || generation != _generation) return; + resolved = await identity.resolve(isCurrent: _isCurrent(generation)); + } + } + if (generation != _generation || _disposed) return; + if (resolved.state == HostedIdentityState.needsSelection) { + identityChoices = List.unmodifiable(resolved.choices); + _set(HostedFlowStage.chooseIdentity); + return; + } + final pubkey = resolved.pubkey; + if (target is NostrBindDeepLink && + pubkey != null && + { + HostedIdentityState.ready, + HostedIdentityState.needsBinding, + HostedIdentityState.needsBackup, + }.contains(resolved.state)) { + final returnTo = await WebConnectBindingService().complete( + request: target, + subject: session.subject, + expectedPubkey: pubkey, + withKeys: store.withKeys, + verify: _verifyBinding, + isCurrent: _isCurrent(generation), + ); + if (generation != _generation || _disposed) return; + if (!await _openWeb(returnTo)) throw BrowserAuthFailure.unavailable; + await browser.clearIntent(); + _set(HostedFlowStage.browserReturned); + return; + } + if (resolved.state != HostedIdentityState.ready || pubkey == null) { + final failure = switch (resolved.state) { + HostedIdentityState.needsBackup => HostedFlowProblem.backupRequired, + HostedIdentityState.needsBinding => HostedFlowProblem.bindingRequired, + HostedIdentityState.mismatch => HostedFlowProblem.identityMismatch, + _ => HostedFlowProblem.identityUnavailable, + }; + _set(HostedFlowStage.blocked, failure); + return; + } + if (target == null) { + await _continueCommunities(generation, session.subject, pubkey); + return; + } + final destination = target is HostedCreateCommunity + ? await target.provision(account, pubkey, _isCurrent(generation)) + : target; + if (_disposed || generation != _generation) return; + await enterCommunity( + destination, + session.subject, + pubkey, + _isCurrent(generation), + ); + if (generation != _generation || _disposed) return; + await browser.clearIntent(); + _set(HostedFlowStage.complete); + } + + void selectCommunity(String relay, bool selected) { + if (stage != HostedFlowStage.chooseCommunities || + !communities.any((item) => item.relayUrl == relay)) { + return; + } + selectedRelays = Set.unmodifiable({ + ...selectedRelays.where((item) => item != relay), + if (selected) relay, + }); + notifyListeners(); + } + + Future addSelectedCommunities() { + if (!accountOnly || + stage != HostedFlowStage.chooseCommunities || + selectedRelays.isEmpty) { + return Future.value(); + } + _addingCommunities = true; + final generation = _nextGeneration(); + _set(HostedFlowStage.working); + return _enqueue(() => _continue(generation)); + } + + /// Keep completed additions and let the user omit a failing target. + void backToCommunityChoices() { + if (!canChangeCommunities) return; + _nextGeneration(); + communities = List.unmodifiable( + communities.where((item) => !_addedRelays.contains(item.relayUrl)), + ); + selectedRelays = Set.unmodifiable(selectedRelays.difference(_addedRelays)); + _conflictingRelay = null; + addingCommunityName = null; + _set(HostedFlowStage.chooseCommunities); + } + + Future skipConflictingCommunity() { + final relay = _conflictingRelay; + if (stage != HostedFlowStage.communityConflict || relay == null) { + return Future.value(); + } + selectedRelays = Set.unmodifiable( + selectedRelays.where((item) => item != relay), + ); + _conflictingRelay = null; + final generation = _nextGeneration(); + _set(HostedFlowStage.working); + return _enqueue(() => _continue(generation)); + } + + Future _continueCommunities( + int generation, + String subject, + String pubkey, + ) async { + bool current() => + !_disposed && + generation == _generation && + account.account?.subject == subject; + if (!_addingCommunities) { + final listed = await loadHostedCommunities(account); + if (!current()) return; + communities = listed; + selectedRelays = Set.unmodifiable(listed.map((item) => item.relayUrl)); + if (listed.isEmpty) { + await leave(noCommunities: true); + return; + } + // Direct account entry imports every joined community. The existing + // switcher handles choosing which community to enter after admission. + _addingCommunities = true; + } + for (final community in communities) { + if (!current()) return; + if (!selectedRelays.contains(community.relayUrl) || + _addedRelays.contains(community.relayUrl)) { + continue; + } + addingCommunityName = community.name; + notifyListeners(); + try { + await enterCommunity(community, subject, pubkey, current); + } on SavedCommunityIdentityConflict { + if (!current()) return; + _conflictingRelay = community.relayUrl; + _set(HostedFlowStage.communityConflict); + return; + } + if (!current()) return; + _addedRelays.add(community.relayUrl); + } + if (!current()) return; + addingCommunityName = null; + if (_addedRelays.isEmpty) { + communities = List.unmodifiable( + communities.where((item) => selectedRelays.contains(item.relayUrl)), + ); + await leave(noCommunities: true); + return; + } + await browser.clearIntent(); + if (current()) _set(HostedFlowStage.complete); + } + + bool _acceptTarget(BuzzDeepLink target) { + final relay = target.relayUrl; + if (relay != null && !HostedEnvironment.supportsRelay(Uri.parse(relay))) { + _set(HostedFlowStage.blocked, HostedFlowProblem.unsupportedEnvironment); + return false; + } + return true; + } + + Future _verifyBinding({ + required String subject, + required String challengeId, + required String nonce, + required String signedPayload, + }) async { + if (account.account?.subject != subject) { + throw AccountFailure.differentAccount; + } + final response = await account.post('/v1/buzz/nostr-identities/verify', { + 'challenge_id': challengeId, + 'nonce': nonce, + 'signed_payload': signedPayload, + }); + if (account.account?.subject != subject) { + throw AccountFailure.differentAccount; + } + final bound = response['identity']; + if (response['error'] != null || + bound is! Map || + bound['pubkey_hex'] is! String) { + throw AccountFailure.invalidResponse; + } + return bound['pubkey_hex'] as String; + } + + bool Function() _isCurrent(int generation) => + () => !_disposed && generation == _generation; + + Future _enqueue(Future Function() operation) { + final generation = _generation; + final result = _queue.run(() async { + if (_disposed || generation != _generation) return; + try { + await operation(); + } catch (error) { + if (!_disposed && generation == _generation) { + _set(HostedFlowStage.blocked, _problemFor(error)); + } + } + }); + return result; + } + + static HostedFlowProblem _problemFor(Object error) => switch (error) { + HostedCreationFailure.rejected => + HostedFlowProblem.communityCreationRejected, + HostedInviteFailure.policyRequired => + HostedFlowProblem.invitePolicyRequired, + HostedInviteFailure.expired => HostedFlowProblem.inviteExpired, + HostedInviteFailure.exhausted => HostedFlowProblem.inviteExhausted, + HostedInviteFailure.invalid => HostedFlowProblem.inviteInvalid, + AccountFailure.denied => HostedFlowProblem.denied, + AccountFailure.accountSetupRequired => + HostedFlowProblem.accountSetupRequired, + AccountFailure.rejected || + HostedBackupFailure.rejected => HostedFlowProblem.requestRejected, + HostedBackupFailure.unavailable => HostedFlowProblem.backupUnavailable, + AccountFailure.differentAccount => HostedFlowProblem.differentAccount, + AccountIdentityFailure.unreadable || + AccountIdentityFailure.invalidKey || + AccountFailure.storage || + HostedBackupFailure.storage => HostedFlowProblem.identityUnreadable, + SavedCommunityIdentityConflict() || + AccountIdentityFailure.mismatch || + AccountIdentityFailure.occupied || + HostedBackupFailure.identityMismatch || + HpkeBackupFailure.identityMismatch => HostedFlowProblem.identityMismatch, + HpkeBackupFailure.unavailable => HostedFlowProblem.backupUnavailable, + AccountFailure.invalidResponse || + HostedBackupFailure.invalidResponse || + HpkeBackupFailure.invalidEnrollment => HostedFlowProblem.invalidResponse, + _ => HostedFlowProblem.retry, + }; + + void _set(HostedFlowStage next, [HostedFlowProblem? failure]) { + if (_disposed) return; + if (next != HostedFlowStage.chooseIdentity) identityChoices = const []; + stage = next; + problem = failure; + notifyListeners(); + } + + bool noCommunitiesToAdd = false; + + void _clearCommunityBatch() { + noCommunitiesToAdd = false; + communities = const []; + selectedRelays = const {}; + _addedRelays.clear(); + _batchSubject = null; + _conflictingRelay = null; + _addingCommunities = false; + addingCommunityName = null; + } + + Future leave({bool noCommunities = false}) async { + _nextGeneration(); + final generation = _generation; + _target = null; + _pairedPubkey = null; + pairedSensitiveActionPolicy = null; + reconnecting = false; + accountOnly = false; + _clearCommunityBatch(); + noCommunitiesToAdd = noCommunities; + try { + await browser.cancel(); + if (_disposed || generation != _generation) return; + await browser.clearIntent(); + if (generation == _generation) _set(HostedFlowStage.idle); + } catch (_) { + if (generation == _generation) { + _set(HostedFlowStage.blocked, HostedFlowProblem.retry); + } + } + } + + static Map _encode(BuzzDeepLink target) { + if (target is HostedCreateCommunity) { + return {'type': 'create', 'name': target.name}; + } + if (target is NostrBindDeepLink) return {'type': 'bind', ...target.fields}; + if (target is AddCommunityDeepLink) { + return {'type': 'add', 'relay': target.relayUrl, 'name': target.name}; + } + final invite = target as InviteDeepLink; + return { + 'type': 'invite', + 'relay': invite.relayUrl, + 'code': invite.code, + if (invite.policyReceipt != null) 'policy_receipt': invite.policyReceipt!, + }; + } + + static BuzzDeepLink? _decode(Map intent) { + if (intent['type'] == 'create') { + final name = intent['name']; + return name != null && HostedCreateCommunity.validName(name) + ? HostedCreateCommunity(name) + : null; + } + final fields = Map.of(intent) + ..remove('type') + ..remove('paired_pubkey') + ..remove('paired_protection'); + final host = switch (intent['type']) { + 'bind' => 'nostr-bind', + 'add' => 'add-community', + 'invite' => 'join', + _ => '', + }; + return parseBuzzDeepLink( + Uri(scheme: 'buzz', host: host, queryParameters: fields), + ); + } + + @override + void dispose() { + _disposed = true; + _cancelPolicy(); + _cancelProfile(); + _nextGeneration(); + unawaited(_codes?.cancel()); + unawaited(_failures?.cancel()); + super.dispose(); + } +} diff --git a/mobile/lib/shared/hosted/hosted_flow_problem_message.dart b/mobile/lib/shared/hosted/hosted_flow_problem_message.dart new file mode 100644 index 0000000000..957804c940 --- /dev/null +++ b/mobile/lib/shared/hosted/hosted_flow_problem_message.dart @@ -0,0 +1,40 @@ +import 'hosted_flow.dart'; + +String hostedFlowProblemMessage( + HostedFlowProblem? problem, +) => switch (problem) { + HostedFlowProblem.communityCreationRejected => + 'This community could not be created. Leave setup to choose another address, or check your hosted community limit in Builderlab.', + 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.invitePolicyRequired => + 'The community policy needs to be accepted again. Try again to review it.', + HostedFlowProblem.inviteExpired => + 'This invite has expired. Ask for a new invite link.', + HostedFlowProblem.inviteExhausted => + 'This invite has no uses left. Ask for a new invite link.', + HostedFlowProblem.inviteInvalid => + 'This invite is not valid. Ask for a new invite link.', + 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.', + HostedFlowProblem.identityUnreadable => + 'Your saved identity could not be read safely. Leave setup and recover access to this device’s secure storage before continuing.', + HostedFlowProblem.backupUnavailable => + 'Secure backup is not supported on this device yet. Your identity has been kept; joining requires a confirmed backup.', + HostedFlowProblem.requestRejected => + 'The account service could not accept this setup request. Your saved account and identity have been kept.', + HostedFlowProblem.invalidResponse => + 'The account service returned a response this app cannot safely use. Setup has stopped.', + HostedFlowProblem.identityMismatch => + 'This account uses a different identity. Your saved identity has been kept.', + HostedFlowProblem.backupRequired => + '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 => + 'We could not safely recover an identity for this account. Your saved keys have been kept.', + _ => 'We could not finish connecting. You can try again.', +}; diff --git a/mobile/lib/shared/hosted/hosted_flow_provider.dart b/mobile/lib/shared/hosted/hosted_flow_provider.dart new file mode 100644 index 0000000000..d39411ca2e --- /dev/null +++ b/mobile/lib/shared/hosted/hosted_flow_provider.dart @@ -0,0 +1,213 @@ +import 'dart:async'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../features/invites/invite_join_provider.dart'; +import '../../features/profile/profile_provider.dart'; +import '../relay/relay_provider.dart'; +import '../relay/relay_session.dart'; +import '../auth/auth.dart'; +import '../community/paired_community_landing.dart'; +import '../deeplink/deep_link.dart'; +import 'account_identity_store.dart'; +import 'browser_auth.dart'; +import 'builderlab_account.dart'; +import 'hosted_admission.dart'; +import 'hosted_community_connection.dart'; +import 'hosted_flow.dart'; +import 'hosted_identity_service.dart'; +import 'hosted_relay_auth.dart'; + +// Set by a community-entry surface while it owns setup presentation. +final hostedInlineEntryProvider = NotifierProvider( + HostedInlineEntry.new, +); + +class HostedInlineEntry extends Notifier { + @override + bool build() => false; + + 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) => createHostedFlow(ref)); + +/// Shared orchestration with injectable browser and relay network boundaries. +HostedFlow createHostedFlow( + Ref ref, { + BrowserAuth? browserAuth, + Future Function(Community)? probe, +}) { + final account = ref.read(builderlabAccountProvider); + final browser = browserAuth ?? BrowserAuth(); + final store = AccountIdentityStore(); + late final HostedFlow flow; + final admission = HostedAdmission( + relayAuth: ref.read(hostedRelayAuthProvider), + account: account, + store: store, + confirmPolicy: (policy) => flow.confirmJoinPolicy(policy), + probe: probe, + ); + flow = HostedFlow( + account: account, + browser: browser, + store: store, + identity: HostedIdentityService(account: account, store: store), + enterCommunity: (link, subject, pubkey, isCurrent) async { + await connectHostedCommunity( + link: link, + subject: subject, + pubkey: pubkey, + isCurrent: () => isCurrent() && account.account?.subject == subject, + storage: ref.read(communityStorageProvider), + admission: admission, + persist: (community, current) => ref + .read(authProvider.notifier) + .authenticateWithCommunity( + flow.pairedSensitiveActionPolicy == null + ? community + : community.copyWith( + sensitiveActionPolicy: flow.pairedSensitiveActionPolicy, + ), + shouldContinue: current, + activate: + community.starterSetupIncomplete || + flow.addedCommunityCount == 0, + ), + recoverStarterChannels: (community, current) => + finishHostedStarterSetup(ref, community, current), + ); + if (flow.addedCommunityCount == 0 && isCurrent()) { + await offerHostedProfile( + ref, + flow, + link, + pubkey, + () => isCurrent() && account.account?.subject == subject, + ); + } + }, + ); + void onCompleted() { + if (flow.reconnecting && flow.stage == HostedFlowStage.complete) { + unawaited(ref.read(relaySessionProvider.notifier).reconnect()); + } else if (flow.accountOnly && flow.stage == HostedFlowStage.complete) { + ref.read(communityGridLandingProvider.notifier).request(); + } + } + + flow.addListener(onCompleted); + ref.onDispose(() { + flow.removeListener(onCompleted); + flow.dispose(); + browser.dispose(); + admission.dispose(); + }); + return flow; +} + +/// Optional and scoped to the first activated community. A failed read is not +/// proof that no profile exists; leave it to Settings in that case. +Future offerHostedProfile( + Ref ref, + HostedFlow flow, + BuzzDeepLink link, + String pubkey, + bool Function() isCurrent, +) async { + final relay = link.relayUrl; + if (relay == null || !ref.mounted || !isCurrent()) return; + final active = await ref.read(activeCommunityProvider.future); + bool current() { + if (!ref.mounted || !isCurrent()) return false; + final now = ref.read(activeCommunityProvider).asData?.value; + return now != null && + now.id == active?.id && + now.pubkey == pubkey && + relayIdentity(now.relayUrl) == relayIdentity(relay); + } + + if (!current()) return; + // Keep the provider hydrated throughout the optional sheet. + final subscription = ref.listen(profileProvider, (_, _) {}); + try { + try { + final profile = await ref.read(profileProvider.future); + if (profile != null || !current()) return; + } catch (_) { + return; + } + await flow.requestProfile((name) async { + if (!current()) throw AccountFailure.unavailable; + await ref.read(profileProvider.notifier).updateDisplayName(name); + }); + } finally { + subscription.close(); + } +} + +/// Completes the existing scoped invite recovery only after activation. The +/// pending flag remains durable on failure, cancellation or a community switch. +Future finishHostedStarterSetup( + Ref ref, + Community community, + bool Function() isCurrent, +) async { + void checkCurrent() { + if (!ref.mounted || !isCurrent()) throw AccountFailure.unavailable; + } + + bool matches(Community? candidate) => + candidate?.id == community.id && + candidate?.relayUrl == community.relayUrl && + candidate?.pubkey == community.pubkey && + candidate?.nsec == community.nsec; + + checkCurrent(); + final active = await ref.read(activeCommunityProvider.future); + checkCurrent(); + if (!matches(active)) throw AccountFailure.unavailable; + final config = RelayConfig(baseUrl: community.relayUrl, nsec: community.nsec); + await ref + .read(inviteJoinRecoveryProvider)( + InviteJoinRecoveryScope( + relayHttpOrigin: config.baseUrl, + nsec: config.nsec, + ), + ) + .ensureStarterChannels(); + checkCurrent(); + await ref.read(communityTransitionProvider).runExclusive(() async { + checkCurrent(); + final storage = ref.read(communityStorageProvider); + final saved = (await storage.loadAll()) + .where((item) => item.id == community.id) + .firstOrNull; + final activeId = await storage.loadActiveId(); + checkCurrent(); + if (!matches(saved) || activeId != community.id) { + throw AccountFailure.unavailable; + } + await storage.save(saved!.copyWith(starterSetupIncomplete: false)); + await syncStoredCommunitySnapshot(ref); + ref.invalidate(communityListProvider); + ref.invalidate(activeCommunityProvider); + ref.invalidate(authProvider); + }); +} diff --git a/mobile/lib/shared/hosted/hosted_identity_service.dart b/mobile/lib/shared/hosted/hosted_identity_service.dart new file mode 100644 index 0000000000..3c7ae73cd4 --- /dev/null +++ b/mobile/lib/shared/hosted/hosted_identity_service.dart @@ -0,0 +1,323 @@ +import 'dart:convert'; + +import 'package:nostr/nostr.dart' as nostr; + +import 'account_identity_store.dart'; +import 'builderlab_account.dart'; +import 'device_identity_source.dart'; + +enum HostedIdentityState { + ready, + needsSelection, + needsBinding, + needsBackup, + mismatch, + recoveryBlocked, + needsAuthoritativeAbsence, +} + +class HostedIdentityResult { + const HostedIdentityResult( + this.state, { + this.pubkey, + this.choices = const [], + }); + final HostedIdentityState state; + final String? pubkey; + final List choices; +} + +/// Resolves identity safely. Generation requires a separately verified +/// complete absence contract and explicit legacy-key reconciliation; neither a +/// failed recovery nor an unbound account proves there is no prior identity. +class HostedIdentityService { + HostedIdentityService({ + required this.account, + required this.store, + DeviceIdentitySource? deviceIdentities, + }) : deviceIdentities = deviceIdentities ?? DeviceIdentitySource(); + + final DeviceIdentitySource deviceIdentities; + final BuilderlabAccountClient account; + final AccountIdentityStore store; + + Future resolve({ + bool Function()? isCurrent, + String? selectedPubkey, + }) => _resolve(isCurrent, selectedPubkey); + + Future _resolve( + bool Function()? isCurrent, + String? selectedPubkey, + ) async { + void checkCurrent() { + if (isCurrent != null && !isCurrent()) throw AccountFailure.unavailable; + } + + checkCurrent(); + final subject = account.account?.subject; + if (subject == null) throw AccountFailure.signedOut; + final store = this.store.forSubject(subject); + final local = await store.read(); + final response = await account.currentIdentity(); + _sameAccount(subject); + if (response['error'] != null) throw AccountFailure.invalidResponse; + final identity = response['identity']; + final String? bound; + if (identity == null) { + bound = null; // The current API omits the optional unbound identity. + } else if (identity is Map) { + bound = _pubkey(identity['pubkey_hex']); + } else { + throw AccountFailure.invalidResponse; + } + if (local != null && bound != null) return _result(local, bound, subject); + + final status = await account.post('/v1/buzz/nsec-backups/status', {}); + _sameAccount(subject); + checkCurrent(); + final listed = status['backup_nostr_pubkeys']; + if (status['error'] != null || listed is! List) { + throw AccountFailure.invalidResponse; + } + final statusBound = status['current_nostr_pubkey'] == null + ? null + : _pubkey(status['current_nostr_pubkey']); + final statusKeys = listed.map(_pubkey).toSet(); + if (statusBound != bound) throw AccountFailure.invalidResponse; + if (local != null) { + // 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); + } + final device = await deviceIdentities.choices(); + final savedAccounts = await store.identities(); + final choices = [ + ...device, + for (final saved in savedAccounts) + if (!device.any((item) => item.pubkey == saved.pubkey)) + DeviceIdentityChoice(saved.pubkey, 'Saved identity'), + ]; + _sameAccount(subject); + checkCurrent(); + // The authoritative bound key already on this device needs no remote + // decryption, even when the account retains backups of older identities. + final matchingDevice = + bound != null && choices.any((item) => item.pubkey == bound); + if ((statusKeys.isEmpty && bound == null) || matchingDevice) { + final eligible = choices + .where((item) => bound == null || item.pubkey == bound) + .toList(); + if (eligible.isNotEmpty) { + final chosen = + selectedPubkey ?? + (bound != null && eligible.length == 1 + ? eligible.single.pubkey + : null); + if (!eligible.any((item) => item.pubkey == chosen)) { + return HostedIdentityResult( + HostedIdentityState.needsSelection, + choices: eligible, + ); + } + void checkSelection() { + _sameAccount(subject); + checkCurrent(); + } + + final saved = device.any((item) => item.pubkey == chosen) + ? await deviceIdentities.adopt( + chosen!, + store, + checkCurrent: checkSelection, + ) + : await store.adoptSaved(chosen!, checkCurrent: checkSelection); + return _result(saved, bound, subject); + } + } + if (statusBound == null && statusKeys.isEmpty) { + final generated = await store.generateIfAbsent( + authorize: () async { + _sameAccount(subject); + checkCurrent(); + final reconciled = (await deviceIdentities.choices()).isEmpty; + _sameAccount(subject); + checkCurrent(); + return reconciled; + }, + ); + _sameAccount(subject); + checkCurrent(); + return generated == null + ? const HostedIdentityResult( + HostedIdentityState.needsAuthoritativeAbsence, + ) + : _result(generated, bound, subject); + } + + final recovered = await account.post('/v1/buzz/nsec-backups/recovery', {}); + _sameAccount(subject); + final backups = recovered['backups']; + final failed = recovered['failed_backup_ids']; + if (recovered['error'] != null || + backups is! List || + failed is! List || + failed.any((id) => id is! String)) { + throw AccountFailure.invalidResponse; + } + if (failed.isNotEmpty) { + return const HostedIdentityResult(HostedIdentityState.recoveryBlocked); + } + if (backups.isEmpty) { + return HostedIdentityResult( + bound == null + ? HostedIdentityState.needsAuthoritativeAbsence + : HostedIdentityState.recoveryBlocked, + ); + } + // Validate the entire result before storing anything. Never trust its + // advertised pubkey without deriving it from the actual secret. + final candidates = {}; + try { + for (final backup in backups) { + if (backup is! Map) throw const FormatException(); + final secret = backup['secret_key']; + final backupId = backup['backup_id']; + if (secret is! String || + !RegExp(r'^[A-Za-z0-9_-]{43}$').hasMatch(secret) || + backupId is! String || + backupId.isEmpty) { + throw const FormatException(); + } + final bytes = base64Url.decode(base64Url.normalize(secret)); + if (bytes.length != 32) throw const FormatException(); + final hex = bytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + final keys = AccountIdentityStore.keysForHex(hex); + if (keys.public != _pubkey(backup['nostr_pubkey'])) { + throw const FormatException(); + } + candidates.putIfAbsent( + keys.public, + () => (keys: keys, backupId: backupId), + ); + } + } catch (_) { + throw AccountFailure.invalidResponse; + } + if (bound == null && + (statusKeys.length != candidates.length || + !statusKeys.every(candidates.containsKey))) { + return const HostedIdentityResult(HostedIdentityState.recoveryBlocked); + } + // 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) && + selectedPubkey != candidate.keys.public) { + return HostedIdentityResult( + HostedIdentityState.needsSelection, + choices: [ + DeviceIdentityChoice( + candidate.keys.public, + 'Use Builderlab identity', + fromCloud: true, + ), + ], + ); + } + _sameAccount(subject); + checkCurrent(); + final saved = await store.create( + candidate.keys.nsec, + checkCurrent: () { + _sameAccount(subject); + checkCurrent(); + }, + recoveredBackup: BackupReceipt( + backend: BuilderlabAccountClient.origin.origin, + subject: subject, + pubkey: candidate.keys.public, + backupId: candidate.backupId, + ), + ); + _sameAccount(subject); + checkCurrent(); + return _result(saved, bound, subject); + } + + HostedIdentityResult _result( + AccountIdentitySnapshot local, + String? bound, + String subject, + ) { + _sameAccount(subject); + if (bound != null && bound != local.pubkey) { + return HostedIdentityResult( + HostedIdentityState.mismatch, + pubkey: local.pubkey, + ); + } + if (bound == null) { + return HostedIdentityResult( + HostedIdentityState.needsBinding, + pubkey: local.pubkey, + ); + } + return HostedIdentityResult( + local.hasBackupFor( + backend: BuilderlabAccountClient.origin.origin, + subject: subject, + ) + ? HostedIdentityState.ready + : HostedIdentityState.needsBackup, + pubkey: local.pubkey, + ); + } + + void _sameAccount(String subject) { + if (account.account?.subject != subject) { + throw AccountFailure.differentAccount; + } + } + + static String _pubkey(dynamic value) { + if (value is! String || !RegExp(r'^[a-fA-F0-9]{64}$').hasMatch(value)) { + throw AccountFailure.invalidResponse; + } + return value.toLowerCase(); + } +} diff --git a/mobile/lib/shared/hosted/hosted_join_policy.dart b/mobile/lib/shared/hosted/hosted_join_policy.dart new file mode 100644 index 0000000000..d91ac8dfde --- /dev/null +++ b/mobile/lib/shared/hosted/hosted_join_policy.dart @@ -0,0 +1,109 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'builderlab_account.dart'; +import 'bounded_http_response.dart'; + +/// Public policy text bound to the exact version the user accepts. +class HostedJoinPolicy { + const HostedJoinPolicy({ + required this.version, + required this.ageRequired, + this.terms, + this.privacy, + }); + final String version; + final bool ageRequired; + final String? terms; + final String? privacy; +} + +enum HostedInviteFailure { policyRequired, expired, exhausted, invalid } + +/// Reads only a bounded JSON document, never follows redirects or logs bodies. +Future> readJoinResponse( + http.Client client, + http.Request request, +) async { + final http.Response response; + final Map data; + try { + response = await boundedHttpResponse(client, request); + if (response.statusCode == 429 || + (response.statusCode >= 500 && response.statusCode < 600)) { + throw AccountFailure.unavailable; + } + data = jsonDecode(utf8.decode(response.bodyBytes)) as Map; + } on FormatException { + throw AccountFailure.invalidResponse; + } on TypeError { + throw AccountFailure.invalidResponse; + } + if (response.statusCode < 200 || response.statusCode >= 300) { + throw switch (data['error']) { + 'join_policy_required' || + 'join_policy_not_accepted' => HostedInviteFailure.policyRequired, + 'invite_expired' => HostedInviteFailure.expired, + 'invite_exhausted' => HostedInviteFailure.exhausted, + 'invite_invalid' => HostedInviteFailure.invalid, + _ => AccountFailure.denied, + }; + } + return data; +} + +Future freshJoinPolicyReceipt({ + required http.Client client, + required Uri origin, + required String code, + required Future Function(HostedJoinPolicy) confirm, + required void Function() checkCurrent, +}) async { + Future> send( + String method, + String path, [ + Map? body, + ]) async { + checkCurrent(); + final request = http.Request( + method, + origin.replace(scheme: 'https', path: path), + )..followRedirects = false; + if (body != null) { + request.headers['Content-Type'] = 'application/json'; + request.body = jsonEncode(body); + } + final data = await readJoinResponse(client, request); + checkCurrent(); + return data; + } + + final data = await send('GET', '/api/join-policy'); + final raw = data['policy']; + if (raw == null) return null; + if (raw is! Map || + raw['version'] is! String || + (raw['version'] as String).isEmpty || + raw['age_attestation_required'] is! bool || + (raw['terms_markdown'] != null && raw['terms_markdown'] is! String) || + (raw['privacy_markdown'] != null && raw['privacy_markdown'] is! String)) { + throw AccountFailure.invalidResponse; + } + final policy = HostedJoinPolicy( + version: raw['version'] as String, + ageRequired: raw['age_attestation_required'] as bool, + terms: raw['terms_markdown'] as String?, + privacy: raw['privacy_markdown'] as String?, + ); + if (!await confirm(policy)) throw HostedInviteFailure.policyRequired; + checkCurrent(); + final accepted = await send('POST', '/api/invites/accept-policy', { + 'code': code, + 'policy_version': policy.version, + 'age_confirmed': policy.ageRequired, + }); + final receipt = accepted['receipt']; + if (receipt is! String || receipt.isEmpty || receipt.length > 8192) { + throw AccountFailure.invalidResponse; + } + return receipt; +} diff --git a/mobile/lib/shared/hosted/hosted_relay_auth.dart b/mobile/lib/shared/hosted/hosted_relay_auth.dart new file mode 100644 index 0000000000..dee75c032d --- /dev/null +++ b/mobile/lib/shared/hosted/hosted_relay_auth.dart @@ -0,0 +1,245 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; + +import '../relay/relay_provider.dart'; +import 'builderlab_account.dart'; +import 'account_identity_store.dart'; +import 'hosted_environment.dart'; + +final builderlabAccountProvider = Provider((ref) { + final account = BuilderlabAccountClient(); + ref.onDispose(account.dispose); + return account; +}); + +final hostedRelayAuthProvider = Provider((ref) { + final auth = HostedRelayAuth(ref.watch(builderlabAccountProvider)); + ref.onDispose(auth.dispose); + return auth; +}); + +/// Each transport owns its client lifetime; configuration and auth are shared. +final hostedHttpClientFactoryProvider = Provider((ref) { + final auth = ref.watch(hostedRelayAuthProvider); + final config = ref.watch(relayConfigProvider); + return () => HostedRelayHttpClient(auth, config); +}); + +/// One account session, with a short-lived assertion for the current relay/key. +/// Enable alongside backend issuance; legacy and self-hosted builds stay usable. +class HostedRelayAuth extends ChangeNotifier { + HostedRelayAuth( + this.account, { + this.enabled = const bool.fromEnvironment('BUZZ_HOSTED_RELAY_AUTH'), + DateTime Function()? now, + }) : _now = now ?? DateTime.now; + + final BuilderlabAccountClient account; + final bool enabled; + final DateTime Function() _now; + ({ + String relay, + String pubkey, + String token, + DateTime expiry, + BuilderlabAccount account, + })? + _cached; + Future? _pending; + bool _disposed = false; + String? failedRelay; + String? failedPubkey; + AccountFailure? failure; + + bool protects(RelayConfig config) => + enabled && + HostedEnvironment.requiresAuth(Uri.parse(config.baseUrl).host) && + HostedEnvironment.supportsRelay(Uri.parse(config.baseUrl)); + + Future> headers(RelayConfig config) async { + if (!protects(config)) return const {}; + final relay = Uri.parse(config.wsUrl).replace(path: '').toString(); + final pubkey = pubkeyFromNsec(config.nsec); + if (pubkey == null) throw AccountFailure.denied; + while (!_disposed) { + final cached = _cached; + if (cached != null && + cached.relay == relay && + cached.pubkey == pubkey && + identical(cached.account, account.account) && + cached.expiry.isAfter(_now())) { + return {'Nostr-Federated-Identity': 'Bearer ${cached.token}'}; + } + final pending = _pending; + if (pending != null) { + // Different communities share account restoration, never assertions. + try { + await pending; + } on AccountFailure { + if (failedRelay == config.baseUrl && failedPubkey == pubkey) rethrow; + } + continue; + } + final operation = _issue(config, relay, pubkey); + _pending = operation; + try { + await operation; + } finally { + _pending = null; + } + } + throw AccountFailure.unavailable; + } + + Future _issue(RelayConfig config, String relay, String pubkey) async { + try { + if (account.account == null && await account.restore() == null) { + throw AccountFailure.signedOut; + } + final session = account.account!; + // Issuance can create a binding. Reconnect must not silently give a + // different, unbound account this community's saved identity. + final current = await account.currentIdentity(); + if (!identical(session, account.account)) throw AccountFailure.denied; + final identity = current['identity']; + if (current['error'] != null) throw AccountFailure.invalidResponse; + if (identity == null) { + final AccountIdentitySnapshot? saved; + try { + saved = await AccountIdentityStore() + .forSubject(session.subject) + .read(); + } on AccountIdentityFailure { + throw AccountFailure.storage; + } + if (saved?.pubkey != pubkey || + !saved!.hasBackupFor( + backend: BuilderlabAccountClient.origin.origin, + subject: session.subject, + )) { + throw AccountFailure.denied; + } + } else if (identity is! Map || identity['pubkey_hex'] != pubkey) { + throw AccountFailure.denied; + } + final value = await account.relayAssertion( + relay, + config.nsec!, + expectedSubject: session.subject, + ); + final token = value['assertion']; + final expiry = value['expires_at']; + if (token is! String || + token.isEmpty || + token.length > 16384 || + token.contains(RegExp(r'[\r\n]')) || + value['nostr_pubkey'] != pubkey || + expiry is! int) { + throw AccountFailure.invalidResponse; + } + final now = _now().millisecondsSinceEpoch ~/ 1000; + if (expiry <= now || expiry > now + 300) { + throw AccountFailure.invalidResponse; + } + final expires = DateTime.fromMillisecondsSinceEpoch( + expiry * 1000, + isUtc: true, + ); + if (_disposed) throw AccountFailure.unavailable; + if (!identical(session, account.account)) { + return; + } + _cached = ( + relay: relay, + pubkey: pubkey, + token: token, + expiry: expires.subtract( + Duration(seconds: (expiry - now) > 60 ? 30 : (expiry - now) ~/ 2), + ), + account: session, + ); + // Issuance alone cannot prove admission after a relay refusal. + if (failure != AccountFailure.denied) connected(config); + } on AccountFailure catch (error) { + if (!_disposed) { + failedRelay = config.baseUrl; + failedPubkey = pubkey; + failure = error; + notifyListeners(); + } + rethrow; + } + } + + /// Clear a scoped failure only after this community actually reconnects. + void connected(RelayConfig config) { + if (_disposed || (failedRelay != null && failedRelay != config.baseUrl)) { + return; + } + failure = null; + failedRelay = null; + failedPubkey = null; + notifyListeners(); + } + + void rejected( + RelayConfig config, + String? usedToken, { + bool terminal = false, + }) { + if (!protects(config) || usedToken == null) return; + if ('Bearer ${_cached?.token}' == usedToken) _cached = null; + if (terminal && !_disposed) { + failedRelay = config.baseUrl; + failedPubkey = pubkeyFromNsec(config.nsec); + failure = AccountFailure.denied; + notifyListeners(); + } + } + + @override + void dispose() { + _disposed = true; + _cached = null; + super.dispose(); + } +} + +/// Captures the signing context with the request. Never follows redirects with +/// credentials or replays a failed write automatically. +class HostedRelayHttpClient extends http.BaseClient { + HostedRelayHttpClient( + this.auth, + this.config, { + http.Client? inner, + this.canSend, + }) : _inner = inner ?? http.Client(); + final HostedRelayAuth auth; + final RelayConfig config; + final http.Client _inner; + final bool Function()? canSend; + + @override + Future send(http.BaseRequest request) async { + final origin = Uri.parse(config.baseUrl); + final scoped = request.url.origin == origin.origin && auth.protects(config); + if (scoped) { + request.followRedirects = false; + request.headers.addAll(await auth.headers(config)); + } + if (canSend != null && !canSend!()) { + throw StateError('Relay request expired'); + } + final response = await _inner.send(request); + if (scoped && (response.statusCode == 401 || response.statusCode == 403)) { + auth.rejected(config, request.headers['Nostr-Federated-Identity']); + } + return response; + } + + @override + void close() => _inner.close(); +} diff --git a/mobile/lib/shared/hosted/hpke_backup.dart b/mobile/lib/shared/hosted/hpke_backup.dart new file mode 100644 index 0000000000..5122160b66 --- /dev/null +++ b/mobile/lib/shared/hosted/hpke_backup.dart @@ -0,0 +1,139 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/services.dart'; +import 'package:nostr/nostr.dart' as nostr; + +/// Never include input, plaintext, or native exception details in this error. +enum HpkeBackupFailure { invalidEnrollment, identityMismatch, unavailable } + +/// Seal only enrollment obtained from the fixed-origin authenticated account +/// client. Never pass recipient material from a relay, deep link or QR payload. +/// Persist the returned envelope BEFORE upload; retry its exact backup_id and +/// ciphertext instead of enrolling/sealing again after uncertain responses. +class HpkeBackup { + static const _channel = MethodChannel('buzz/hosted_hpke'); + + Future> seal({ + required Map enrollment, + required String secretHex, + }) async { + try { + final pubkey = nostr.Keys(secretHex).public; + if (enrollment['nostr_pubkey'] != pubkey) { + throw HpkeBackupFailure.identityMismatch; + } + final aad = associatedData(enrollment); + final recipient = _decode(enrollment['recipient_public_key'] as String); + if (recipient.length != 65 || recipient.first != 4) { + throw HpkeBackupFailure.invalidEnrollment; + } + final plaintext = _hex(secretHex, 32); + final Map? sealed; + try { + sealed = await _channel.invokeMapMethod('seal', { + 'recipient': recipient, + 'plaintext': plaintext, + 'aad': aad, + }); + } finally { + plaintext.fillRange(0, plaintext.length, 0); + } + final enc = sealed?['enc']; + final ciphertext = sealed?['ciphertext']; + if (enc is! Uint8List || + enc.length != 65 || + enc.first != 4 || + ciphertext is! Uint8List || + ciphertext.length != 48) { + throw HpkeBackupFailure.unavailable; + } + return { + for (final field in [ + 'version', + 'suite', + 'recipient_key_id', + 'service_namespace', + 'owner_id', + 'backup_id', + 'nostr_pubkey', + ]) + field: enrollment[field], + 'enc': _encode(enc), + 'ciphertext': _encode(ciphertext), + }; + } on HpkeBackupFailure { + rethrow; + } on PlatformException { + throw HpkeBackupFailure.unavailable; + } on MissingPluginException { + throw HpkeBackupFailure.unavailable; + } catch (_) { + throw HpkeBackupFailure.invalidEnrollment; + } + } + + /// Exact v1 wire framing shared with desktop and the recovery service. + static Uint8List associatedData(Map enrollment) { + try { + final suite = enrollment['suite'] as Map; + if (enrollment['version'] != 1 || + suite['kem_id'] != 16 || + suite['kdf_id'] != 1 || + suite['aead_id'] != 2) { + throw HpkeBackupFailure.invalidEnrollment; + } + final bytes = BytesBuilder(copy: false) + ..add(utf8.encode('buzz/nsec-backup/aad/v1')) + ..add([1, 0, 16, 0, 1, 0, 2]); + for (final field in [ + 'recipient_key_id', + 'service_namespace', + 'owner_id', + ]) { + final value = enrollment[field] as String; + final encoded = utf8.encode(value); + if (encoded.isEmpty || + encoded.length > 255 || + value.runes.any((r) => r < 32 || (r >= 127 && r <= 159))) { + throw HpkeBackupFailure.invalidEnrollment; + } + bytes.add([0, encoded.length]); + bytes.add(encoded); + } + final id = enrollment['backup_id'] as String; + if (!RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', + ).hasMatch(id)) { + throw HpkeBackupFailure.invalidEnrollment; + } + bytes.add(_hex(id.replaceAll('-', ''), 16)); + bytes.add(_hex(enrollment['nostr_pubkey'] as String, 32)); + return bytes.toBytes(); + } on HpkeBackupFailure { + rethrow; + } catch (_) { + throw HpkeBackupFailure.invalidEnrollment; + } + } + + static Uint8List _hex(String value, int length) { + if (value.length != length * 2 || !RegExp(r'^[0-9a-f]+$').hasMatch(value)) { + throw HpkeBackupFailure.invalidEnrollment; + } + return Uint8List.fromList( + List.generate( + length, + (i) => int.parse(value.substring(i * 2, i * 2 + 2), radix: 16), + ), + ); + } + + static String _encode(List bytes) => + base64UrlEncode(bytes).replaceAll('=', ''); + static Uint8List _decode(String value) { + final decoded = base64Url.decode(base64Url.normalize(value)); + if (_encode(decoded) != value) throw HpkeBackupFailure.invalidEnrollment; + return decoded; + } +} 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..9113991ed2 --- /dev/null +++ b/mobile/lib/shared/hosted/self_hosted_entry.dart @@ -0,0 +1,177 @@ +import '../storage/serial_queue.dart'; +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 final _queue = SerialQueue(); + + /// Saved keys available for explicit reuse, including a hosted account that + /// has not joined any community yet. This performs no account network calls. + Future<({List choices, bool complete})> + choices() async { + var complete = true; + var device = []; + var accounts = []; + try { + device = await identities.choices(); + } on AccountIdentityFailure { + complete = false; + } + try { + accounts = await _accountStore.identities(); + } on AccountIdentityFailure { + complete = false; + } + return ( + choices: [ + ...device, + for (final account in accounts) + if (!device.any((item) => item.pubkey == account.pubkey)) + DeviceIdentityChoice(account.pubkey, 'Saved identity'), + ], + complete: complete, + ); + } + + Future prepare({ + required String relay, + required String name, + String? nsec, + String? savedPubkey, + bool create = false, + required bool Function() isCurrent, + }) { + final result = _queue.run(() 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 = _pendingKey(relay); + final retained = await _storage.read(key: key); + String? candidate = nsec; + if (savedPubkey != null) { + // Selection is explicit: resolve the exact key without requiring a + // complete inventory of unrelated account slots. + try { + candidate = await identities.withIdentity( + savedPubkey, + (nsec) async => nsec, + ); + } on AccountIdentityFailure { + candidate = await _accountStore.withSavedIdentity( + savedPubkey, + (nsec) async => nsec, + ); + } + } + if (retained != null) { + final retainedPubkey = AccountIdentityStore.publicKeyForNsec(retained); + if (candidate != null && + AccountIdentityStore.publicKeyForNsec(candidate) != + retainedPubkey) { + // An explicit replacement may follow a refused or uncertain probe. + // Preserve the previous key before changing the relay's retry slot. + await _accountStore.forPairing(retainedPubkey).create(retained); + check(); + } + candidate ??= retained; + } + if (candidate == null) { + if (!create) throw AccountIdentityFailure.absent; + final inventory = await choices(); + if (!inventory.complete) throw AccountIdentityFailure.unreadable; + if (inventory.choices.isNotEmpty) { + throw AccountIdentityFailure.absent; + } + candidate = nostr.Keys.generate().nsec; + } + final pubkey = AccountIdentityStore.publicKeyForNsec(candidate); + check(); + if (retained != candidate) { + 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; + }); + return result; + } + + static String _pendingKey(String relay) => + 'buzz_pending_self_hosted_${base64Url.encode(utf8.encode(relay))}'; + + /// Retire this exact pending key only after its community is durably saved. + Future complete(Community community) => _queue.run(() async { + final key = _pendingKey(community.relayUrl); + if (await _storage.read(key: key) != community.nsec) return; + await _storage.delete(key: key); + if (await _storage.read(key: key) != null) { + throw AccountIdentityFailure.unreadable; + } + }); + + /// 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/hosted/web_connect_binding.dart b/mobile/lib/shared/hosted/web_connect_binding.dart new file mode 100644 index 0000000000..ae28d5e6bd --- /dev/null +++ b/mobile/lib/shared/hosted/web_connect_binding.dart @@ -0,0 +1,93 @@ +import 'dart:convert'; + +import 'package:nostr/nostr.dart' as nostr; + +import '../deeplink/deep_link.dart'; +import 'hosted_environment.dart'; + +/// The owner supplies keys only inside this callback, after authenticating and +/// resolving identity. Neither this service nor its result retains a secret. +typedef WithWebConnectKeys = + Future Function(Future Function(nostr.Keys keys) operation); + +typedef VerifyWebConnectBinding = + Future Function({ + required String subject, + required String challengeId, + required String nonce, + required String signedPayload, + }); + +/// Binds a web challenge using the authenticated account's verification API. +/// The API must consume it for [subject] and return the verified bound pubkey. +/// A signature is never returned to a browser: shape checks alone cannot prove +/// that an incoming challenge belongs to the account signed into the app. +class WebConnectBindingService { + final DateTime Function() now; + WebConnectBindingService({DateTime Function()? now}) + : now = now ?? DateTime.now; + + Future complete({ + required NostrBindDeepLink request, + required String subject, + required String expectedPubkey, + required WithWebConnectKeys withKeys, + required VerifyWebConnectBinding verify, + bool Function()? isCurrent, + }) async { + void checkCurrent() { + if (isCurrent != null && !isCurrent()) { + throw const FormatException('Binding attempt cancelled'); + } + } + + checkCurrent(); + if (subject.isEmpty || !request.expiresAt.isAfter(now())) { + throw const FormatException('Binding request expired or unauthenticated'); + } + final signed = await withKeys<({String payload, String pubkey})>(( + keys, + ) async { + if (keys.public.toLowerCase() != expectedPubkey.toLowerCase()) { + throw const FormatException('Identity does not match'); + } + final event = nostr.Event.from( + kind: 24243, + content: '', + tags: [ + for (final field in const [ + 'challenge_id', + 'nonce', + 'verification_code', + 'audience', + 'action', + 'protocol', + 'version', + 'origin', + 'expires_at', + ]) + [field, request.fields[field]!], + ], + secretKey: keys.secret, + verify: false, + ); + // Encode through the library's canonical event representation. + final payload = event.toJson(); + if (jsonDecode(payload) is! Map) { + throw const FormatException('Invalid signed event'); + } + return (payload: payload, pubkey: keys.public); + }); + checkCurrent(); + final boundPubkey = await verify( + subject: subject, + challengeId: request.challengeId, + nonce: request.nonce, + signedPayload: signed.payload, + ); + if (boundPubkey.toLowerCase() != signed.pubkey.toLowerCase()) { + throw const FormatException('Verified identity does not match'); + } + return HostedEnvironment.origin.resolve('/buzz'); + } +} diff --git a/mobile/lib/shared/huddle/huddle_session.dart b/mobile/lib/shared/huddle/huddle_session.dart index e08d28c5d1..7ce06b81d2 100644 --- a/mobile/lib/shared/huddle/huddle_session.dart +++ b/mobile/lib/shared/huddle/huddle_session.dart @@ -6,6 +6,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../features/age_gate/age_signal_provider.dart'; import 'huddle_auth.dart'; +import '../hosted/hosted_relay_auth.dart'; +import '../relay/relay_provider.dart'; import 'huddle_media.dart'; import 'huddle_transport.dart'; import 'huddle_wire.dart'; @@ -152,10 +154,15 @@ final huddleMediaFactoryProvider = Provider( (_) => MethodChannelHuddleMedia.new, ); -final huddleTransportFactoryProvider = Provider( - (_) => - (parameters) => HuddleTransport(parameters: parameters), -); +final huddleTransportFactoryProvider = Provider((ref) { + final auth = ref.watch(hostedRelayAuthProvider); + return (parameters) => HuddleTransport( + parameters: parameters, + headers: () => auth.headers( + RelayConfig(baseUrl: parameters.relayWebSocketUrl, nsec: parameters.nsec), + ), + ); +}); final huddleReconnectDelaysProvider = Provider>( (_) => const [ diff --git a/mobile/lib/shared/huddle/huddle_transport.dart b/mobile/lib/shared/huddle/huddle_transport.dart index 87ed660d04..bfa835c132 100644 --- a/mobile/lib/shared/huddle/huddle_transport.dart +++ b/mobile/lib/shared/huddle/huddle_transport.dart @@ -6,14 +6,12 @@ import 'package:flutter/foundation.dart'; import 'package:web_socket_channel/io.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; +import '../relay/relay_websocket.dart'; import 'huddle_auth.dart'; import 'huddle_wire.dart'; typedef HuddleWebSocketFactory = WebSocketChannel Function(Uri uri); -WebSocketChannel _openHuddleWebSocket(Uri uri) => - IOWebSocketChannel.connect(uri, pingInterval: const Duration(seconds: 30)); - enum HuddleTransportPhase { idle, connecting, @@ -138,7 +136,8 @@ final class HuddleTransport implements HuddleTransportClient { final HuddleConnectionParameters parameters; final Duration connectTimeout; final Duration handshakeTimeout; - final HuddleWebSocketFactory _channelFactory; + final HuddleWebSocketFactory? _channelFactory; + final Future> Function()? headers; final StreamController _stateController = StreamController.broadcast(sync: true); @@ -167,7 +166,8 @@ final class HuddleTransport implements HuddleTransportClient { required this.parameters, this.connectTimeout = const Duration(seconds: 8), this.handshakeTimeout = const Duration(seconds: 5), - HuddleWebSocketFactory channelFactory = _openHuddleWebSocket, + HuddleWebSocketFactory? channelFactory, + this.headers, }) : _channelFactory = channelFactory; @override @@ -229,9 +229,22 @@ final class HuddleTransport implements HuddleTransportClient { ); try { - final channel = _channelFactory(parameters.audioWebSocketUri); + final deadline = Stopwatch()..start(); + final authentication = + await headers?.call().timeout(connectTimeout) ?? + const {}; + if (!_isCurrent(generation)) return; + final channel = + _channelFactory?.call(parameters.audioWebSocketUri) ?? + IOWebSocketChannel( + openRelayWebSocket( + parameters.audioWebSocketUri, + headers: authentication, + pingInterval: const Duration(seconds: 30), + ), + ); _channel = channel; - await channel.ready.timeout(connectTimeout); + await channel.ready.timeout(connectTimeout - deadline.elapsed); if (!_isCurrent(generation)) { throw const HuddleTransportError( code: HuddleTransportErrorCode.cancelled, 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..2e1930b16f --- /dev/null +++ b/mobile/lib/shared/identity_transfer/nsec_transfer.dart @@ -0,0 +1,88 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.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()); + } + + /// Reads a single small UTF-8 nsec file. Cancellation returns null. + static Future pickFile() async { + final text = await const MethodChannel( + 'buzz/identity_file', + ).invokeMethod('import'); + if (text == null) return null; + preview(text); + return text.trim(); + } + + /// 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 (defaultTargetPlatform == TargetPlatform.android) { + return await const MethodChannel('buzz/identity_file').invokeMethod( + 'export', + {'text': '${nsec.trim()}\n', 'name': 'buzz-identity.nsec'}, + ) ?? + false; + } + // iOS must apply protection when the secret is first written, not after it. + final File file; + if (defaultTargetPlatform == TargetPlatform.iOS) { + final path = await const MethodChannel( + 'buzz/identity_file', + ).invokeMethod('prepareExport', {'text': '${nsec.trim()}\n'}); + if (path == null) throw PlatformException(code: 'export_failed'); + file = File(path); + } else { + final directory = await (await getTemporaryDirectory()).createTemp( + 'buzz-identity-', + ); + file = File('${directory.path}/buzz-identity.nsec'); + } + try { + if (defaultTargetPlatform != TargetPlatform.iOS) { + 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 file.parent.delete(recursive: true); + } + } +} diff --git a/mobile/lib/shared/push/push_lease_revocation_outbox.dart b/mobile/lib/shared/push/push_lease_revocation_outbox.dart index 4dcff817be..ba15f58421 100644 --- a/mobile/lib/shared/push/push_lease_revocation_outbox.dart +++ b/mobile/lib/shared/push/push_lease_revocation_outbox.dart @@ -4,9 +4,12 @@ import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; +import '../storage/secure_storage.dart'; + import '../community/community.dart'; import '../relay/signed_event_relay.dart'; import 'dev_push_lease.dart'; @@ -166,7 +169,7 @@ class BuzzPushLeaseRevocationStorage { final FlutterSecureStorage _secure; BuzzPushLeaseRevocationStorage({FlutterSecureStorage? secure}) - : _secure = secure ?? const FlutterSecureStorage(); + : _secure = secure ?? communitySecureStorage; Future> loadAll() async { final raw = await _secure.read(key: _key); diff --git a/mobile/lib/shared/relay/media_image.dart b/mobile/lib/shared/relay/media_image.dart index d6d43e3ff4..857fbcd663 100644 --- a/mobile/lib/shared/relay/media_image.dart +++ b/mobile/lib/shared/relay/media_image.dart @@ -6,13 +6,14 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; +import '../hosted/hosted_relay_auth.dart'; import 'media_auth.dart'; /// Shared, keep-alive HTTP client for media fetches. One client per container /// so TLS connections are reused across images instead of re-handshaking per /// fetch. Override in tests to stub the network. final mediaHttpClientProvider = Provider((ref) { - final client = http.Client(); + final client = ref.watch(hostedHttpClientFactoryProvider)(); ref.onDispose(client.close); return client; }); diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index 21f5de4df2..f94b476172 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -16,6 +16,7 @@ import 'animated_image_sanitizer.dart'; import 'media_auth.dart'; import 'mp4_fast_start.dart'; import 'relay_provider.dart'; +import '../hosted/hosted_relay_auth.dart'; part 'media_upload/platform_bindings.dart'; part 'media_upload/helpers.dart'; diff --git a/mobile/lib/shared/relay/media_upload/platform_bindings.dart b/mobile/lib/shared/relay/media_upload/platform_bindings.dart index 527871f11f..2f8d0c7ad1 100644 --- a/mobile/lib/shared/relay/media_upload/platform_bindings.dart +++ b/mobile/lib/shared/relay/media_upload/platform_bindings.dart @@ -13,8 +13,11 @@ Future requiresLegacyMediaStoragePermission() async { final mediaUploadServiceProvider = Provider((ref) { final config = ref.watch(relayConfigProvider); + final client = ref.watch(hostedHttpClientFactoryProvider)(); + ref.onDispose(client.close); final picker = ImagePicker(); final service = MediaUploadService( + httpClient: client, baseUrl: config.baseUrl, nsec: config.nsec, pickGalleryImage: () => picker.pickImage( diff --git a/mobile/lib/shared/relay/relay_http_query_client.dart b/mobile/lib/shared/relay/relay_http_query_client.dart index 615b28f64c..71f9bc1fd9 100644 --- a/mobile/lib/shared/relay/relay_http_query_client.dart +++ b/mobile/lib/shared/relay/relay_http_query_client.dart @@ -20,14 +20,23 @@ class RelayHttpQueryClient { required Map headers, required List body, required Duration timeout, + http.Client Function(http.Client, bool Function())? decorate, }) async { final generation = _injectedClient == null ? (_currentGeneration ??= _createGeneration()) : null; generation?.acquire(); + var active = true; try { - return await (_injectedClient ?? generation!.client) - .post(url, headers: headers, body: body) + final request = http.Request('POST', url) + ..followRedirects = false + ..headers.addAll(headers) + ..bodyBytes = body; + final transport = _injectedClient ?? generation!.client; + final client = decorate?.call(transport, () => active) ?? transport; + return await client + .send(request) + .then(http.Response.fromStream) .timeout(timeout); } on TimeoutException { if (identical(_currentGeneration, generation)) { @@ -36,6 +45,7 @@ class RelayHttpQueryClient { generation?.retire(); rethrow; } finally { + active = false; generation?.release(); } } diff --git a/mobile/lib/shared/relay/relay_provider.dart b/mobile/lib/shared/relay/relay_provider.dart index 7e3f0d00a2..c60f57bb29 100644 --- a/mobile/lib/shared/relay/relay_provider.dart +++ b/mobile/lib/shared/relay/relay_provider.dart @@ -1,6 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; +import '../hosted/hosted_relay_auth.dart'; import '../community/community_provider.dart'; import 'relay_client.dart'; @@ -131,7 +132,10 @@ final myPubkeyProvider = Provider((ref) { /// through the relay WebSocket session. final relayClientProvider = Provider((ref) { final config = ref.watch(relayConfigProvider); - final client = RelayClient(baseUrl: config.baseUrl); + final client = RelayClient( + baseUrl: config.baseUrl, + httpClient: ref.watch(hostedHttpClientFactoryProvider)(), + ); ref.onDispose(client.dispose); return client; }); diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index 70b175fad7..18e03bb2b7 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -12,6 +12,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../features/age_gate/age_signal_provider.dart'; import '../auth/auth.dart'; +import '../hosted/hosted_relay_auth.dart'; +import '../hosted/builderlab_account.dart'; import 'nostr_filters.dart'; import 'nostr_models.dart'; import 'relay_client.dart'; @@ -248,6 +250,13 @@ class RelaySessionNotifier extends Notifier { }, body: bodyBytes, timeout: timeout, + decorate: (client, active) => HostedRelayHttpClient( + ref.read(hostedRelayAuthProvider), + config, + inner: client, + canSend: () => + active() && !_disposed && generation == _contextGeneration, + ), ); if (_disposed || _ageRestricted || generation != _contextGeneration) { throw StateError('Relay query belongs to a retired session'); @@ -549,7 +558,7 @@ class RelaySessionNotifier extends Notifier { _connect(config); } - Future _connect(RelayConfig config) async { + Future _connect(RelayConfig config, {bool renewed = false}) async { if (_disposed || _ageRestricted) return; final generation = ++_connectionGeneration; @@ -561,23 +570,48 @@ class RelaySessionNotifier extends Notifier { ); _socket?.dispose(); - final socket = _socketFactory( + late final RelaySocket socket; + socket = _socketFactory( wsUrl: config.wsUrl, nsec: config.nsec, onMessage: (message) { if (generation == _connectionGeneration) _handleMessage(message); }, onConnected: () => _handleConnected(generation), - onDisconnected: (error) => _handleDisconnected(generation, error), + onDisconnected: (error) { + if (_disposed || generation != _connectionGeneration) return; + final auth = ref.read(hostedRelayAuthProvider); + if (auth.protects(config) && relayUpgradeRefused(error)) { + auth.rejected( + config, + socket.headers['Nostr-Federated-Identity'], + terminal: renewed, + ); + if (!renewed) { + unawaited(_connect(config, renewed: true)); + return; + } + error = AccountFailure.denied; + } + _handleDisconnected(generation, error); + }, ); _socket = socket; - await socket.connect(); + try { + final auth = ref.read(hostedRelayAuthProvider); + if (auth.protects(config)) socket.headers = await auth.headers(config); + if (_disposed || generation != _connectionGeneration) return; + await socket.connect(); + } on AccountFailure catch (error) { + _handleDisconnected(generation, error); + } } Future _handleConnected(int generation) async { if (_disposed || generation != _connectionGeneration) return; _socketConnected = true; + ref.read(hostedRelayAuthProvider).connected(ref.read(relayConfigProvider)); _hasConnectedOnce = true; _reconnectDelayMs = _baseReconnectDelayMs; state = const SessionState(status: SessionStatus.connected); @@ -593,7 +627,8 @@ class RelaySessionNotifier extends Notifier { _eventBuffer.clear(); _flushTimer?.cancel(); _flushTimer = null; - if (error is RelayAuthRejectedException) { + if (error is RelayAuthRejectedException || + (error is AccountFailure && error != AccountFailure.unavailable)) { _reconnectTimer?.cancel(); state = const SessionState(status: SessionStatus.disconnected); return; diff --git a/mobile/lib/shared/relay/relay_socket.dart b/mobile/lib/shared/relay/relay_socket.dart index 5b23279e81..858f17e8e1 100644 --- a/mobile/lib/shared/relay/relay_socket.dart +++ b/mobile/lib/shared/relay/relay_socket.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'dart:async'; import 'dart:convert'; @@ -7,6 +8,7 @@ import 'package:web_socket_channel/io.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import 'nostr_models.dart'; +import 'relay_websocket.dart'; /// Low-level websocket connection with NIP-42 authentication. /// @@ -52,6 +54,9 @@ class RelaySocket { SocketState get state => _state; + /// Scoped assertion supplied by the connection owner before connecting. + Map headers = const {}; + RelaySocket({ required String wsUrl, required String? nsec, @@ -70,9 +75,12 @@ class RelaySocket { _state = SocketState.connecting; try { - _channel = IOWebSocketChannel.connect( - Uri.parse(_wsUrl), - pingInterval: debugPingInterval, + _channel = IOWebSocketChannel( + openRelayWebSocket( + Uri.parse(_wsUrl), + headers: headers, + pingInterval: debugPingInterval, + ), ); await _channel!.ready; } catch (e) { @@ -259,3 +267,10 @@ class RelaySocket { _onMessage(data); } } + +/// HTTP upgrade failures retain their status through WebSocketChannel. +bool relayUpgradeRefused(Object? error) { + if (error is WebSocketChannelException) error = error.inner; + return error is WebSocketException && + (error.httpStatusCode == 401 || error.httpStatusCode == 403); +} diff --git a/mobile/lib/shared/relay/relay_websocket.dart b/mobile/lib/shared/relay/relay_websocket.dart new file mode 100644 index 0000000000..a91f19129a --- /dev/null +++ b/mobile/lib/shared/relay/relay_websocket.dart @@ -0,0 +1,53 @@ +import 'dart:async'; +import 'dart:io'; + +/// WebSocket's default HTTP client follows redirects, including custom headers. +/// Keep key-bound assertions at their original origin and bound upgrade time. +Future openRelayWebSocket( + Uri uri, { + required Map headers, + required Duration pingInterval, +}) async { + if (headers.isEmpty) { + return WebSocket.connect( + uri.toString(), + ).then((socket) => socket..pingInterval = pingInterval); + } + final client = _UpgradeClient(); + try { + return await WebSocket.connect( + uri.toString(), + headers: headers, + customClient: client, + ) + .then((socket) => socket..pingInterval = pingInterval) + .timeout( + const Duration(seconds: 10), + onTimeout: () { + client.close(force: true); + throw TimeoutException('Relay connection timed out'); + }, + ); + } finally { + client.close(); + } +} + +/// WebSocket.connect uses only openUrl on its supplied HTTP client. Keep this +/// adapter limited to upgrades instead of exposing a second general transport. +class _UpgradeClient implements HttpClient { + final _inner = HttpClient(); + + @override + Future openUrl(String method, Uri url) async { + final request = await _inner.openUrl(method, url); + request.followRedirects = false; + return request; + } + + @override + void close({bool force = false}) => _inner.close(force: force); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/mobile/lib/shared/security/sensitive_action_authorizer.dart b/mobile/lib/shared/security/sensitive_action_authorizer.dart index cc3b96e6ad..b9f92ccf4a 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/lib/shared/storage/secure_storage.dart b/mobile/lib/shared/storage/secure_storage.dart new file mode 100644 index 0000000000..6d8f72b380 --- /dev/null +++ b/mobile/lib/shared/storage/secure_storage.dart @@ -0,0 +1,18 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Android caches the first configuration per namespace. Every production +/// client must use these options: a failed read must never delete a key. +const secureStorageAndroidOptions = AndroidOptions(resetOnError: false); + +/// Preserve the existing community-storage namespace and iOS accessibility. +const communitySecureStorage = FlutterSecureStorage( + aOptions: secureStorageAndroidOptions, +); + +/// Account credentials and identities remain device-only on iOS. +const accountSecureStorage = FlutterSecureStorage( + aOptions: secureStorageAndroidOptions, + iOptions: IOSOptions( + accessibility: KeychainAccessibility.first_unlock_this_device, + ), +); diff --git a/mobile/lib/shared/storage/serial_queue.dart b/mobile/lib/shared/storage/serial_queue.dart new file mode 100644 index 0000000000..270b9f5d23 --- /dev/null +++ b/mobile/lib/shared/storage/serial_queue.dart @@ -0,0 +1,10 @@ +/// Serializes operations without letting a failed operation poison later work. +class SerialQueue { + Future _tail = Future.value(); + + Future run(Future Function() operation) { + final result = _tail.then((_) => operation()); + _tail = result.then((_) {}, onError: (Object _, StackTrace _) {}); + return result; + } +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 7333019aad..2bfd5e1656 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -290,7 +290,7 @@ packages: source: hosted version: "0.3.5+2" crypto: - dependency: "direct dev" + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index cf4ac1e9aa..133a0fcf4e 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: hooks_riverpod: ^3.0.3 flutter_hooks: ^0.21.3 http: ^1.4.0 + crypto: ^3.0.7 flutter_secure_storage: ^10.0.0 flutter_svg: ^2.2.4 mobile_scanner: ^7.0.0 @@ -57,7 +58,6 @@ dev_dependencies: flutter_lints: ^6.0.0 fake_async: ^1.3.3 camera_platform_interface: ^2.13.1 - crypto: ^3.0.7 custom_lint: ^0.8.0 riverpod_lint: ^3.1.0 mocktail: ^1.0.4 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 5a378fe656..6e0076573c 100644 --- a/mobile/test/features/age_gate/age_gate_app_test.dart +++ b/mobile/test/features/age_gate/age_gate_app_test.dart @@ -513,7 +513,12 @@ class _RecordingPairAuthNotifier extends _UnauthenticatedAuthNotifier { int imports = 0; @override - Future authenticateWithCommunity(Community community) async { + Future authenticateWithCommunity( + Community community, { + bool Function()? shouldContinue, + bool activate = true, + }) async { + if (shouldContinue != null && !shouldContinue()) return; imports += 1; } } diff --git a/mobile/test/features/channels/deep_link_dispatcher_test.dart b/mobile/test/features/channels/deep_link_dispatcher_test.dart index 6a581b24a6..72c9abda5e 100644 --- a/mobile/test/features/channels/deep_link_dispatcher_test.dart +++ b/mobile/test/features/channels/deep_link_dispatcher_test.dart @@ -15,6 +15,64 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/community/community_storage_test.dart'; void main() { + for (final outcome in ['handled', 'declined', 'failed']) { + testWidgets('hosted handler drains queued links after $outcome setup', ( + tester, + ) async { + final BuzzDeepLink first = outcome == 'declined' + ? const AddCommunityDeepLink( + relayUrl: 'wss://one.communities.buzz.xyz', + name: 'One', + ) + : const InviteDeepLink( + relayUrl: 'wss://one.communities.buzz.xyz', + code: 'same', + ); + final duplicate = outcome == 'declined' + ? first + : InviteDeepLink( + relayUrl: 'wss://one.communities.buzz.xyz', + code: 'same', + ); + const second = InviteDeepLink( + relayUrl: 'wss://two.communities.buzz.xyz', + code: 'invite', + ); + final pending = _QueuedPendingDeepLinkNotifier([ + first, + duplicate, + second, + ]); + final handled = []; + await tester.pumpWidget( + ProviderScope( + overrides: [ + pendingDeepLinkProvider.overrideWith(() => pending), + inviteKeyGeneratorProvider.overrideWithValue( + () => throw StateError('must not generate'), + ), + ], + child: MaterialApp( + home: DeepLinkDispatcher( + dispatchMessageLinks: false, + onboardingHandler: (_, link) async { + handled.add(link); + if (link == first) { + if (outcome == 'declined') return false; + if (outcome == 'failed') throw StateError('setup failed'); + } + return true; + }, + child: const Scaffold(body: SizedBox()), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(handled, [first, duplicate, second]); + expect(pending.consumeCalls, 3); + }); + } testWidgets('queues deep links in arrival order until acknowledged', ( tester, ) async { diff --git a/mobile/test/features/invites/invite_join_provider_test.dart b/mobile/test/features/invites/invite_join_provider_test.dart index 83f7dc70c9..cd350b673e 100644 --- a/mobile/test/features/invites/invite_join_provider_test.dart +++ b/mobile/test/features/invites/invite_join_provider_test.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/hosted/hosted_environment.dart'; import 'dart:async'; import 'dart:convert'; @@ -17,6 +18,47 @@ import 'package:buzz/shared/deeplink/deep_link.dart'; import '../../shared/community/community_storage_test.dart'; void main() { + test( + 'legacy invite entry refuses staging before key generation or claim', + () async { + final storage = CommunityStorage(secure: FakeSecureStorage()); + var generated = 0; + var requests = 0; + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + inviteKeyGeneratorProvider.overrideWithValue(() { + generated++; + return nostr.Keys.generate(); + }), + inviteJoinHttpClientProvider.overrideWithValue( + http_testing.MockClient((_) async { + requests++; + return http.Response('{}', 500); + }), + ), + ], + ); + addTearDown(container.dispose); + await container.read(communityListProvider.future); + final notifier = container.read(inviteJoinProvider.notifier); + await notifier.prepare( + const InviteDeepLink( + relayUrl: 'wss://team.communities.public.builderlabstaging.xyz', + code: 'fixture', + ), + ); + await notifier.confirmJoin(); + expect(container.read(inviteJoinProvider).status, InviteJoinStatus.error); + expect(generated, 0); + expect(requests, 0); + expect(await storage.loadAll(), isEmpty); + }, + skip: !HostedEnvironment.requiresAuth( + 'team.communities.public.builderlabstaging.xyz', + ), + ); + for (final existingRelayUrl in [ 'wss://relay.example.com', 'https://relay.example.com', @@ -749,7 +791,12 @@ class _RecordingAuthNotifier extends AuthNotifier { const AuthState(status: AuthStatus.unauthenticated); @override - Future authenticateWithCommunity(Community community) async { + Future authenticateWithCommunity( + Community community, { + bool Function()? shouldContinue, + bool activate = true, + }) async { + if (shouldContinue != null && !shouldContinue()) return; final storage = ref.read(communityStorageProvider); await storage.save(community); await storage.saveActiveId(community.id); diff --git a/mobile/test/features/onboarding/community_link_input_test.dart b/mobile/test/features/onboarding/community_link_input_test.dart new file mode 100644 index 0000000000..12d8c1f2df --- /dev/null +++ b/mobile/test/features/onboarding/community_link_input_test.dart @@ -0,0 +1,45 @@ +import 'package:buzz/shared/deeplink/community_link_input.dart'; +import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('normalizes addresses through the existing community parser', () { + for (final value in [ + ' team.communities.buzz.xyz ', + 'https://team.communities.buzz.xyz/', + 'wss://team.communities.buzz.xyz', + ]) { + final uri = parseCommunityLinkInput(value)!; + final link = parseBuzzDeepLink(uri) as AddCommunityDeepLink; + expect(link.relayUrl, 'wss://team.communities.buzz.xyz'); + } + }); + + test('preserves the invite and its destination', () { + final uri = parseCommunityLinkInput( + 'https://community.example.com/invite/abc123', + )!; + final link = parseBuzzDeepLink(uri) as InviteDeepLink; + expect(link.code, 'abc123'); + expect(link.relayUrl, 'wss://community.example.com'); + }); + + test('rejects unrelated actions and unsafe or incomplete addresses', () { + for (final value in [ + '', + 'not a link', + 'http://community.example.com', + 'https://user:secret@community.example.com', + 'https://community.example.com/other', + 'https://community.example.com/invite/', + 'https://community.example.com#secret', + 'https://community.example.com?token=secret', + 'https://127.0.0.1', + 'https://community.example.com.', + 'buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32', + 'nostrpair://example', + ]) { + expect(parseCommunityLinkInput(value), isNull, reason: value); + } + }); +} diff --git a/mobile/test/features/onboarding/hosted_app_gate_environment_test.dart b/mobile/test/features/onboarding/hosted_app_gate_environment_test.dart new file mode 100644 index 0000000000..e12848af4b --- /dev/null +++ b/mobile/test/features/onboarding/hosted_app_gate_environment_test.dart @@ -0,0 +1,150 @@ +import 'package:buzz/features/channels/deep_link_dispatcher.dart'; +import 'package:buzz/features/onboarding/self_hosted_entry_sheet.dart'; +import 'package:buzz/hosted_app_gate.dart'; +import 'package:buzz/shared/hosted/hosted_flow_provider.dart'; +import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart'; +import 'package:buzz/shared/hosted/account_identity_store.dart'; +import 'package:buzz/shared/hosted/browser_auth.dart'; +import 'package:buzz/shared/hosted/builderlab_account.dart'; +import 'package:buzz/shared/hosted/hosted_environment.dart'; +import 'package:buzz/shared/hosted/hosted_flow.dart'; +import 'package:buzz/shared/hosted/hosted_identity_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class _Flow extends HostedFlow { + _Flow(BuilderlabAccountClient account, AccountIdentityStore store) + : super( + account: account, + store: store, + browser: BrowserAuth(), + identity: HostedIdentityService(account: account, store: store), + enterCommunity: (_, _, _, _) async {}, + ); + BuzzDeepLink? started; + @override + Future initialize() async {} + @override + Future start(BuzzDeepLink target, {bool resumePairing = false}) async { + started = target; + } +} + +void main() { + testWidgets('app dispatch keeps staging invites out of legacy generation', ( + tester, + ) async { + FlutterSecureStorage.setMockInitialValues({}); + PendingDeepLinkNotifier.debugUriStreamOverride = const Stream.empty(); + final account = BuilderlabAccountClient(); + final flow = _Flow(account, AccountIdentityStore()); + addTearDown(() async { + PendingDeepLinkNotifier.debugUriStreamOverride = null; + flow.dispose(); + await flow.browser.dispose(); + account.dispose(); + }); + await tester.pumpWidget( + ProviderScope( + overrides: [hostedFlowProvider.overrideWithValue(flow)], + child: const MaterialApp( + home: HostedAppGate( + authenticated: false, + child: Scaffold(body: Text('entry')), + ), + ), + ), + ); + await tester.pump(); + final dispatcher = tester.widget( + find.byType(DeepLinkDispatcher), + ); + final context = tester.element(find.byType(DeepLinkDispatcher)); + const staging = InviteDeepLink( + relayUrl: 'wss://team.communities.public.builderlabstaging.xyz', + code: 'fixture', + ); + final pending = dispatcher.onboardingHandler!(context, staging); + await tester.pumpAndSettle(); + if (!HostedEnvironment.requiresAuth(Uri.parse(staging.relayUrl).host)) { + expect(await pending, isFalse); + expect(flow.started, isNull); + if (!HostedEnvironment.enabled) { + final container = ProviderScope.containerOf(context); + final queue = container.read(pendingDeepLinkProvider.notifier); + final bind = Uri( + scheme: 'buzz', + host: 'nostr-bind', + queryParameters: { + 'challenge_id': '12345678-1234-1234-1234-123456789012', + 'nonce': 'a' * 43, + 'verification_code': '123456', + 'audience': 'buzz:nostr-identity', + 'action': 'bind_nostr_identity', + 'protocol': 'buzz-nostr-identity', + 'version': '1', + 'origin': HostedEnvironment.origin.toString(), + 'expires_at': '2099-01-01T00:00:00Z', + 'return': 'browser_fragment_v1', + 'callback_url': HostedEnvironment.origin + .resolve('/buzz') + .toString(), + }, + ); + expect(parseBuzzDeepLink(bind), isA()); + queue.open(bind); + queue.open( + Uri.parse( + 'buzz://add-community?relay=wss%3A%2F%2Fcommunity.example.org&name=Community', + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(SelfHostedEntrySheet), findsOneWidget); + expect( + container.read(pendingDeepLinkProvider), + isA(), + ); + Navigator.of(tester.element(find.byType(SelfHostedEntrySheet))).pop(); + await tester.pumpAndSettle(); + expect(container.read(pendingDeepLinkProvider), isNull); + expect(flow.started, isNull); + } + return; + } + if (HostedEnvironment.origin.host == 'app.builderlabstaging.xyz') { + expect(await pending, isTrue); + expect(flow.started, staging); + } else { + expect(find.text('Community setup unavailable'), findsOneWidget); + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + expect(await pending, isTrue); + expect(flow.started, isNull); + } + final dotted = dispatcher.onboardingHandler!( + context, + const InviteDeepLink( + relayUrl: 'wss://team.communities.public.builderlabstaging.xyz.', + code: 'fixture', + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Community setup unavailable'), findsOneWidget); + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + expect(await dotted, isTrue); + expect( + await dispatcher.onboardingHandler!( + context, + const InviteDeepLink( + relayUrl: 'wss://community.example.org', + code: 'fixture', + ), + ), + isFalse, + ); + }); +} diff --git a/mobile/test/features/onboarding/hosted_communities_test.dart b/mobile/test/features/onboarding/hosted_communities_test.dart new file mode 100644 index 0000000000..35f87c8f16 --- /dev/null +++ b/mobile/test/features/onboarding/hosted_communities_test.dart @@ -0,0 +1,95 @@ +import 'package:buzz/shared/hosted/hosted_environment.dart'; +import 'package:buzz/features/onboarding/hosted_connect_page.dart'; +import 'package:buzz/hosted_app_gate.dart'; +import 'package:buzz/shared/hosted/hosted_flow_provider.dart'; +import 'package:buzz/onboarding_app_entry.dart'; +import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart'; +import 'package:buzz/shared/hosted/hosted_flow.dart'; +import 'package:buzz/shared/hosted/builderlab_account.dart'; +import 'package:buzz/shared/hosted/hosted_community_connection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/hosted_communities_fixture.dart'; +import '../../helpers/widget_helpers.dart'; + +void main() { + testWidgets( + 'cancel sign-in and resolve batch conflicts without replacing saved identities', + (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({}); + PendingDeepLinkNotifier.debugUriStreamOverride = + const Stream.empty(); + final f = HostedCommunitiesFixture(); + await f.initialize(widget: true); + f.rows = [f.row('one'), f.row('two')]; + addTearDown(() async { + PendingDeepLinkNotifier.debugUriStreamOverride = null; + await f.dispose(); + }); + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + child: const HostedAppGate( + authenticated: false, + child: OnboardingAppEntry(), + ), + overrides: [hostedFlowProvider.overrideWithValue(f.flow)], + ), + ); + await tester.pumpAndSettle(); + f.expired = true; + await tester.tap(find.text('Continue with Builderlab')); + await tester.pump(); + await tester.pumpAndSettle(); + expect(f.flow.stage, HostedFlowStage.signIn); + expect(f.browser.startedIntent, {'type': 'account'}); + expect(find.byType(HostedConnectPage), findsNothing); + expect(find.text('Cancel sign-in').hitTestable(), findsOneWidget); + expect(find.text('Enter a community link'), findsOneWidget); + f.expired = false; + await tester.tap(find.text('Cancel sign-in').hitTestable()); + await tester.pumpAndSettle(); + expect(f.flow.stage, HostedFlowStage.idle); + await tester.pumpAndSettle(); + expect(find.text('Continue with Builderlab'), findsOneWidget); + expect(find.byType(HostedConnectPage), findsNothing); + await tester.tap(find.text('Continue with Builderlab')); + await tester.pumpAndSettle(); + expect(find.byType(CheckboxListTile), findsNothing); + expect(f.admitted, ['one', 'two']); + expect(find.byType(HostedConnectPage), findsNothing); + f.admitted.clear(); + f.beforeAdmission = (name) async { + if (name == 'two') throw const SavedCommunityIdentityConflict(); + }; + await tester.tap(find.text('Continue with Builderlab')); + await tester.pumpAndSettle(); + expect(find.text('Keep saved identity and skip'), findsOneWidget); + expect(find.text('1 community added and kept.'), findsOneWidget); + await tester.tap(find.text('Keep saved identity and skip')); + await tester.pumpAndSettle(); + expect(f.admitted, ['one']); + expect(find.byType(HostedConnectPage), findsNothing); + f.admitted.clear(); + f.beforeAdmission = (name) async { + if (name == 'two') throw AccountFailure.rejected; + }; + await tester.tap(find.text('Continue with Builderlab')); + await tester.pumpAndSettle(); + expect(find.text('1 community added and kept.'), findsOneWidget); + expect(find.text('Try again').hitTestable(), findsNothing); + await tester.tap(find.text('Back to choices')); + await tester.pumpAndSettle(); + expect(find.byType(CheckboxListTile), findsOneWidget); + expect(find.text('two'), findsOneWidget); + } + }, + skip: HostedEnvironment.authDomains.isEmpty, + ); +} diff --git a/mobile/test/features/onboarding/hosted_journeys_test.dart b/mobile/test/features/onboarding/hosted_journeys_test.dart new file mode 100644 index 0000000000..c65c2c84dc --- /dev/null +++ b/mobile/test/features/onboarding/hosted_journeys_test.dart @@ -0,0 +1,783 @@ +import 'package:buzz/shared/hosted/hosted_environment.dart'; +import 'package:buzz/features/pairing/pairing_provider.dart'; +import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart'; +import 'dart:async'; +import 'dart:convert'; + +import 'package:buzz/features/onboarding/hosted_profile_setup.dart'; +import 'package:buzz/onboarding_app_entry.dart'; +import 'package:buzz/shared/auth/auth.dart'; +import 'package:buzz/shared/hosted/builderlab_account.dart'; +import 'package:buzz/shared/hosted/account_identity_store.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:buzz/shared/hosted/hosted_relay_auth.dart'; +import 'package:buzz/shared/relay/relay_session.dart'; +import 'package:buzz/shared/relay/relay_provider.dart'; +import 'package:buzz/shared/relay/media_image.dart'; +import 'package:buzz/shared/utils/string_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; + +import '../../helpers/hosted_journey_fixture.dart'; + +void main() { + void journey( + String name, + Future Function(WidgetTester, HostedJourneyFixture) run, { + bool requiresAuthDomain = false, + bool requiresBuilderlab = true, + }) { + testWidgets( + name, + (tester) async { + final fixture = HostedJourneyFixture(); + // Keep asynchronous storage/HTTP owners in one real zone across tests; + // their serialized tails otherwise outlive the widget fake clock. The + // fixture pumps bounded frames with event-loop yields, not pumpAndSettle. + await tester.runAsync( + () => http.runWithClient(() async { + try { + await run(tester, fixture); + } finally { + await fixture.dispose(tester); + } + }, fixture.client), + ); + }, + skip: + (requiresBuilderlab && HostedEnvironment.authDomains.isEmpty) || + (requiresAuthDomain && + !HostedEnvironment.requiresAuth('one.communities.buzz.xyz')), + ); + } + + journey( + 'QR credentials enter without an account when domain is not required', + (tester, f) async { + if (HostedEnvironment.requiresAuth('paired.communities.buzz.xyz')) return; + // Seed a previous account session: an opt-out build must not restore it. + await f.initialize(localIdentity: false); + f.requests.clear(); + await f.mount( + tester, + pairingValidator: ({required relayUrl, required nsec}) async {}, + ); + if (HostedEnvironment.authDomains.isEmpty) { + expect(f.activeFlow, isNull); + expect(f.requests, isEmpty); + expect(find.text('Connect to Builderlab'), findsNothing); + expect(find.text('Continue with Builderlab'), findsNothing); + expect(find.text('Create a community'), findsNothing); + expect(find.text('Enter a community link'), findsOneWidget); + expect(find.text('Scan a QR code'), findsOneWidget); + expect(find.text('Use pairing code'), findsOneWidget); + await f.tap(tester, 'Enter a community link'); + expect( + find.byKey(const Key('community-link-input')).hitTestable(), + findsOneWidget, + ); + expect(f.activeFlow, isNull); + await f.tap(tester, 'Back'); + expect(f.requests, isEmpty); + } + final notifier = f.container.read(pairingProvider.notifier); + await notifier.pair( + base64Url.encode( + utf8.encode( + jsonEncode({ + 'relayUrl': 'https://paired.communities.buzz.xyz', + 'nsec': f.keys.nsec, + 'pubkey': f.keys.public, + }), + ), + ), + ); + await f.settle(tester); + final saved = (await f.storage.loadAll()).single; + expect(saved.nsec, f.keys.nsec); + expect(saved.pubkey, f.keys.public); + expect(f.sent('/exchange'), isEmpty); + expect(f.sent('/current'), isEmpty); + expect(f.sent('/nsec-backups/status'), isEmpty); + expect(find.text('Sign in to Builderlab to reconnect'), findsNothing); + }, + requiresBuilderlab: false, + ); + + journey( + 'saved community signs in again without changing identity or onboarding', + (tester, f) async { + f.relayAuthEnabled = true; + await f.initialize(signedIn: false); + final community = Community.create( + name: 'One', + relayUrl: 'wss://one.communities.buzz.xyz', + pubkey: f.keys.public, + nsec: f.keys.nsec, + ); + await f.storage.save(community); + await f.storage.saveActiveId(community.id); + await f.mount(tester, openEntry: false); + // The real signed-in app watches its session; the fixture's home is inert. + final sessionSubscription = f.container.listen( + relaySessionProvider, + (_, _) {}, + ); + addTearDown(sessionSubscription.close); + final session = f.container.read(relaySessionProvider.notifier); + await f.settle(tester); + if (!HostedEnvironment.requiresAuth(Uri.parse(community.relayUrl).host)) { + expect(find.text('Sign in to Builderlab to reconnect'), findsNothing); + expect(f.socketHeaders, isNotEmpty); + expect(f.socketHeaders, everyElement(isEmpty)); + expect(f.sent('/assertions'), isEmpty); + expect(f.container.read(relayConfigProvider).nsec, community.nsec); + return; + } + expect(find.text('Sign in to Builderlab to reconnect'), findsOneWidget); + expect(f.socketHeaders, isEmpty); + + final browser = Completer(); + f.beforeBrowserReturn = () => browser.future; + await f.tap(tester, 'Sign in'); + await f.tap(tester, 'Cancel sign-in'); + browser.complete(); + await f.settle(tester); + expect(find.text('Sign in to Builderlab to reconnect'), findsOneWidget); + expect(f.socketHeaders, isEmpty); + + f.beforeBrowserReturn = null; + await f.tap(tester, 'Sign in'); + await f.settle(tester); + expect(f.socketHeaders, isNotEmpty); + expect(find.text('Sign in to Builderlab to reconnect'), findsNothing); + await session.queryRelay(const []); + expect(f.sent('/query'), hasLength(1)); + final proofRequest = f.sent('/identity/assertions').single; + expect( + proofRequest.headers['X-BB-Session-Credential'], + 'synthetic-session', + ); + final proof = + jsonDecode( + utf8.decode( + base64Decode( + proofRequest.headers['Authorization']!.substring(6), + ), + ), + ) + as Map; + expect(proof['pubkey'], f.keys.public); + expect( + proof['tags'], + contains(equals(['u', proofRequest.url.toString()])), + ); + + // Production media transport attaches only to its captured community, + // refuses redirects, and renews an expired assertion without browser UI. + final media = f.container.read(mediaHttpClientProvider); + for (final origin in [ + 'https://one.communities.buzz.xyz', + 'https://outside.example', + ]) { + await media.get(Uri.parse('$origin/auth-transport')); + final request = f.requests.last; + expect( + request.headers['Nostr-Federated-Identity'], + origin.contains('communities.buzz.xyz') + ? 'Bearer synthetic-assertion' + : isNull, + ); + if (origin.contains('communities.buzz.xyz')) { + expect(request.followRedirects, isFalse); + } + } + final logins = f.browserStarts; + final issued = f.sent('/identity/assertions').length; + f.assertionNow = f.assertionNow.add(const Duration(minutes: 6)); + await session.queryRelay(const []); + expect(f.sent('/identity/assertions'), hasLength(issued + 1)); + expect(f.browserStarts, logins); + + f.expiredSession = true; + f.assertionNow = f.assertionNow.add(const Duration(minutes: 6)); + await expectLater( + session.queryRelay(const []), + throwsA(AccountFailure.signedOut), + ); + await f.settle(tester); + expect(find.text('Sign in to Builderlab to reconnect'), findsOneWidget); + final beforeWrongAccount = f.sent('/identity/assertions').length; + f.bound = f.secondKeys.public; + f.subject = 'wrong-owner'; + await f.tap(tester, 'Sign in'); + await f.settle(tester); + expect( + find.text( + 'This account cannot reconnect to this community with its saved key.', + ), + findsOneWidget, + ); + expect(f.sent('/identity/assertions'), hasLength(beforeWrongAccount)); + // An empty different account cannot claim the saved key through reconnect. + f.bound = null; + await f.tap(tester, 'Use a different account'); + await f.settle(tester); + expect( + find.text( + 'This account cannot reconnect to this community with its saved key.', + ), + findsOneWidget, + ); + expect(f.sent('/identity/assertions'), hasLength(beforeWrongAccount)); + expect(f.bound, isNull); + expect(await f.store.read(), isNull); + expect((await f.storage.loadAll()).single.nsec, community.nsec); + f.subject = 'journey-owner'; + f.bound = f.keys.public; + await f.tap(tester, 'Use a different account'); + await f.settle(tester); + expect( + f.container.read(builderlabAccountProvider).account!.subject, + 'journey-owner', + ); + expect(find.text('Use a different account'), findsNothing); + expect((await f.storage.loadAll()).single.nsec, community.nsec); + // A legacy account can link the existing saved key on first issuance. + f.bound = null; + f.assertionNow = f.assertionNow.add(const Duration(minutes: 6)); + await session.reconnect(); + await f.settle(tester); + expect(f.bound, f.keys.public); + expect( + find.text( + 'This account cannot reconnect to this community with its saved key.', + ), + findsNothing, + ); + expect((await f.storage.loadAll()).single.nsec, community.nsec); + expect(await f.storage.loadActiveId(), community.id); + expect((await f.store.read())!.pubkey, f.keys.public); + for (final path in ['/status', '/enrollment', '/communities/joined']) { + expect( + f.sent(path), + isEmpty, + reason: 'Reconnect must not run onboarding', + ); + } + // The issuer can succeed while the relay refuses the upgrade. One + // renewal is allowed, then stop; a user retry can recover later. + f.refuseUpgrade = true; + final attempts = f.socketHeaders.length; + await session.reconnect(); + await f.settle(tester); + expect(f.socketHeaders.length, attempts + 2); + expect( + f.container.read(relaySessionProvider).status, + SessionStatus.disconnected, + ); + expect(find.text('Use a different account'), findsOneWidget); + f.refuseUpgrade = false; + await session.reconnect(); + await f.settle(tester); + + // A timed-out query cannot dispatch later when a slow issuer completes. + final stalled = Completer(); + f.beforeAssertionReturn = () => stalled.future; + f.assertionNow = f.assertionNow.add(const Duration(minutes: 6)); + final queries = f.sent('/query').length; + await expectLater( + session.queryRelay(const [], timeout: const Duration(milliseconds: 20)), + throwsA(isA()), + ); + stalled.complete(); + await f.settle(tester); + expect(f.sent('/query'), hasLength(queries)); + f.beforeAssertionReturn = null; + expect(f.container.read(relayConfigProvider).nsec, community.nsec); + }, + requiresBuilderlab: false, + ); + + journey( + 'fresh account signs in, backs up, accepts invite and retries starters before home', + (tester, f) async { + await f.initialize( + localIdentity: false, + cloudChoice: true, + signedIn: false, + ); + f.starterFailures = 2; + final browserReturn = Completer(); + f.beforeBrowserReturn = () => browserReturn.future; + final enrollmentReached = Completer(); + final enrollmentReturn = Completer(); + f.beforeEnrollmentReturn = () { + enrollmentReached.complete(); + return enrollmentReturn.future; + }; + await f.mount(tester); + await f.link(tester, 'one'); + try { + expect( + find.text('Enter a community link').hitTestable(), + findsOneWidget, + ); + expect(find.text('Scan a QR code').hitTestable(), findsOneWidget); + expect(find.text('Start login again'), findsNothing); + expect(find.text('Complete sign-in in your browser.'), findsNothing); + } finally { + browserReturn.complete(); + } + await enrollmentReached.future.timeout(const Duration(seconds: 5)); + await tester.pump(); + try { + await tester.tap(find.text('Leave setup').hitTestable()); + await tester.pump(); + } finally { + enrollmentReturn.complete(); + } + await f.settle(tester); + expect(f.sent('/nsec-backups'), isEmpty); + final retainedKey = (await f.store.read())!.pubkey; + f.beforeEnrollmentReturn = null; + // A dispatched upload can succeed after cancellation. Its receipt must + // still be durable; resuming must not replay an acknowledged backup. + f.beforeUploadReturn = () => f.flow.leave(); + await f.link(tester, 'one'); + expect((await f.store.read())!.backup, isNotNull); + f.beforeUploadReturn = null; + await f.link(tester, 'one'); + expect((await f.store.read())!.pubkey, retainedKey); + expect(f.browserStarts, 1); + final generated = (await f.store.read())!; + expect(generated.pubkey, f.bound); + expect(generated.backup, isNotNull); + expect(f.sent('/verify'), hasLength(1)); + expect(f.sent('/nsec-backups'), hasLength(1)); + final accept = find.widgetWithText(FilledButton, 'Accept and join'); + expect(tester.widget(accept).onPressed, isNull); + expect(f.sent('/claim'), isEmpty); + expect(await f.storage.loadAll(), isEmpty); + await f.tap(tester, 'Terms of service'); + expect(find.text('Be kind to this community.'), findsOneWidget); + await tester.tap(find.byType(CheckboxListTile)); + await f.settle(tester); + await f.tap(tester, 'Accept and join'); + expect(find.text('Try again').hitTestable(), findsOneWidget); + final pending = (await f.storage.loadAll()).single; + expect(pending.starterSetupIncomplete, isTrue); + expect(pending.pubkey, generated.pubkey); + expect(f.sent('/claim'), hasLength(1)); + expect( + jsonDecode(f.sent('/claim').single.body)['policy_receipt'], + 'synthetic-policy-receipt', + ); + expect( + jsonDecode(f.sent('/accept-policy').single.body)['age_confirmed'], + isTrue, + ); + // Reopen the saved invite after its claim was consumed. Starter recovery + // must retain the durable marker and must not claim it again. + await f.link(tester, 'one'); + expect((await f.storage.loadAll()).single.starterSetupIncomplete, isTrue); + expect(find.text('Try again').hitTestable(), findsOneWidget); + await f.tap(tester, 'Try again'); + expect(find.byType(HostedProfileSetup), findsOneWidget); + expect(f.sent('/claim'), hasLength(1)); + expect(f.recoveryScopes, hasLength(3)); + expect( + f.recoveryScopes.every((scope) => scope.nsec == pending.nsec), + isTrue, + ); + await tester.enterText( + find.byType(TextField).hitTestable(), + 'Journey Person', + ); + await f.tap(tester, 'Continue'); + expect(f.profileWrites, ['Journey Person']); + final saved = (await f.storage.loadAll()).single; + expect(saved.id, pending.id); + expect(saved.starterSetupIncomplete, isFalse); + expect(await f.storage.loadActiveId(), saved.id); + expect(find.byType(OnboardingAppEntry), findsNothing); + expect(find.text('Community home').hitTestable(), findsOneWidget); + }, + requiresAuthDomain: true, + ); + + for (final boundAccount in [false, true]) { + journey( + 'switch accounts preserves both keys and requires unbound reuse consent ($boundAccount)', + (tester, f) async { + await f.initialize(); + final original = f.store; + await f.mount(tester); + await f.container.read(builderlabAccountProvider).clearLocalSession(); + f.subject = 'new-owner'; + f.bound = boundAccount ? f.secondKeys.public : null; + if (boundAccount) f.cloudKeys.add(f.secondKeys); + await f.tap(tester, 'Continue with Builderlab'); + if (!boundAccount) { + expect(find.text('Saved identity'), findsOneWidget); + expect(f.sent('/verify'), isEmpty); + expect(f.sent('/nsec-backups'), isEmpty); + expect(await f.store.read(), isNull); + await f.tap(tester, fullNpub(f.keys.public)!); + } + final saved = (await f.store.read())!; + expect( + saved.pubkey, + boundAccount ? f.secondKeys.public : f.keys.public, + ); + expect(saved.backup!.subject, 'new-owner'); + expect((await original.read())!.pubkey, f.keys.public); + expect((await original.read())!.backup!.subject, 'journey-owner'); + }, + ); + } + + journey( + 'choose second cloud identity, dismiss profile, retry remaining batch without losing keys', + (tester, f) async { + await f.initialize(localIdentity: false, cloudChoice: true); + f.cloudKeys.addAll([f.keys, f.secondKeys]); + f.rejectList = true; + f.secondRelayFailures = 1; + final legacy = Community.create( + name: 'Saved local', + relayUrl: 'wss://saved.example', + pubkey: f.keys.public, + nsec: f.keys.nsec, + ); + await f.storage.save(legacy); + await f.storage.saveActiveId(legacy.id); + await f.mount(tester); + await f.tap(tester, 'Continue with Builderlab'); + expect(find.text('Use Builderlab identity'), findsNWidgets(2)); + await f.tap(tester, fullNpub(f.secondKeys.public)!); + expect((await f.store.read())!.pubkey, f.secondKeys.public); + expect( + find.text('We could not finish connecting. You can try again.'), + findsOneWidget, + ); + expect( + find.text( + 'No communities available to add. Scan a QR code or use an invite link.', + ), + findsNothing, + ); + f.rejectList = false; + await f.tap(tester, 'Continue with Builderlab'); + expect( + find.text( + 'No communities available to add. Scan a QR code or use an invite link.', + ), + findsOneWidget, + ); + expect(find.text('Scan a QR code'), findsOneWidget); + expect(find.text('Use pairing code'), findsOneWidget); + f.rows.addAll([f.row('one'), f.row('two')]); + await f.tap(tester, 'Continue with Builderlab'); + // Joined communities are added automatically after choosing the identity. + expect(find.byType(CheckboxListTile).hitTestable(), findsNothing); + expect(find.byType(HostedProfileSetup), findsOneWidget); + final firstSaved = (await f.storage.loadAll()).firstWhere( + (c) => c.name == 'one', + ); + Navigator.of( + tester.element(find.byType(HostedProfileSetup)), + rootNavigator: true, + ).pop(); + await f.settle(tester); + expect(f.profileWrites, isEmpty); + expect(find.text('Try again').hitTestable(), findsOneWidget); + expect( + (await f.storage.loadAll()).map((c) => c.id), + containsAll([legacy.id, firstSaved.id]), + ); + await f.tap(tester, 'Try again'); + final saved = await f.storage.loadAll(); + expect(saved, hasLength(3)); + expect(saved.singleWhere((c) => c.id == legacy.id).nsec, f.keys.nsec); + expect( + saved + .where((c) => c.id != legacy.id) + .every((c) => c.nsec == f.secondKeys.nsec), + isTrue, + ); + expect(saved.singleWhere((c) => c.name == 'one').id, firstSaved.id); + expect(await f.storage.loadActiveId(), firstSaved.id); + expect(f.probes.where((c) => c.name == 'one'), hasLength(1)); + expect(f.probes.where((c) => c.name == 'two'), hasLength(2)); + expect(find.byType(OnboardingAppEntry), findsNothing); + }, + ); + + for (final unreadableProfile in [false, true]) { + journey( + 'new device recovers and preserves existing/unreadable profile ($unreadableProfile)', + (tester, f) async { + await f.initialize(localIdentity: false); + f.cloudKeys.addAll([f.secondKeys, f.keys]); + f.rows.add(f.row('one')); + f.offerProfile = false; + f.profileUnavailable = unreadableProfile; + await f.mount(tester); + await f.tap(tester, 'Continue with Builderlab'); + // One discovered community is admitted automatically. + expect(f.profileWrites, isEmpty); + expect(find.byType(HostedProfileSetup), findsNothing); + final saved = (await f.storage.loadAll()).single; + expect(saved.nsec, f.keys.nsec); + expect(saved.pubkey, f.keys.public); + expect((await f.store.read())!.pubkey, f.keys.public); + expect(f.sent('/recovery'), hasLength(1)); + expect(f.sent('/challenge'), isEmpty); + expect(await f.storage.loadActiveId(), saved.id); + expect(find.byType(OnboardingAppEntry), findsNothing); + }, + ); + } + + journey( + 'self-hosted link explicitly reuses saved account key and persists admission', + (tester, f) async { + await f.initialize(); + f.requests.clear(); + await f.mount(tester); + await f.tap(tester, 'Enter a community link'); + await tester.enterText( + find.byKey(const Key('community-link-input')).hitTestable(), + 'my-community.example', + ); + await f.tap(tester, 'Continue'); + await f.tap(tester, 'Saved identity'); + final saved = (await f.storage.loadAll()).single; + expect(saved.relayUrl, 'wss://my-community.example'); + expect(saved.nsec, f.keys.nsec); + expect(await f.storage.loadActiveId(), saved.id); + expect(f.probes, hasLength(1)); + expect(f.sent('/claim'), isEmpty); + expect( + (await const FlutterSecureStorage().readAll()).keys.where( + (key) => key.startsWith('buzz_pending_self_hosted_'), + ), + isEmpty, + ); + // Reopening an interrupted self-hosted invite follows legacy recovery. + await f.storage.save(saved.copyWith(starterSetupIncomplete: true)); + f.container.invalidate(communityListProvider); + f.container + .read(pendingDeepLinkProvider.notifier) + .open( + Uri( + scheme: 'buzz', + host: 'join', + queryParameters: { + 'relay': saved.relayUrl, + 'code': 'consumed-invite', + }, + ), + ); + await f.settle(tester); + expect(f.recoveryScopes, hasLength(1)); + expect( + (await f.storage.loadAll()).single.starterSetupIncomplete, + isFalse, + ); + expect(f.sent('/claim'), isEmpty); + + expect(find.byType(OnboardingAppEntry), findsNothing); + await f.container + .read(communityListProvider.notifier) + .removeCommunity(saved.id); + await AccountIdentityStore( + subject: 'other-owner', + ).create(f.secondKeys.nsec); + await f.restart(tester); + await f.tap(tester, 'Community home'); + await f.tap(tester, 'Enter a community link'); + await tester.enterText( + find.byKey(const Key('community-link-input')).hitTestable(), + 'my-community.example', + ); + await f.tap(tester, 'Continue'); + // A refused key must not pin the URL against an explicit allowed choice. + f.refusedSelfHostedPubkey = f.keys.public; + await f.tap(tester, fullNpub(f.keys.public)!); + expect(await f.storage.loadAll(), isEmpty); + expect(find.textContaining('Could not join.'), findsOneWidget); + await f.tap(tester, fullNpub(f.secondKeys.public)!); + expect((await f.storage.loadAll()).single.nsec, f.secondKeys.nsec); + expect( + (await AccountIdentityStore().forPairing(f.keys.public).read())!.pubkey, + f.keys.public, + ); + if (HostedEnvironment.authDomains.isEmpty) { + expect(f.requests, isEmpty); + } + }, + requiresBuilderlab: false, + ); + + journey( + 'signed-in invite survives restart and leaving consent abandons only setup', + (tester, f) async { + await f.initialize(); + await f.mount(tester); + await f.link(tester, 'one'); + expect(find.text('Accept and join').hitTestable(), findsOneWidget); + await f.restart(tester); + await f.tap(tester, 'Start login again'); + expect(find.text('Accept and join').hitTestable(), findsOneWidget); + expect(f.browserStarts, 0); + expect( + f.sent('/api/join-policy').last.url.host, + 'one.communities.buzz.xyz', + ); + Navigator.of( + tester.element(find.text('Accept and join')), + rootNavigator: true, + ).pop(); + await f.settle(tester); + await f.restart(tester); + expect(find.text('Start login again'), findsNothing); + expect(find.text('Community home').hitTestable(), findsOneWidget); + expect(f.sent('/accept-policy'), isEmpty); + expect(f.sent('/claim'), isEmpty); + expect(f.probes, isEmpty); + expect(await f.storage.loadAll(), isEmpty); + expect(await f.storage.loadActiveId(), isNull); + expect((await f.store.read())!.pubkey, f.keys.public); + + // An open relay's successful AUTH must not convert a failed invitation + // into a completed claim for a non-member. + f.offerProfile = false; + f.inviteExpired = true; + f.missingList = true; + await f.link(tester, 'one'); + await tester.tap(find.byType(CheckboxListTile)); + await f.settle(tester); + await f.tap(tester, 'Accept and join'); + expect(await f.storage.loadAll(), isEmpty); + expect(f.probes, isEmpty); + + expect( + find.text('This invite has expired. Ask for a new invite link.'), + findsOneWidget, + ); + f.missingList = false; + await f.link(tester, 'one'); + await tester.tap(find.byType(CheckboxListTile)); + await f.settle(tester); + await f.tap(tester, 'Accept and join'); + expect(await f.storage.loadAll(), isEmpty); + expect(f.probes, isEmpty); + + // The server admits this identity but its reply is lost. The retained + // intent resumes after restart even once the code has expired. + f.inviteExpired = false; + f.missingList = false; + f.loseClaimResponse = true; + await f.link(tester, 'one'); + await tester.tap(find.byType(CheckboxListTile)); + await f.settle(tester); + await f.tap(tester, 'Accept and join'); + expect(await f.storage.loadAll(), isEmpty); + expect(f.rows, hasLength(1)); + f.inviteExpired = true; + f.rejectList = true; + await f.restart(tester); + await f.tap(tester, 'Start login again'); + await tester.tap(find.byType(CheckboxListTile)); + await f.settle(tester); + await f.tap(tester, 'Accept and join'); + expect(await f.storage.loadAll(), isEmpty); + expect(f.probes, isEmpty); + f.rejectList = false; + await f.tap(tester, 'Try again'); + await tester.tap(find.byType(CheckboxListTile)); + await f.settle(tester); + await f.tap(tester, 'Accept and join'); + final saved = (await f.storage.loadAll()).single; + expect(saved.relayUrl, 'wss://one.communities.buzz.xyz'); + expect(saved.pubkey, f.keys.public); + expect(await f.storage.loadActiveId(), saved.id); + expect(find.text('Community home').hitTestable(), findsOneWidget); + }, + requiresAuthDomain: true, + ); + + journey( + 'create survives a lost response and restart, then joins the same owned community', + (tester, f) async { + await f.initialize(); + f.loseCreateResponse = true; + await f.mount(tester); + await f.tap(tester, 'Create a community'); + await tester.enterText(find.byType(TextField).hitTestable(), 'new-home'); + await f.tap(tester, 'Continue'); + expect(find.text('Try again').hitTestable(), findsOneWidget); + expect(f.owned, hasLength(1)); + expect(await f.storage.loadAll(), isEmpty); + await f.restart(tester); + await f.tap(tester, 'Start login again'); + expect(find.byType(HostedProfileSetup), findsOneWidget); + await tester.enterText(find.byType(TextField).hitTestable(), 'Creator'); + await f.tap(tester, 'Continue'); + final saved = (await f.storage.loadAll()).single; + expect(saved.relayUrl, 'wss://new-home.communities.buzz.xyz'); + expect(saved.nsec, f.keys.nsec); + expect(await f.storage.loadActiveId(), saved.id); + expect(f.sent('/v1/buzz/communities'), hasLength(2)); + expect(f.owned, hasLength(1)); + expect(f.sent('/status'), isEmpty); + expect(f.browserStarts, 0); + expect(f.profileWrites, ['Creator']); + expect(find.text('Community home').hitTestable(), findsOneWidget); + }, + ); + + journey( + 'second invitation replaces pending consent and only joins the newer community', + (tester, f) async { + await f.initialize(); + f.offerProfile = false; + // The first dispatched attempt ends; retry owns the pending consent. + // A new incoming link must now invalidate that consent, not wait on it. + f.policyResponses.addAll([ + http.Response('Unavailable', 502), + http.Response('', 503), + http.Response('{"error":"proxy_busy"}', 503), + http.Response('', 429), + ]); + await f.mount(tester); + await f.link(tester, 'one'); + while (f.policyResponses.isNotEmpty) { + expect(find.text('Try again').hitTestable(), findsOneWidget); + expect(await f.storage.loadAll(), isEmpty); + await f.tap(tester, 'Try again'); + } + await f.tap(tester, 'Try again'); + expect(find.text('Accept and join').hitTestable(), findsOneWidget); + await f.link(tester, 'two'); + await tester.tap(find.byType(CheckboxListTile)); + await f.settle(tester); + await f.tap(tester, 'Accept and join'); + final saved = (await f.storage.loadAll()).single; + expect(saved.relayUrl, 'wss://two.communities.buzz.xyz'); + expect(saved.starterSetupIncomplete, isFalse); + expect(f.sent('/claim'), hasLength(1)); + expect(f.sent('/claim').single.url.host, 'two.communities.buzz.xyz'); + expect(f.sent('/accept-policy'), hasLength(1)); + expect( + f.sent('/accept-policy').single.url.host, + 'two.communities.buzz.xyz', + ); + expect(find.byType(OnboardingAppEntry), findsNothing); + }, + requiresAuthDomain: true, + ); +} 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..dcf2b90f50 --- /dev/null +++ b/mobile/test/features/onboarding/onboarding_entry_routes_test.dart @@ -0,0 +1,359 @@ +import 'package:buzz/shared/hosted/hosted_environment.dart'; +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/shared/hosted/hosted_flow_provider.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/hosted_community_connection.dart'; +import 'package:buzz/shared/hosted/self_hosted_entry.dart'; +import 'package:buzz/shared/hosted/account_identity_store.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; + final inventoryStorage = _EnumerationStorage(); + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + overrides: [ + hostedFlowProvider.overrideWithValue(f.flow), + channelsProvider.overrideWith(_NoChannels.new), + selfHostedEntryProvider.overrideWithValue( + SelfHostedEntry( + accountStore: AccountIdentityStore(storage: inventoryStorage), + 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)); + } + + if (HostedEnvironment.authDomains.isNotEmpty) { + // 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.admitted, ['one']); + 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(); + } + if (HostedEnvironment.requiresAuth('paired.communities.buzz.xyz')) { + 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); + // Native browser owns sign-in presentation; the welcome/form stays behind it. + expect(find.byType(HostedConnectPage), findsNothing); + await f.flow.leave(); + await tester.pumpAndSettle(); + expect(f.flow.stage, HostedFlowStage.idle); + await tester.tap(find.text('Back').hitTestable()); + await tester.pumpAndSettle(); + expect(find.text('Enter a community link').hitTestable(), findsOneWidget); + expect(container.read(hostedInlineEntryProvider), isTrue); + f.expired = false; + } else { + await openEntry(); + } + + // 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); + expect(find.text('Saved identity').hitTestable(), findsOneWidget); + expect(find.text('Import a Nostr key').hitTestable(), findsOneWidget); + Navigator.of(tester.element(find.byType(SelfHostedEntrySheet))).pop(); + await tester.pumpAndSettle(); + // A failed bulk read must keep explicit import available, but not offer + // generation on the strength of a falsely empty inventory. + inventoryStorage.failEnumeration = true; + await tester.tap(find.text('Continue').hitTestable()); + await tester.pumpAndSettle(); + expect(find.text('Import a Nostr key').hitTestable(), findsOneWidget); + expect(find.text('Create an identity and join'), findsNothing); + Navigator.of(tester.element(find.byType(SelfHostedEntrySheet))).pop(); + await tester.pumpAndSettle(); + await const FlutterSecureStorage().write( + key: 'buzz_nsec', + value: f.keys.nsec, + ); + await tester.tap(find.text('Continue').hitTestable()); + await tester.pumpAndSettle(); + expect(find.text('Import a Nostr key').hitTestable(), findsOneWidget); + await tester.tap(find.text('Previous device 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); + if (HostedEnvironment.requiresAuth('invited.communities.buzz.xyz')) { + 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); + + if (HostedEnvironment.authDomains.isEmpty) return; + + // 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('Cancel sign-in').hitTestable(), findsOneWidget); + expect(find.byType(BuzzSheetHeader), findsNothing); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + expect(tester.takeException(), isNull); + }); +} + +class _EnumerationStorage extends FlutterSecureStorage { + bool failEnumeration = false; + @override + Future> readAll({ + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async { + if (failEnumeration) throw StateError('unrelated entry cannot decrypt'); + return super.readAll(); + } +} 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..474b6d5ad7 --- /dev/null +++ b/mobile/test/features/pairing/pairing_hosted_handoff_test.dart @@ -0,0 +1,145 @@ +import 'package:buzz/shared/hosted/hosted_environment.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'package:buzz/features/pairing/pairing_provider.dart'; +import 'package:buzz/shared/hosted/hosted_flow_provider.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 { + if (!HostedEnvironment.requiresAuth('paired.communities.buzz.xyz')) { + return; + } + 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 retains transfer but blocks conflicting account admission', + () async { + if (!HostedEnvironment.requiresAuth('paired.communities.buzz.xyz')) { + return; + } + final f = HostedCommunitiesFixture(); + await f.initialize(); + addTearDown(f.dispose); + final container = ProviderContainer( + overrides: [hostedFlowProvider.overrideWithValue(f.flow)], + ); + addTearDown(container.dispose); + final transferred = nostr.Keys('2'.padLeft(64, '0')); + final settled = Completer(); + f.flow.addListener(() { + if ((f.flow.stage == HostedFlowStage.blocked || + f.flow.stage == HostedFlowStage.complete) && + !settled.isCompleted) { + settled.complete(); + } + }); + await container + .read(pairingProvider.notifier) + .pair(_payload(transferred)); + await settled.future.timeout(const Duration(seconds: 2)); + expect(f.flow.stage, HostedFlowStage.blocked); + expect(f.flow.problem, HostedFlowProblem.identityMismatch); + expect( + (await f.store.forPairing(transferred.public).read())?.pubkey, + transferred.public, + ); + 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 d08ca5387d..bbece7916d 100644 --- a/mobile/test/features/pairing/pairing_provider_test.dart +++ b/mobile/test/features/pairing/pairing_provider_test.dart @@ -528,6 +528,37 @@ void main() { }, ); + test('admission rejection never reports a successful transfer', () async { + importAuth.authentication = Completer(); + importAuth.entered = Completer(); + final finished = Completer(); + container.listen(pairingProvider, (_, next) { + if (next.status == PairingStatus.error && !finished.isCompleted) { + finished.complete(); + } + }); + await beginImport(protected: false); + validation.complete(); + await importAuth.entered!.future; + expect( + socket + .decryptedPublishedMessages(sourceSecret) + .where((message) => message['type'] == 'complete'), + isEmpty, + ); + importAuth.authentication!.completeError( + StateError('Admission refused'), + ); + await finished.future; + final completions = socket + .decryptedPublishedMessages(sourceSecret) + .where((message) => message['type'] == 'complete') + .toList(); + expect(completions, hasLength(1)); + expect(completions.single['success'], isFalse); + expect(container.read(pairedCommunityLandingProvider), isNull); + }); + test('unchecked protection persists on a successful import', () async { await beginImport(protected: false); @@ -638,6 +669,10 @@ void main() { await Future.delayed(Duration.zero); expect(container.read(pairingProvider).status, PairingStatus.idle); + expect( + container.read(authProvider).value!.status, + AuthStatus.unauthenticated, + ); expect(socket.isConnected, isFalse); }); @@ -1233,6 +1268,7 @@ class FakeAuthNotifier extends AsyncNotifier implements AuthNotifier { Community? lastCommunity; Completer? authentication; + Completer? entered; bool signedOut = false; @override @@ -1246,9 +1282,16 @@ class FakeAuthNotifier extends AsyncNotifier } @override - Future authenticateWithCommunity(Community community) async { + Future authenticateWithCommunity( + Community community, { + bool Function()? shouldContinue, + bool activate = true, + }) async { + if (shouldContinue != null && !shouldContinue()) return; lastCommunity = community; + entered?.complete(); await authentication?.future; + if (shouldContinue != null && !shouldContinue()) return; state = AsyncData( AuthState(status: AuthStatus.authenticated, community: community), ); diff --git a/mobile/test/helpers/hosted_communities_fixture.dart b/mobile/test/helpers/hosted_communities_fixture.dart new file mode 100644 index 0000000000..fb7e4ae027 --- /dev/null +++ b/mobile/test/helpers/hosted_communities_fixture.dart @@ -0,0 +1,171 @@ +import 'dart:convert'; +import 'dart:async'; + +import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:buzz/shared/hosted/account_identity_store.dart'; +import 'package:buzz/shared/hosted/browser_auth.dart'; +import 'package:buzz/shared/hosted/builderlab_account.dart'; +import 'package:buzz/shared/hosted/hosted_flow.dart'; +import 'package:buzz/shared/hosted/hosted_identity_service.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:nostr/nostr.dart' as nostr; + +class DiscoveryBrowser extends BrowserAuth { + final callbacks = StreamController.broadcast(); + Map? startedIntent; + @override + Future start(Map intent) async { + startedIntent = intent; + } + + @override + Stream get codes => callbacks.stream; + @override + Future dispose() async { + await callbacks.close(); + await super.dispose(); + } + + @override + Future initialize() async {} + @override + Future clearIntent() async {} + @override + Future cancel() async {} +} + +class WidgetDiscoveryAccount extends BuilderlabAccountClient { + WidgetDiscoveryAccount(this.fixture); + final HostedCommunitiesFixture fixture; + @override + BuilderlabAccount? get account => fixture.expired + ? null + : BuilderlabAccount( + subject: fixture.subject, + expiresAt: DateTime.now().add(const Duration(hours: 1)), + ); + @override + Future restore() async => account; + @override + Future> post( + String path, + Map body, + ) async { + if (path.endsWith('/current')) { + if (fixture.rejectCurrent) throw AccountFailure.denied; + return { + 'identity': {'pubkey_hex': fixture.bound}, + }; + } + if (path.endsWith('/communities/joined')) { + return {'communities': fixture.rows}; + } + throw AccountFailure.unavailable; + } +} + +class HostedCommunitiesFixture { + final keys = nostr.Keys('1'.padLeft(64, '0')); + AccountIdentityStore get store => AccountIdentityStore(subject: subject); + final browser = DiscoveryBrowser(); + late BuilderlabAccountClient account; + late final HostedFlow flow; + final admitted = []; + final requests = []; + List> rows = []; + String? bound; + bool rejectList = false; + bool rejectCurrent = false; + bool expired = false; + String subject = 'owner'; + Future Function(String)? beforeAdmission; + Future Function()? beforeList; + Uri? opened; + + Map row(String name, {bool archived = false}) => { + 'id': name, + 'name': name, + 'normalized_host': '$name.communities.buzz.xyz', + if (archived) 'archived_at': '2026-10-01T00:00:00Z', + }; + + Future initialize({bool backup = true, bool widget = false}) async { + bound = keys.public; + final expiry = DateTime.now() + .toUtc() + .add(const Duration(hours: 1)) + .toIso8601String(); + account = BuilderlabAccountClient( + httpClient: MockClient((request) async { + final path = request.url.path; + requests.add(path); + if (path.endsWith('/exchange')) { + return http.Response( + jsonEncode({'session_credential': 'fixture', 'expires_at': expiry}), + 200, + ); + } + if (path.endsWith('/me')) { + if (expired) return http.Response('{}', 401); + return http.Response( + jsonEncode({'subject': subject, 'expires_at': expiry}), + 200, + ); + } + if (path.endsWith('/current')) { + return http.Response( + jsonEncode({ + 'identity': {'pubkey_hex': bound}, + }), + 200, + ); + } + if (path.endsWith('/communities/joined')) { + await beforeList?.call(); + if (rejectList) return http.Response('{}', 503); + return http.Response(jsonEncode({'communities': rows}), 200); + } + return http.Response('{}', 503); + }), + ); + if (widget) { + account.dispose(); + account = WidgetDiscoveryAccount(this); + } else { + await account.exchange(code: 'code', verifier: 'a' * 43); + } + await store.create(keys.nsec); + if (backup) { + await store.confirmBackup( + BackupReceipt( + backend: BuilderlabAccountClient.origin.toString(), + subject: 'owner', + pubkey: keys.public, + backupId: 'fixture-backup', + ), + ); + } + flow = HostedFlow( + account: account, + browser: browser, + store: store, + identity: HostedIdentityService(account: account, store: store), + openWeb: (uri) async { + opened = uri; + return true; + }, + enterCommunity: (link, subject, pubkey, current) async { + final target = link as AddCommunityDeepLink; + await beforeAdmission?.call(target.name); + if (current()) admitted.add(target.name); + }, + ); + } + + Future dispose() async { + flow.dispose(); + await browser.dispose(); + account.dispose(); + } +} diff --git a/mobile/test/helpers/hosted_journey_fixture.dart b/mobile/test/helpers/hosted_journey_fixture.dart new file mode 100644 index 0000000000..b2ca016fb8 --- /dev/null +++ b/mobile/test/helpers/hosted_journey_fixture.dart @@ -0,0 +1,538 @@ +import 'package:buzz/shared/hosted/hosted_environment.dart'; +import 'package:buzz/features/pairing/pairing_provider.dart'; +import 'dart:io'; +import 'dart:convert'; +import 'package:flutter/services.dart'; + +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channels_provider.dart'; +import 'package:buzz/features/invites/invite_join_provider.dart'; +import 'package:buzz/features/onboarding/self_hosted_entry_sheet.dart'; +import 'package:buzz/features/profile/profile_provider.dart'; +import 'package:buzz/hosted_app_gate.dart'; +import 'package:buzz/shared/hosted/hosted_flow_provider.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/account_identity_store.dart'; +import 'package:buzz/shared/hosted/browser_auth.dart'; +import 'package:buzz/shared/hosted/builderlab_account.dart'; +import 'package:buzz/shared/hosted/hosted_flow.dart'; +import 'package:buzz/shared/hosted/hosted_relay_auth.dart'; +import 'package:buzz/shared/relay/relay_session.dart'; +import 'package:buzz/shared/relay/relay_socket.dart'; +import 'package:buzz/shared/hosted/self_hosted_entry.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import 'widget_helpers.dart'; + +/// Synthetic REST/relay boundaries with the production flow factory, account, +/// identity, admission, auth and storage. No admission/persistence is simulated. +class HostedJourneyFixture { + final keys = nostr.Keys('1'.padLeft(64, '0')); + final secondKeys = nostr.Keys('2'.padLeft(64, '0')); + AccountIdentityStore get store => AccountIdentityStore(subject: subject); + final storage = CommunityStorage(); + final requests = []; + Future Function()? beforeBrowserReturn; + Future Function()? beforeEnrollmentReturn; + Future Function()? beforeUploadReturn; + final probes = []; + String? refusedSelfHostedPubkey; + final profileWrites = []; + final recoveryScopes = []; + final rows = >[]; + final cloudKeys = []; + HostedFlow? activeFlow; + HostedFlow get flow => activeFlow!; + late ProviderContainer container; + String? bound; + int starterFailures = 0; + final policyResponses = []; + int browserStarts = 0; + int secondRelayFailures = 0; + DateTime assertionNow = DateTime.now(); + bool relayAuthEnabled = false; + bool expiredSession = false; + bool denyAssertion = false; + bool refuseUpgrade = false; + String subject = 'journey-owner'; + Future Function()? beforeAssertionReturn; + final socketHeaders = >[]; + bool offerProfile = true; + bool profileUnavailable = false; + bool rejectList = false; + bool missingList = false; + bool loseCreateResponse = false; + bool loseClaimResponse = false; + bool inviteExpired = false; + final owned = >[]; + final expiry = DateTime.now() + .toUtc() + .add(const Duration(minutes: 5)) + .toIso8601String(); + + Map row(String name) => { + 'id': name, + 'name': name, + 'normalized_host': '$name.communities.buzz.xyz', + }; + + Future initialize({ + bool localIdentity = true, + bool cloudChoice = false, + bool signedIn = true, + }) async { + FlutterSecureStorage.setMockInitialValues({}); + PendingDeepLinkNotifier.debugUriStreamOverride = const Stream.empty(); + bound = cloudChoice ? null : keys.public; + if (localIdentity) { + await store.create(keys.nsec); + await store.confirmBackup( + BackupReceipt( + backend: BuilderlabAccountClient.origin.origin, + subject: 'journey-owner', + pubkey: keys.public, + backupId: 'backup-1', + ), + ); + } + // A real exchange writes the credential used by both the flow and the + // independent account verifier constructed during durable admission. + if (!signedIn) return; + final account = BuilderlabAccountClient(); + await account.exchange(code: 'synthetic-code', verifier: 'v' * 43); + account.dispose(); + } + + http.Client client() => MockClient((request) async { + requests.add(request); + final path = request.url.path; + Object result; + if (path.endsWith('/exchange')) { + expiredSession = false; + result = { + 'session_credential': 'synthetic-session', + 'expires_at': expiry, + }; + } else if (path.endsWith('/me')) { + if (expiredSession) return http.Response('{}', 401); + result = {'subject': subject, 'expires_at': expiry}; + } else if (path.endsWith('/identity/assertions')) { + await beforeAssertionReturn?.call(); + if (expiredSession) return http.Response('{}', 401); + if (denyAssertion) return http.Response('{}', 403); + final body = jsonDecode(request.body) as Map; + if (bound != null && body['nostr_pubkey'] != bound) { + return http.Response('{}', 403); + } + bound ??= body['nostr_pubkey'] as String; + result = { + 'assertion': 'synthetic-assertion', + 'nostr_pubkey': bound, + 'expires_at': + assertionNow + .add(const Duration(minutes: 5)) + .millisecondsSinceEpoch ~/ + 1000, + }; + } else if (path == '/auth-transport') { + return http.Response( + '', + 302, + headers: {'location': 'https://outside.example'}, + ); + } else if (path == '/query' || path == '/profiles/search') { + if (relayAuthEnabled && + HostedEnvironment.requiresAuth(request.url.host)) { + expect( + request.headers['Nostr-Federated-Identity'], + 'Bearer synthetic-assertion', + ); + expect(request.headers.containsKey('X-BB-Session-Credential'), isFalse); + } + result = []; + } else if (path.endsWith('/current')) { + if (expiredSession) return http.Response('{}', 401); + result = { + 'identity': bound == null ? null : {'pubkey_hex': bound}, + }; + } else if (path.endsWith('/status')) { + result = { + 'current_nostr_pubkey': bound, + 'backup_nostr_pubkeys': cloudKeys.map((k) => k.public).toList(), + }; + } else if (path.endsWith('/recovery')) { + result = { + 'backups': [ + for (final k in cloudKeys) + { + 'backup_id': 'backup-${k.public}', + 'nostr_pubkey': k.public, + 'secret_key': base64Url + .encode([ + for (var i = 0; i < 64; i += 2) + int.parse(k.secret.substring(i, i + 2), radix: 16), + ]) + .replaceAll('=', ''), + }, + ], + 'failed_backup_ids': [], + }; + } else if (path.endsWith('/challenge')) { + result = { + 'challenge_id': '12345678-1234-1234-1234-123456789abc', + 'nonce': 'a' * 43, + 'verification_code': '123456', + 'origin': BuilderlabAccountClient.origin.toString(), + 'expires_at': expiry, + }; + } else if (path.endsWith('/verify')) { + final event = + jsonDecode(jsonDecode(request.body)['signed_payload'] as String) + as Map; + bound = event['pubkey'] as String; + result = { + 'identity': {'pubkey_hex': bound}, + }; + } else if (path.endsWith('/enrollment')) { + await beforeEnrollmentReturn?.call(); + result = { + 'version': 1, + 'suite': {'kem_id': 16, 'kdf_id': 1, 'aead_id': 2}, + 'recipient_key_id': 'test-key', + 'service_namespace': 'buzz', + 'owner_id': 'journey-owner', + 'backup_id': '12345678-1234-1234-1234-123456789abc', + 'nostr_pubkey': bound, + 'recipient_public_key': base64Url + .encode([4, ...List.filled(64, 1)]) + .replaceAll('=', ''), + }; + } else if (path.endsWith('/nsec-backups')) { + await beforeUploadReturn?.call(); + result = { + 'stored': true, + 'backup_id': jsonDecode(request.body)['backup_id'], + }; + } else if (path.endsWith('/v1/buzz/communities')) { + final name = jsonDecode(request.body)['name'] as String; + final created = + owned + .where( + (row) => row['name'] == name && row['owner_pubkey'] == bound, + ) + .firstOrNull ?? + {...row(name), 'owner_pubkey': bound}; + if (!owned.contains(created)) owned.add(created); + if (loseCreateResponse) { + loseCreateResponse = false; + throw AccountFailure.unavailable; + } + result = {'community': created}; + } else if (path.endsWith('/communities/joined')) { + if (rejectList) return http.Response('{}', 503); + if (missingList) return http.Response('{}', 404); + result = {'communities': rows}; + } else if (path == '/api/join-policy') { + if (policyResponses.isNotEmpty) return policyResponses.removeAt(0); + result = { + 'policy': { + 'version': request.url.host, + 'age_attestation_required': true, + 'terms_markdown': 'Be kind to this community.', + 'privacy_markdown': 'Your public profile is public.', + }, + }; + } else if (path == '/api/invites/accept-policy') { + if (inviteExpired) { + return http.Response(jsonEncode({'error': 'invite_expired'}), 410); + } + result = {'receipt': 'synthetic-policy-receipt'}; + } else if (path == '/api/invites/claim') { + if (loseClaimResponse) { + loseClaimResponse = false; + rows.add(row(request.url.host.split('.').first)); + throw AccountFailure.unavailable; + } + result = {'host': request.url.host}; + } else { + throw StateError('Unexpected journey request: ${request.method} $path'); + } + return http.Response( + jsonEncode(result), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + HostedFlow createFlow(Ref ref) => activeFlow = createHostedFlow( + ref, + browserAuth: _Browser(() async { + browserStarts++; + await beforeBrowserReturn?.call(); + }), + probe: (community) async { + probes.add(community); + if (Uri.parse(community.relayUrl).host.startsWith('two.') && + secondRelayFailures-- > 0) { + throw AccountFailure.unavailable; + } + }, + ); + + Future mount( + WidgetTester tester, { + bool openEntry = true, + PairingCredentialValidator? pairingValidator, + }) async { + await tester.binding.setSurfaceSize(const Size(440, 1100)); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('buzz/hosted_hpke'), + (_) async => { + 'enc': Uint8List.fromList([4, ...List.filled(64, 2)]), + 'ciphertext': Uint8List(48), + }, + ); + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + overrides: [ + hostedFlowProvider.overrideWith(createFlow), + if (pairingValidator != null) + pairingProvider.overrideWith( + () => PairingNotifier(credentialValidator: pairingValidator), + ), + if (relayAuthEnabled) ...[ + hostedRelayAuthProvider.overrideWith((ref) { + final auth = HostedRelayAuth( + ref.watch(builderlabAccountProvider), + enabled: true, + now: () => assertionNow, + ); + ref.onDispose(auth.dispose); + return auth; + }), + relaySessionProvider.overrideWith( + () => RelaySessionNotifier( + socketFactory: + ({ + required wsUrl, + required nsec, + required onMessage, + required onConnected, + required onDisconnected, + }) => _RelaySocket( + this, + wsUrl: wsUrl, + nsec: nsec, + onMessage: onMessage, + onConnected: onConnected, + onDisconnected: onDisconnected, + ), + ), + ), + ], + communitySnapshotWriterProvider.overrideWithValue((_) async {}), + channelsProvider.overrideWith(_NoChannels.new), + profileProvider.overrideWith(() => _Profile(this)), + inviteJoinRecoveryProvider.overrideWithValue( + (scope) => _Recovery(this, scope), + ), + selfHostedEntryProvider.overrideWithValue( + SelfHostedEntry( + probe: (community) async { + probes.add(community); + if (community.pubkey == refusedSelfHostedPubkey) { + throw StateError('Community refused entry'); + } + }, + ), + ), + ], + 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('Community home'), + ), + ), + ), + ), + ), + ); + await settle(tester); + container = ProviderScope.containerOf( + tester.element(find.byType(HostedAppGate)), + ); + await container.read(authProvider.future); + if (openEntry) { + await tap(tester, 'Community home'); + expect(find.byType(OnboardingAppEntry), findsOneWidget); + } + } + + /// Recreate app owners while retaining secure storage and remote fixtures. + Future restart(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + await Future.delayed(Duration.zero); + await tester.pump(); + await mount(tester, openEntry: false); + } + + // HTTP response streams use real event-loop turns. Yield between frames so + // spinner animation cannot exhaust the fake clock before those turns run. + Future settle(WidgetTester tester) async { + for (var i = 0; i < 60; i++) { + await Future.delayed(Duration.zero); + await tester.pump(const Duration(milliseconds: 50)); + if (i >= 15 && activeFlow?.stage != HostedFlowStage.working) return; + } + throw StateError( + 'Journey did not settle: ${flow.stage} / ${flow.problem}; requests: ${requests.map((r) => r.url.path).join(',')}', + ); + } + + Future tap(WidgetTester tester, String label) async { + await tester.pump(); + await tester.tap(find.text(label).hitTestable()); + await settle(tester); + } + + Future link( + WidgetTester tester, + String name, { + bool invite = true, + }) async { + container + .read(pendingDeepLinkProvider.notifier) + .open( + Uri( + scheme: 'buzz', + host: invite ? 'join' : 'add', + queryParameters: { + 'relay': 'wss://$name.communities.buzz.xyz', + if (invite) 'code': 'invite-$name', + if (!invite) 'name': name, + }, + ), + ); + await settle(tester); + } + + List sent(String path) => + requests.where((r) => r.url.path.endsWith(path)).toList(); + + Future dispose(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + await Future.delayed(Duration.zero); + await tester.pump(); + PendingDeepLinkNotifier.debugUriStreamOverride = null; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('buzz/hosted_hpke'), + null, + ); + await tester.binding.setSurfaceSize(null); + } +} + +class _NoChannels extends ChannelsNotifier { + @override + Future> build() async => []; +} + +// Replace relay metadata transport, not the production profile-offer logic. +class _Profile extends ProfileNotifier { + _Profile(this.fixture); + final HostedJourneyFixture fixture; + @override + Future build() async { + if (fixture.profileUnavailable) throw StateError('relay unavailable'); + return fixture.offerProfile ? null : UserProfile(pubkey: fixture.bound!); + } + + @override + Future updateDisplayName(String name) async { + fixture.profileWrites.add(name); + } +} + +// This boundary proves pending-flag/retry ownership, not relay memberships. +class _Recovery implements InviteJoinRecovery { + _Recovery(this.fixture, this.scope); + final HostedJourneyFixture fixture; + final InviteJoinRecoveryScope scope; + @override + Future ensureStarterChannels() async { + fixture.recoveryScopes.add(scope); + if (fixture.starterFailures-- > 0) throw AccountFailure.unavailable; + return 'general'; + } +} + +class _Browser extends BrowserAuth { + _Browser(Future Function() onStart) + : super( + openBrowser: (uri) async { + await onStart(); + return Uri.parse(BrowserAuth.callback) + .replace( + queryParameters: { + 'state': uri.queryParameters['state']!, + 'code': 'synthetic-code', + }, + ) + .toString(); + }, + ); + @override + Future initialize() async {} +} + +class _RelaySocket extends RelaySocket { + _RelaySocket( + this.fixture, { + required super.wsUrl, + required super.nsec, + required super.onMessage, + required super.onConnected, + required super.onDisconnected, + }) : url = wsUrl, + _connected = onConnected, + _disconnected = onDisconnected; + final HostedJourneyFixture fixture; + final String url; + final void Function() _connected; + final void Function(Object?) _disconnected; + @override + Future connect() async { + fixture.socketHeaders.add(Map.of(headers)); + expect( + headers['Nostr-Federated-Identity'], + HostedEnvironment.requiresAuth(Uri.parse(url).host) + ? 'Bearer synthetic-assertion' + : isNull, + ); + if (fixture.refuseUpgrade) { + _disconnected(const WebSocketException('refused', 403)); + } else { + _connected(); + } + } +} 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/auth/community_entry_requirements_test.dart b/mobile/test/shared/auth/community_entry_requirements_test.dart new file mode 100644 index 0000000000..339142bf80 --- /dev/null +++ b/mobile/test/shared/auth/community_entry_requirements_test.dart @@ -0,0 +1,74 @@ +import 'package:buzz/shared/hosted/hosted_environment.dart'; +import 'package:buzz/shared/auth/auth_provider.dart'; +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/community/community_provider.dart'; +import 'package:buzz/shared/community/community_storage.dart'; +import 'package:buzz/shared/hosted/builderlab_account.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 '../community/community_storage_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + for (final useAuth in [true, false]) { + for (final host in [ + 'team.communities.buzz.xyz', + 'team.communities.public.builderlabstaging.xyz', + 'team.communities.public.builderlabstaging.xyz.', + ]) { + test( + 'accountless entry follows domain policy through ' + '${useAuth ? "authentication" : "community addition"}: $host', + () async { + FlutterSecureStorage.setMockInitialValues({}); + final storage = CommunityStorage(secure: FakeSecureStorage()); + final container = ProviderContainer( + overrides: [communityStorageProvider.overrideWithValue(storage)], + ); + addTearDown(container.dispose); + await container.read(authProvider.future); + await container.read(communityListProvider.future); + var transitions = 0; + container.read(communityTransitionProvider).register(() async { + transitions++; + }); + final keys = nostr.Keys.generate(); + final community = Community.create( + name: 'Hosted', + relayUrl: 'wss://$host', + pubkey: keys.public, + nsec: keys.nsec, + ); + final pending = useAuth + ? container + .read(authProvider.notifier) + .authenticateWithCommunity(community) + : container + .read(communityListProvider.notifier) + .addCommunity(community); + if (!HostedEnvironment.requiresAuth(host)) { + await pending; + expect((await storage.loadAll()).single.nsec, keys.nsec); + expect( + await storage.loadActiveId(), + useAuth ? community.id : isNull, + ); + expect(transitions, useAuth ? 1 : 0); + return; + } + await expectLater(pending, throwsA(isA())); + expect(await storage.loadAll(), isEmpty); + expect(await storage.loadActiveId(), isNull); + expect(transitions, 0); + expect( + container.read(authProvider).value!.status, + AuthStatus.unauthenticated, + ); + }, + ); + } + } +} diff --git a/mobile/test/shared/deeplink/pending_deep_link_provider_test.dart b/mobile/test/shared/deeplink/pending_deep_link_provider_test.dart index b13f42df6d..476887acc4 100644 --- a/mobile/test/shared/deeplink/pending_deep_link_provider_test.dart +++ b/mobile/test/shared/deeplink/pending_deep_link_provider_test.dart @@ -2,6 +2,7 @@ import 'package:buzz/shared/deeplink/deep_link.dart'; import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart'; import 'package:buzz/shared/push/push_bridge.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; void main() { @@ -15,6 +16,22 @@ void main() { pendingPushNotificationLink.value = null; }); + test('unsupported callback does not log its code or state', () { + final messages = []; + final previous = debugPrint; + debugPrint = (message, {wrapWidth}) => messages.add(message); + addTearDown(() => debugPrint = previous); + final container = ProviderContainer(); + addTearDown(container.dispose); + container + .read(pendingDeepLinkProvider.notifier) + .open(Uri.parse('buzz://auth?code=secret-code&state=secret-state')); + expect(container.read(pendingDeepLinkProvider), isNull); + expect(messages.join(), isNot(contains('secret-code'))); + expect(messages.join(), isNot(contains('secret-state'))); + expect(messages, isNotEmpty); + }); + test('parks and consumes a native notification message link', () async { final container = ProviderContainer(); addTearDown(container.dispose); diff --git a/mobile/test/shared/hosted/account_identity_store_test.dart b/mobile/test/shared/hosted/account_identity_store_test.dart new file mode 100644 index 0000000000..4cb8748924 --- /dev/null +++ b/mobile/test/shared/hosted/account_identity_store_test.dart @@ -0,0 +1,269 @@ +import 'dart:async'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/shared/hosted/account_identity_store.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final key = nostr.Keys( + '0000000000000000000000000000000000000000000000000000000000000001', + ); + setUp(() => FlutterSecureStorage.setMockInitialValues({})); + + for (final simulated in [false, true]) { + test( + 'recovery retains scoped receipt; simulated=$simulated cannot authorize', + () async { + final receipt = BackupReceipt( + backend: 'https://app.builderlab.xyz', + subject: 'account-a', + pubkey: key.public, + backupId: simulated ? 'debug-simulated-backup:old' : 'backup-1', + ); + final store = AccountIdentityStore(); + expect(await store.read(), isNull); + await store.create(key.nsec, recoveredBackup: receipt); + final restored = (await AccountIdentityStore().read())!; + expect( + restored.hasBackupFor( + backend: receipt.backend, + subject: receipt.subject, + ), + !simulated, + ); + expect( + restored.hasBackupFor(backend: receipt.backend, subject: 'account-b'), + isFalse, + ); + expect(await store.withKeys((keys) async => keys.public), key.public); + expect((await store.create(key.nsec)).pubkey, key.public); + // Existing receipt migrates only to its owner; another account has + // an independent create-only slot and cannot replace the first key. + final owner = store.forSubject('account-a'); + final otherAccount = store.forSubject('account-b'); + expect((await owner.read())!.pubkey, key.public); + expect(await otherAccount.read(), isNull); + final otherKey = nostr.Keys('2'.padLeft(64, '0')); + await otherAccount.create(otherKey.nsec); + expect((await owner.read())!.pubkey, key.public); + expect((await store.read())!.pubkey, key.public); + expect((await otherAccount.read())!.pubkey, otherKey.public); + await store.forPairing(key.public).create(key.nsec); + await store.forPairing(otherKey.public).create(otherKey.nsec); + expect((await store.identities()).map((item) => item.pubkey).toSet(), { + key.public, + otherKey.public, + }); + }, + ); + } + + test( + 'unrelated enumeration failure preserves known-slot access and errors', + () async { + final storage = EnumerationFailureStorage(); + final legacy = AccountIdentityStore(storage: storage); + await legacy.create(key.nsec); + final scoped = legacy.forSubject('b'); + final other = nostr.Keys('2'.padLeft(64, '0')); + await scoped.create(other.nsec); + await expectLater( + scoped.identities(), + throwsA(AccountIdentityFailure.unreadable), + ); + expect((await scoped.read())!.pubkey, other.public); + expect( + await scoped.withSavedIdentity(key.public, (value) async => value), + key.nsec, + ); + await storage.write( + key: AccountIdentityStore.storageKey, + value: 'broken', + ); + await expectLater( + scoped.identities(), + throwsA(AccountIdentityFailure.unreadable), + ); + }, + ); + + test( + 'unreadable storage never allows creation or touches legacy keys', + () async { + FlutterSecureStorage.setMockInitialValues({ + AccountIdentityStore.storageKey: 'broken', + 'buzz_communities': 'legacy', + }); + final store = AccountIdentityStore(); + await expectLater( + store.read(), + throwsA(AccountIdentityFailure.unreadable), + ); + await expectLater( + store.create(key.nsec), + throwsA(AccountIdentityFailure.unreadable), + ); + expect( + await const FlutterSecureStorage().read(key: 'buzz_communities'), + 'legacy', + ); + }, + ); + + test('invalid candidate and wrong-key receipt never write', () async { + final store = AccountIdentityStore(); + await expectLater( + store.create('not-a-key'), + throwsA(AccountIdentityFailure.invalidKey), + ); + final other = nostr.Keys( + '0000000000000000000000000000000000000000000000000000000000000002', + ); + await expectLater( + store.create( + key.nsec, + recoveredBackup: BackupReceipt( + backend: 'https://app.builderlab.xyz', + subject: 'a', + pubkey: other.public, + backupId: 'b', + ), + ), + throwsA(AccountIdentityFailure.mismatch), + ); + expect(await store.read(), isNull); + await store.create(key.nsec); + await expectLater( + store.confirmBackup( + BackupReceipt( + backend: 'https://app.builderlab.xyz', + subject: 'a', + pubkey: other.public, + backupId: 'b', + ), + ), + throwsA(AccountIdentityFailure.mismatch), + ); + expect((await store.read())!.backup, isNull); + }); + + for (final generate in [false, true]) { + test( + 'uncertain ${generate ? "generated" : "recovered"} write retries exact identity once', + () async { + final storage = UncertainStorage(); + final store = AccountIdentityStore(storage: storage); + var authorizations = 0; + final receipt = BackupReceipt( + backend: 'https://app.builderlab.xyz', + subject: 'a', + pubkey: key.public, + backupId: 'one', + ); + Future write() => generate + ? store.generateIfAbsent( + authorize: () async { + authorizations++; + return true; + }, + ) + : store.create(key.nsec, recoveredBackup: receipt); + await expectLater(write(), throwsA(AccountIdentityFailure.unreadable)); + final persisted = (await store.read())!; + final retried = (await write())!; + expect(retried.pubkey, persisted.pubkey); + expect(authorizations, generate ? 1 : 0); + if (!generate) { + expect(retried.backup?.backupId, 'one'); + final other = nostr.Keys('2'.padLeft(64, '0')); + await expectLater( + store.create(other.nsec), + throwsA(AccountIdentityFailure.occupied), + ); + await expectLater( + store.create( + key.nsec, + recoveredBackup: BackupReceipt( + backend: receipt.backend, + subject: 'b', + pubkey: key.public, + backupId: 'two', + ), + ), + throwsA(AccountIdentityFailure.mismatch), + ); + } + expect(storage.writes, 1); + }, + ); + } + + test( + 'late durable write blocks second instance create until settled', + () async { + final storage = DelayedStorage(); + final first = AccountIdentityStore(storage: storage).create(key.nsec); + await storage.started.future; + final second = AccountIdentityStore(storage: storage).create(key.nsec); + final retried = second; + storage.release.complete(); + await first; + await retried; + expect(storage.writes, 1); + }, + ); +} + +class DelayedStorage extends FlutterSecureStorage { + final started = Completer(); + final release = Completer(); + int writes = 0; + @override + Future write({ + required String key, + required String? value, + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async { + writes++; + started.complete(); + await release.future; + return super.write(key: key, value: value); + } +} + +class UncertainStorage extends FlutterSecureStorage { + int writes = 0; + @override + Future write({ + required String key, + required String? value, + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async { + writes++; + await super.write(key: key, value: value); + throw StateError('plugin response lost'); + } +} + +class EnumerationFailureStorage extends FlutterSecureStorage { + @override + Future> readAll({ + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async => throw StateError('unrelated entry cannot decrypt'); +} diff --git a/mobile/test/shared/hosted/browser_auth_test.dart b/mobile/test/shared/hosted/browser_auth_test.dart new file mode 100644 index 0000000000..96c971972d --- /dev/null +++ b/mobile/test/shared/hosted/browser_auth_test.dart @@ -0,0 +1,160 @@ +import 'dart:convert'; + +import 'package:buzz/shared/hosted/browser_auth.dart'; +import 'package:buzz/shared/hosted/hosted_environment.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => FlutterSecureStorage.setMockInitialValues({})); + + test('cold-start callback restores PKCE and intent, consumes once', () async { + late Uri login; + final first = BrowserAuth( + openBrowser: (uri) async { + login = uri; + return null; + }, + ); + await first.start({ + 'relay': 'wss://example.communities.buzz.xyz', + 'code': 'invite', + }); + await first.dispose(); + final restored = BrowserAuth(); + final codes = []; + final failures = []; + restored.codes.listen(codes.add); + restored.failures.listen(failures.add); + final callback = Uri.parse(BrowserAuth.callback).replace( + queryParameters: { + 'state': login.queryParameters['state']!, + 'code': 'exchange-code', + }, + ); + await restored.receive(callback); + await restored.receive(callback); + await Future.delayed(Duration.zero); + expect(codes, hasLength(1)); + expect(failures, isEmpty); + expect(codes.single.intent['code'], 'invite'); + expect( + login.queryParameters['code_challenge'], + base64UrlEncode( + sha256.convert(ascii.encode(codes.single.verifier)).bytes, + ).replaceAll('=', ''), + ); + expect(login.queryParameters['code_challenge_method'], 'S256'); + expect(login.origin, HostedEnvironment.origin.origin); + final stored = await const FlutterSecureStorage().readAll(); + expect(stored.keys, everyElement(contains(HostedEnvironment.origin.host))); + expect(login.queryParameters['returnTo'], BrowserAuth.callback); + expect(await restored.retainedIntent(), codes.single.intent); + await restored.dispose(); + }); + + test( + 'invalid state and destination never consume the valid pending login', + () async { + late Uri login; + final auth = BrowserAuth( + openBrowser: (uri) async { + login = uri; + return null; + }, + ); + final codes = []; + final failures = []; + auth.codes.listen(codes.add); + auth.failures.listen(failures.add); + await auth.start({'relay': 'wss://example.communities.buzz.xyz'}); + final state = login.queryParameters['state']!; + for (final invalid in [ + '${BrowserAuth.callback}?code=x', + '${BrowserAuth.callback}?state=wrong&code=x', + '${BrowserAuth.callback}?state=$state&state=$state&code=x', + 'xyz.block.buzz.mobile://auth/callback?state=$state&code=x', + '${BrowserAuth.callback}/?state=$state&code=x', + '${BrowserAuth.callback}?state=$state&code=x#fragment', + ]) { + await auth.receive(Uri.parse(invalid)); + } + await Future.delayed(Duration.zero); + expect(codes, isEmpty); + expect(failures, everyElement(BrowserAuthFailure.restartLogin)); + expect(failures, isEmpty); + expect(await auth.retainedIntent(), isNotNull); + await auth.receive( + Uri.parse('${BrowserAuth.callback}?state=$state&code=good'), + ); + await Future.delayed(Duration.zero); + expect(codes.single.code, 'good'); + await auth.dispose(); + }, + ); + + test('expiry and cancel preserve destination but prevent exchange', () async { + var now = DateTime.utc(2026); + late Uri login; + final auth = BrowserAuth( + now: () => now, + openBrowser: (uri) async { + login = uri; + return null; + }, + ); + final codes = []; + auth.codes.listen(codes.add); + await auth.start({'relay': 'wss://example.communities.buzz.xyz'}); + now = now.add(const Duration(minutes: 11)); + await auth.receive( + Uri.parse( + '${BrowserAuth.callback}?state=${login.queryParameters['state']}&code=x', + ), + ); + await auth.cancel(); + now = now.subtract(const Duration(minutes: 11)); + await auth.receive( + Uri.parse( + '${BrowserAuth.callback}?state=${login.queryParameters['state']}&code=x', + ), + ); + await Future.delayed(Duration.zero); + expect(codes, isEmpty); + expect(await auth.retainedIntent(), isNotNull); + await auth.dispose(); + }); + test('old callback cannot fail a newer pending login', () async { + final logins = []; + final auth = BrowserAuth( + openBrowser: (uri) async { + logins.add(uri); + return null; + }, + ); + final failures = []; + final codes = []; + auth.failures.listen(failures.add); + auth.codes.listen(codes.add); + await auth.start({'target': 'old'}); + await auth.start({'target': 'new'}); + await auth.receive( + Uri.parse( + '${BrowserAuth.callback}?state=${logins.first.queryParameters['state']}&code=old', + ), + ); + await Future.delayed(Duration.zero); + expect(failures, isEmpty); + expect(codes, isEmpty); + await auth.receive( + Uri.parse( + '${BrowserAuth.callback}?state=${logins.last.queryParameters['state']}&code=new', + ), + ); + await Future.delayed(Duration.zero); + expect(codes.single.intent['target'], 'new'); + await auth.dispose(); + }); +} diff --git a/mobile/test/shared/hosted/builderlab_account_test.dart b/mobile/test/shared/hosted/builderlab_account_test.dart new file mode 100644 index 0000000000..de557706b7 --- /dev/null +++ b/mobile/test/shared/hosted/builderlab_account_test.dart @@ -0,0 +1,307 @@ +import 'dart:async'; +import 'hosted_http_fixture.dart'; +import 'dart:convert'; + +import 'package:buzz/shared/hosted/builderlab_account.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'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final expiry = DateTime.now() + .toUtc() + .add(const Duration(days: 1)) + .toIso8601String(); + final verifier = List.filled(43, 'a').join(); + setUp(() => FlutterSecureStorage.setMockInitialValues({})); + + test( + 'real client exchanges PKCE code, verifies, persists and restores session', + () async { + final requests = []; + final transport = MockClient((request) async { + requests.add(request); + expect(request.url.origin, 'https://app.builderlab.xyz'); + expect(request.followRedirects, isFalse); + expect(request.headers.containsKey('cookie'), isFalse); + if (request.url.path.endsWith('/exchange')) { + expect(jsonDecode(request.body), { + 'code': 'one-use', + 'code_verifier': verifier, + }); + expect( + request.headers.containsKey('X-BB-Session-Credential'), + isFalse, + ); + return http.Response( + jsonEncode({'session_credential': 'secret', 'expires_at': expiry}), + 200, + ); + } + expect(request.headers['X-BB-Session-Credential'], 'secret'); + return http.Response( + jsonEncode({'subject': 'account-1', 'expires_at': expiry}), + 200, + ); + }); + final client = BuilderlabAccountClient(httpClient: transport); + expect( + (await client.exchange(code: 'one-use', verifier: verifier)).subject, + 'account-1', + ); + final restored = BuilderlabAccountClient(httpClient: transport); + expect((await restored.restore())!.subject, 'account-1'); + expect(requests.length, 3); + await restored.clearLocalSession(); + expect( + await BuilderlabAccountClient(httpClient: transport).restore(), + isNull, + ); + }, + ); + + testWidgets('total deadline releases account queue despite trickling body', ( + tester, + ) async { + final transport = TrickleClient( + (request) => http.Response( + jsonEncode( + request.url.path.endsWith('/exchange') + ? {'session_credential': 'secret', 'expires_at': expiry} + : {'subject': 'account-1', 'expires_at': expiry}, + ), + 200, + ), + ); + final client = BuilderlabAccountClient(httpClient: transport); + addTearDown(client.dispose); + Object? failure; + var cleared = false; + unawaited( + client + .exchange(code: 'code', verifier: verifier) + .then( + (_) => fail('trickling response must not complete'), + onError: (Object error) => failure = error, + ), + ); + unawaited(client.clearLocalSession().then((_) => cleared = true)); + await tester.pump(); + await tester.pump(const Duration(seconds: 20)); + await tester.pump(const Duration(seconds: 5)); + expect(failure, isNull); + expect(cleared, isFalse); + await tester.pump(const Duration(seconds: 5)); + expect(failure, AccountFailure.unavailable); + expect(transport.aborted, isTrue); + expect(transport.cancelled, isTrue); + expect(cleared, isTrue); + transport.slow = false; + final retry = client.exchange(code: 'retry', verifier: verifier); + await tester.pump(); + expect((await retry).subject, 'account-1'); + }); + + test('invalid verification cannot persist an exchanged credential', () async { + final transport = MockClient( + (request) async => http.Response( + jsonEncode( + request.url.path.endsWith('/exchange') + ? {'session_credential': 'secret', 'expires_at': expiry} + : {'expires_at': expiry}, + ), + 200, + ), + ); + final client = BuilderlabAccountClient(httpClient: transport); + await expectLater( + client.exchange(code: 'code', verifier: verifier), + throwsA(AccountFailure.invalidResponse), + ); + expect(await client.restore(), isNull); + }); + + test('redirect and denial are not accepted as login success', () async { + for (final status in [302, 403]) { + final client = BuilderlabAccountClient( + httpClient: MockClient( + (_) async => http.Response('sensitive response', status), + ), + ); + await expectLater( + client.exchange(code: 'code', verifier: verifier), + throwsA( + status == 403 ? AccountFailure.denied : AccountFailure.unavailable, + ), + ); + expect(await client.restore(), isNull); + } + }); + const storageKey = 'buzz_hosted_session_app_builderlab_xyz_v1'; + String record(String subject, String credential) => jsonEncode({ + 'origin': 'https://app.builderlab.xyz', + 'credential': credential, + 'subject': subject, + 'expires_at': expiry, + }); + Future stored() => + const FlutterSecureStorage().read(key: storageKey); + + test('session storage is device-only on iOS', () { + expect( + BuilderlabAccountClient.defaultStorage.iOptions.accessibility, + KeychainAccessibility.first_unlock_this_device, + ); + }); + + test('callback exchange waits for an in-flight startup restore', () async { + FlutterSecureStorage.setMockInitialValues({ + storageKey: record('account-1', 'old'), + }); + final restoreVerified = Completer(); + final client = BuilderlabAccountClient( + httpClient: MockClient((request) async { + if (request.url.path.endsWith('/exchange')) { + return http.Response( + jsonEncode({'session_credential': 'new', 'expires_at': expiry}), + 200, + ); + } + if (request.headers['X-BB-Session-Credential'] == 'old') { + await restoreVerified.future; + } + return http.Response( + jsonEncode({'subject': 'account-1', 'expires_at': expiry}), + 200, + ); + }), + ); + final restoring = client.restore(); + final exchanging = client.exchange(code: 'code', verifier: verifier); + restoreVerified.complete(); + expect((await restoring)!.subject, 'account-1'); + expect((await exchanging).subject, 'account-1'); + expect(jsonDecode((await stored())!)['credential'], 'new'); + }); + + test('login for another account keeps the existing session', () async { + FlutterSecureStorage.setMockInitialValues({ + storageKey: record('account-1', 'old'), + }); + final client = BuilderlabAccountClient( + httpClient: MockClient( + (request) async => http.Response( + jsonEncode( + request.url.path.endsWith('/exchange') + ? {'session_credential': 'new', 'expires_at': expiry} + : {'subject': 'account-2', 'expires_at': expiry}, + ), + 200, + ), + ), + ); + await expectLater( + client.exchange(code: 'code', verifier: verifier), + throwsA(AccountFailure.differentAccount), + ); + expect(client.account, isNull); + expect(await stored(), record('account-1', 'old')); + }); + + test('only a rejected credential clears a stored session', () async { + for (final (status, failure) in [ + (401, null), + (500, AccountFailure.unavailable), + (403, AccountFailure.denied), + ]) { + FlutterSecureStorage.setMockInitialValues({ + storageKey: record('account-1', 'old'), + }); + final client = BuilderlabAccountClient( + httpClient: MockClient((_) async => http.Response('{}', status)), + ); + if (failure == null) { + expect(await client.restore(), isNull); + expect(await stored(), isNull); + } else { + await expectLater(client.restore(), throwsA(failure)); + expect(await stored(), record('account-1', 'old')); + } + } + }); + + test( + 'unreadable record is a storage failure, not an absent session', + () async { + FlutterSecureStorage.setMockInitialValues({storageKey: 'not json'}); + final client = BuilderlabAccountClient( + httpClient: MockClient((_) async => fail('no request expected')), + ); + await expectLater(client.restore(), throwsA(AccountFailure.storage)); + await expectLater( + client.exchange(code: 'code', verifier: verifier), + throwsA(AccountFailure.storage), + ); + await client.clearLocalSession(); + expect(await client.restore(), isNull); + }, + ); + + test('rejected identity read clears the session', () async { + var sessionRevoked = false; + final client = BuilderlabAccountClient( + httpClient: MockClient((request) async { + if (request.url.path.endsWith('/current')) { + return http.Response('{}', 401); + } + if (request.url.path.endsWith('/me') && + request.headers['X-BB-Session-Credential'] == 'secret' && + sessionRevoked) { + return http.Response('{}', 401); + } + return http.Response( + jsonEncode( + request.url.path.endsWith('/exchange') + ? {'session_credential': 'secret', 'expires_at': expiry} + : {'subject': 'account-1', 'expires_at': expiry}, + ), + 200, + ); + }), + ); + await client.exchange(code: 'code', verifier: verifier); + // A route-level 401 with a valid session is an authorization failure. + await expectLater(client.currentIdentity(), throwsA(AccountFailure.denied)); + expect(client.account, isNotNull); + expect(await stored(), isNotNull); + sessionRevoked = true; + await expectLater( + client.currentIdentity(), + throwsA(AccountFailure.signedOut), + ); + expect(client.account, isNull); + expect(await stored(), isNull); + }); + + test('authenticated post accepts only Buzz account routes', () async { + final client = BuilderlabAccountClient( + httpClient: MockClient((_) async => fail('no request expected')), + ); + for (final path in [ + '/v1/auth/me', + '/v1/buzz', + '/v1/buzz/../auth/me', + '/v1/buzz/nsec-backups?x=1', + 'https://evil.example/v1/buzz/nsec-backups', + '//evil.example/v1/buzz/nsec-backups', + ]) { + expect(() => client.post(path, {}), throwsArgumentError, reason: path); + } + await expectLater( + client.post('/v1/buzz/nsec-backups/enrollment', {}), + throwsA(AccountFailure.signedOut), + ); + }); +} diff --git a/mobile/test/shared/hosted/device_identity_reconciliation_test.dart b/mobile/test/shared/hosted/device_identity_reconciliation_test.dart new file mode 100644 index 0000000000..7c5d578078 --- /dev/null +++ b/mobile/test/shared/hosted/device_identity_reconciliation_test.dart @@ -0,0 +1,274 @@ +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:nostr/nostr.dart' as nostr; + +class InventoryAccount extends BuilderlabAccountClient { + String? bound; + List backups = []; + Map recovery = {}; + final calls = []; + @override + BuilderlabAccount get account => + BuilderlabAccount(subject: 'fixture', expiresAt: DateTime.utc(2100)); + @override + Future restore() async => account; + @override + Future> currentIdentity() async => { + if (bound != null) 'identity': {'pubkey_hex': bound}, + }; + @override + Future> post( + String path, + Map body, + ) async { + calls.add(path); + if (path.endsWith('/status')) { + return {'current_nostr_pubkey': bound, 'backup_nostr_pubkeys': backups}; + } + if (path.endsWith('/recovery')) return recovery; + throw AccountFailure.denied; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final first = nostr.Keys('1'.padLeft(64, '0')); + final second = nostr.Keys('2'.padLeft(64, '0')); + const secure = FlutterSecureStorage(); + late InventoryAccount account; + late AccountIdentityStore store; + late HostedIdentityService service; + Map record(nostr.Keys key, String name) => { + 'nsec': key.nsec, + 'pubkey': key.public, + 'name': name, + }; + setUp(() { + FlutterSecureStorage.setMockInitialValues({}); + account = InventoryAccount(); + store = AccountIdentityStore(subject: 'fixture'); + service = HostedIdentityService( + account: account, + store: store, + deviceIdentities: DeviceIdentitySource(), + ); + }); + tearDown(() => account.dispose()); + + for (final scenario in [ + ( + name: 'unbound', + bound: false, + backups: [], + state: HostedIdentityState.needsBinding, + ), + ( + name: 'bound with matching backup', + bound: true, + backups: [first.public], + state: HostedIdentityState.needsBackup, + ), + ( + name: 'bound with other backup', + bound: true, + backups: [first.public, second.public], + state: HostedIdentityState.needsBackup, + ), + ]) { + test('device key reconciliation: ${scenario.name}', () async { + final raw = jsonEncode([record(first, 'Old community')]); + await secure.write(key: 'buzz_workspaces', value: raw); + account.bound = scenario.bound ? first.public : null; + account.backups = scenario.backups; + if (!scenario.bound) { + expect( + (await service.resolve()).state, + HostedIdentityState.needsSelection, + ); + expect(await store.read(), isNull); + } + final result = await service.resolve(selectedPubkey: first.public); + expect(result.state, scenario.state); + expect(result.pubkey, first.public); + expect((await store.read())?.pubkey, first.public); + expect(await secure.read(key: 'buzz_workspaces'), raw); + expect(await secure.read(key: 'buzz_communities'), isNull); + expect(account.calls.every((path) => path.endsWith('/status')), isTrue); + }); + } + + test( + 'multiple current and hidden legacy keys require choice; no writes before choice', + () async { + await secure.write( + key: 'buzz_communities', + value: jsonEncode([record(first, 'Current')]), + ); + await secure.write(key: 'buzz_nsec', value: second.nsec); + final before = await secure.readAll(); + final pending = await service.resolve(); + expect(pending.state, HostedIdentityState.needsSelection); + expect(pending.choices.map((item) => item.pubkey), [ + first.public, + second.public, + ]); + expect(await secure.readAll(), before); + final selected = await service.resolve(selectedPubkey: second.public); + expect(selected.pubkey, second.public); + for (final entry in before.entries) { + expect(await secure.read(key: entry.key), entry.value); + } + expect(account.calls.every((path) => path.endsWith('/status')), isTrue); + expect( + (await service.resolve(selectedPubkey: first.public)).pubkey, + second.public, + ); + }, + ); + + test('duplicate device identities offer one explicit reuse choice', () async { + FlutterSecureStorage.setMockInitialValues({ + 'buzz_nsec': first.nsec, + 'buzz_communities': jsonEncode([record(first, 'Same identity')]), + }); + expect((await service.resolve()).choices.map((item) => item.pubkey), [ + first.public, + ]); + expect( + (await service.resolve(selectedPubkey: first.public)).pubkey, + first.public, + ); + }); + + test( + 'corrupt, incomplete and mismatched legacy records never authorize generation', + () async { + for (final data in [ + {'buzz_workspaces': '{broken'}, + {'buzz_nsec': ''}, + { + 'buzz_communities': jsonEncode([ + {'nsec': first.nsec, 'pubkey': second.public}, + ]), + }, + ]) { + FlutterSecureStorage.setMockInitialValues(data); + await expectLater( + service.resolve(), + throwsA(AccountIdentityFailure.unreadable), + ); + expect(await secure.readAll(), data); + } + // Another account's scoped key must remain a choice, even when an + // unrelated Android entry prevents bulk enumeration. Never mint a key + // from that incomplete inventory; retry after storage recovers. + FlutterSecureStorage.setMockInitialValues({}); + final storage = _InventoryStorage(); + store = AccountIdentityStore(storage: storage, subject: 'fixture'); + await store.forSubject('other-account').create(first.nsec); + service = HostedIdentityService(account: account, store: store); + final before = await secure.readAll(); + expect( + (await service.resolve()).state, + HostedIdentityState.needsSelection, + ); + storage.failEnumeration = true; + await expectLater( + service.resolve(), + throwsA(AccountIdentityFailure.unreadable), + ); + expect(await secure.readAll(), before); + expect(await store.read(), isNull); + storage.failEnumeration = false; + expect((await service.resolve()).choices.single.pubkey, first.public); + expect( + (await service.resolve(selectedPubkey: first.public)).pubkey, + first.public, + ); + expect( + (await store.forSubject('other-account').read())!.pubkey, + first.public, + ); + }, + ); + + test( + 'token-only legacy records do not prevent recovering the same cloud identity', + () async { + final raw = jsonEncode([ + {'pubkey': first.public, 'name': 'Token-only community'}, + ]); + await secure.write(key: 'buzz_communities', value: raw); + await secure.write(key: 'buzz_pubkey', value: first.public); + account.bound = first.public; + account.backups = [first.public]; + account.recovery = { + 'backups': [ + { + 'backup_id': 'fixture-backup', + 'nostr_pubkey': first.public, + 'secret_key': base64UrlEncode([ + ...List.filled(31, 0), + 1, + ]).replaceAll('=', ''), + }, + ], + 'failed_backup_ids': [], + }; + final result = await service.resolve(); + expect(result.state, HostedIdentityState.ready); + expect(result.pubkey, first.public); + expect(account.calls, [ + '/v1/buzz/nsec-backups/status', + '/v1/buzz/nsec-backups/recovery', + ]); + expect(await secure.read(key: 'buzz_communities'), raw); + expect(await secure.read(key: 'buzz_pubkey'), first.public); + }, + ); + + test( + 'inventory labels are deduplicated and empty names get a fallback', + () async { + final raw = jsonEncode([record(first, 'Team'), record(second, ' ')]); + await secure.write(key: 'buzz_communities', value: raw); + await secure.write(key: 'buzz_workspaces', value: raw); + final choices = await DeviceIdentitySource().choices(); + expect(choices.map((choice) => choice.label), [ + 'Team', + 'Saved community', + ]); + }, + ); + + test('cancelled resolution cannot adopt or generate', () async { + await secure.write(key: 'buzz_nsec', value: first.nsec); + await expectLater( + service.resolve(isCurrent: () => false), + throwsA(AccountFailure.unavailable), + ); + expect(await store.read(), isNull); + }); +} + +class _InventoryStorage extends FlutterSecureStorage { + bool failEnumeration = false; + @override + Future> readAll({ + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async { + if (failEnumeration) throw StateError('unrelated entry cannot decrypt'); + return super.readAll(); + } +} diff --git a/mobile/test/shared/hosted/hosted_admission_test.dart b/mobile/test/shared/hosted/hosted_admission_test.dart new file mode 100644 index 0000000000..ca5dfa1d1f --- /dev/null +++ b/mobile/test/shared/hosted/hosted_admission_test.dart @@ -0,0 +1,276 @@ +import 'package:buzz/shared/hosted/hosted_relay_auth.dart'; +import 'dart:async'; +import 'hosted_http_fixture.dart'; +import 'package:buzz/shared/hosted/hosted_join_policy.dart'; +import 'dart:convert'; + +import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:buzz/shared/hosted/account_identity_store.dart'; +import 'package:buzz/shared/hosted/builderlab_account.dart'; +import 'package:buzz/shared/hosted/hosted_admission.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(); + final keys = nostr.Keys( + '0000000000000000000000000000000000000000000000000000000000000001', + ); + const invite = InviteDeepLink( + relayUrl: 'wss://example.communities.buzz.xyz', + code: 'invite', + policyReceipt: 'policy', + ); + late _Account account; + late AccountIdentityStore store; + late List claims; + late HostedAdmission admission; + setUp(() { + FlutterSecureStorage.setMockInitialValues({}); + account = _Account(keys.public); + store = AccountIdentityStore(subject: 'owner'); + claims = []; + admission = HostedAdmission( + account: account, + relayAuth: HostedRelayAuth(account, enabled: false), + store: store, + probe: (_) async {}, + client: MockClient((request) async { + if (request.method == 'POST') claims.add(request); + return http.Response('{}', 200); + }), + ); + }); + tearDown(() { + admission.dispose(); + account.dispose(); + }); + Future backup() async => store.confirmBackup( + BackupReceipt( + backend: BuilderlabAccountClient.origin.origin, + subject: 'owner', + pubkey: keys.public, + backupId: 'backup', + ), + ); + + test('no claim without scoped backup or current matching binding', () async { + await store.create(keys.nsec); + await expectLater( + admission.prepare(invite, 'owner', keys.public), + throwsA(AccountFailure.denied), + ); + expect(account.bindingReads, 0); + expect(claims, isEmpty); + await backup(); + account.bound = 'a' * 64; + await expectLater( + admission.prepare(invite, 'owner', keys.public), + throwsA(AccountFailure.denied), + ); + expect(account.bindingReads, 1); + expect(claims, isEmpty); + }); + + test('claim signature and returned identity use resolved key', () async { + await store.create(keys.nsec); + await backup(); + final community = await admission.prepare(invite, 'owner', keys.public); + expect(claims, hasLength(1)); + final request = claims.single; + final auth = request.headers['Authorization']!; + expect(auth.startsWith('Nostr '), isTrue); + final event = nostr.Event.fromJson( + utf8.decode(base64.decode(auth.substring(6))), + verify: true, + ); + expect(event.pubkey, keys.public); + expect(event.kind, 27235); + expect( + event.tags.map((tag) => tag.toList()).toList(), + contains( + equals(['u', 'https://example.communities.buzz.xyz/api/invites/claim']), + ), + ); + expect( + event.tags.map((tag) => tag.toList()).toList(), + contains(equals(['method', 'POST'])), + ); + expect(jsonDecode(request.body), {'code': 'invite'}); + expect(community.pubkey, keys.public); + expect(community.nsec, keys.nsec); + expect(request.followRedirects, isFalse); + }); + + // Wire limits stay focused; consent/claim ordering is covered by the journey. + for (final (name, response, status, failure) in [ + ('redirect', '{}', 302, AccountFailure.denied), + ('oversized policy', ' ' * 65537, 200, AccountFailure.invalidResponse), + ('invalid JSON', 'not JSON', 200, AccountFailure.invalidResponse), + ('wrong shape', '[]', 200, AccountFailure.invalidResponse), + ]) { + test('$name cannot reach a claim', () async { + await store.create(keys.nsec); + await backup(); + admission.dispose(); + admission = HostedAdmission( + account: account, + relayAuth: HostedRelayAuth(account, enabled: false), + store: store, + probe: (_) async {}, + client: MockClient((request) async { + expect(request.method, 'GET'); + expect(request.followRedirects, isFalse); + expect( + request.headers.containsKey('X-BB-Session-Credential'), + isFalse, + ); + return http.Response(response, status); + }), + ); + await expectLater( + admission.prepare(invite, 'owner', keys.public), + throwsA(failure), + ); + }); + } + + testWidgets('total policy deadline cancels body and permits policy retry', ( + tester, + ) async { + final transport = TrickleClient((_) => http.Response('{}', 200)); + Future policy() => freshJoinPolicyReceipt( + client: transport, + origin: Uri.parse(invite.relayUrl), + code: invite.code, + confirm: (_) async => + fail('incomplete policy must never request consent'), + checkCurrent: () {}, + ); + Object? failure; + unawaited( + policy().then( + (_) => fail('trickling policy must not complete'), + onError: (Object error) { + failure = error; + }, + ), + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 20)); + await tester.pump(const Duration(seconds: 5)); + expect(failure, isNull); + await tester.pump(const Duration(seconds: 5)); + expect(failure, isA()); + expect(transport.aborted, isTrue); + expect(transport.cancelled, isTrue); + transport.slow = false; + final retry = policy(); + await tester.pump(); + expect(await retry, isNull); + }); + + test('cancellation during binding prevents external claim', () async { + await store.create(keys.nsec); + await backup(); + var current = true; + account.onBinding = () => current = false; + await expectLater( + admission.prepare(invite, 'owner', keys.public, isCurrent: () => current), + throwsA(AccountFailure.unavailable), + ); + expect(claims, isEmpty); + }); + + test('cancelled or rejected relay probe never returns community', () async { + await store.create(keys.nsec); + await backup(); + final pending = Completer(); + final started = Completer(); + var current = true; + final delayed = HostedAdmission( + account: account, + relayAuth: HostedRelayAuth(account, enabled: false), + store: store, + probe: (_) { + started.complete(); + return pending.future; + }, + ); + const link = AddCommunityDeepLink( + relayUrl: 'wss://example.communities.buzz.xyz', + name: 'Wes', + ); + final result = delayed.prepare( + link, + 'owner', + keys.public, + isCurrent: () => current, + ); + final expectation = expectLater( + result, + throwsA(AccountFailure.unavailable), + ); + await started.future; + current = false; + pending.complete(); + await expectation; + delayed.dispose(); + final rejected = HostedAdmission( + account: account, + relayAuth: HostedRelayAuth(account, enabled: false), + store: store, + probe: (_) async { + throw AccountFailure.denied; + }, + ); + await expectLater( + rejected.prepare(link, 'owner', keys.public), + throwsA(AccountFailure.denied), + ); + rejected.dispose(); + expect(claims, isEmpty); + }); + + test('different account and untrusted host cannot claim', () async { + await store.create(keys.nsec); + await backup(); + await expectLater( + admission.prepare(invite, 'other', keys.public), + throwsA(AccountFailure.differentAccount), + ); + await expectLater( + admission.prepare( + const InviteDeepLink( + relayUrl: 'wss://communities.buzz.xyz.attacker.test', + code: 'i', + ), + 'owner', + keys.public, + ), + throwsA(AccountFailure.denied), + ); + expect(claims, isEmpty); + }); +} + +class _Account extends BuilderlabAccountClient { + _Account(this.bound); + String bound; + int bindingReads = 0; + void Function()? onBinding; + @override + BuilderlabAccount get account => + BuilderlabAccount(subject: 'owner', expiresAt: DateTime.utc(2100)); + @override + Future> currentIdentity() async { + bindingReads++; + onBinding?.call(); + return { + 'identity': {'pubkey_hex': bound}, + }; + } +} diff --git a/mobile/test/shared/hosted/hosted_backup_service_test.dart b/mobile/test/shared/hosted/hosted_backup_service_test.dart new file mode 100644 index 0000000000..eeb3123096 --- /dev/null +++ b/mobile/test/shared/hosted/hosted_backup_service_test.dart @@ -0,0 +1,178 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:buzz/shared/hosted/account_identity_store.dart'; +import 'package:buzz/shared/hosted/builderlab_account.dart'; +import 'package:buzz/shared/hosted/hosted_backup_service.dart'; +import 'package:buzz/shared/hosted/hpke_backup.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; + +class FakeAccount extends BuilderlabAccountClient { + FakeAccount(this.envelope); + final Map envelope; + int enrollments = 0; + final uploads = []; + bool fail = true; + bool reject = false; + bool rejectEnrollment = false; + String subject = 'subject'; + @override + BuilderlabAccount get account => + BuilderlabAccount(subject: subject, expiresAt: DateTime(2100)); + @override + Future> post( + String path, + Map body, + ) async { + if (path.endsWith('/enrollment')) { + enrollments++; + if (rejectEnrollment) throw AccountFailure.rejected; + return envelope; + } + uploads.add(jsonEncode(body)); + expect( + await const FlutterSecureStorage().read( + key: HostedBackupService.pendingKey( + subject, + envelope['nostr_pubkey'] as String, + ), + ), + isNotNull, + ); + if (reject) throw AccountFailure.rejected; + if (fail) throw AccountFailure.unavailable; + return {'stored': true, 'backup_id': envelope['backup_id']}; + } +} + +class FakeHpke extends HpkeBackup { + FakeHpke(this.envelope); + final Map envelope; + int calls = 0; + @override + Future> seal({ + required Map enrollment, + required String secretHex, + }) async { + calls++; + return envelope; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late Map envelope; + setUp(() { + FlutterSecureStorage.setMockInitialValues({}); + final fixture = + jsonDecode( + File( + '../desktop/src-tauri/src/testdata/hpke_nsec_backup_v1.json', + ).readAsStringSync(), + ) + as Map; + envelope = Map.from(fixture['envelope'] as Map); + }); + test( + 'uncertain upload replays persisted envelope after restart and receipt prevents further upload', + () async { + final identity = AccountIdentityStore(); + await identity.create(nostr.Keys('42' * 32).nsec); + final account = FakeAccount(envelope); + final hpke = FakeHpke(envelope); + await expectLater( + HostedBackupService(account, identity, hpke: hpke).backup(), + throwsA(AccountFailure.unavailable), + ); + expect((await identity.read())!.backup, isNull); + account.fail = false; + await HostedBackupService( + account, + AccountIdentityStore(), + hpke: hpke, + ).backup(); + expect(account.enrollments, 1); + expect(hpke.calls, 1); + expect(account.uploads, hasLength(2)); + expect(account.uploads.first, account.uploads.last); + expect((await identity.read())!.backup!.backupId, envelope['backup_id']); + await HostedBackupService(account, identity, hpke: hpke).backup(); + expect(account.uploads, hasLength(2)); + }, + ); + test('unreadable pending ciphertext blocks enrollment and upload', () async { + final identity = AccountIdentityStore(); + await identity.create(nostr.Keys('42' * 32).nsec); + await const FlutterSecureStorage().write( + key: HostedBackupService.pendingKey( + 'subject', + envelope['nostr_pubkey'] as String, + ), + value: 'invalid', + ); + final account = FakeAccount(envelope); + await expectLater( + HostedBackupService(account, identity).backup(), + throwsA(HostedBackupFailure.storage), + ); + expect(account.enrollments, 0); + expect(account.uploads, isEmpty); + }); + test( + 'definitive rejection permits only one re-enrollment per attempt', + () async { + final identity = AccountIdentityStore(); + await identity.create(nostr.Keys('42' * 32).nsec); + final account = FakeAccount(envelope)..reject = true; + await expectLater( + HostedBackupService( + account, + identity, + hpke: FakeHpke(envelope), + ).backup(), + throwsA(HostedBackupFailure.rejected), + ); + expect(account.enrollments, 2); + expect(account.uploads, hasLength(2)); + expect((await identity.read())!.backup, isNull); + }, + ); + test('enrollment rejection is terminal without upload', () async { + final identity = AccountIdentityStore(); + await identity.create(nostr.Keys('42' * 32).nsec); + final account = FakeAccount(envelope)..rejectEnrollment = true; + await expectLater( + HostedBackupService(account, identity).backup(), + throwsA(HostedBackupFailure.unavailable), + ); + expect(account.enrollments, 1); + expect(account.uploads, isEmpty); + }); + test( + 'another subject pending envelope neither blocks nor gets replaced', + () async { + final identity = AccountIdentityStore(); + await identity.create(nostr.Keys('42' * 32).nsec); + final oldKey = HostedBackupService.pendingKey( + 'other', + envelope['nostr_pubkey'] as String, + ); + await const FlutterSecureStorage().write( + key: oldKey, + value: 'old pending', + ); + final account = FakeAccount(envelope)..fail = false; + await HostedBackupService( + account, + identity, + hpke: FakeHpke(envelope), + ).backup(); + expect( + await const FlutterSecureStorage().read(key: oldKey), + 'old pending', + ); + expect(account.enrollments, 1); + }, + ); +} diff --git a/mobile/test/shared/hosted/hosted_communities_flow_test.dart b/mobile/test/shared/hosted/hosted_communities_flow_test.dart new file mode 100644 index 0000000000..1fa6d43125 --- /dev/null +++ b/mobile/test/shared/hosted/hosted_communities_flow_test.dart @@ -0,0 +1,91 @@ +import 'dart:async'; + +import 'package:buzz/shared/hosted/builderlab_account.dart'; +import 'package:buzz/shared/hosted/browser_auth.dart'; +import 'package:buzz/shared/hosted/account_identity_store.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 '../../helpers/hosted_communities_fixture.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => FlutterSecureStorage.setMockInitialValues({})); + + Future fixture({bool backup = true}) async { + final result = HostedCommunitiesFixture(); + await result.initialize(backup: backup); + addTearDown(result.dispose); + return result; + } + + for (final failure in ['binding', 'backup']) { + test( + '$failure proof failure blocks discovery and preserves saved identity', + () async { + final f = await fixture(backup: failure != 'backup'); + if (failure == 'binding') f.bound = '2' * 64; + final before = await const FlutterSecureStorage().readAll(); + await f.flow.signInAccount(); + expect(f.flow.stage, HostedFlowStage.blocked); + if (failure == 'binding') { + expect(f.flow.problem, HostedFlowProblem.identityMismatch); + } + expect( + f.requests.any((path) => path.endsWith('/communities/joined')), + isFalse, + ); + expect(await const FlutterSecureStorage().readAll(), before); + expect(f.admitted, isEmpty); + }, + ); + } + + test('malformed list targets cannot authorize admission', () async { + final f = await fixture(); + f.rows = [ + {...f.row('one'), 'normalized_host': 'wss://user@evil.example/path'}, + ]; + await f.flow.signInAccount(); + expect(f.flow.problem, HostedFlowProblem.invalidResponse); + expect(f.admitted, isEmpty); + }); + test('expired A batch is discarded after browser login as B', () async { + final f = await fixture(); + await f.flow.initialize(); + f.rows = [f.row('a-one'), f.row('a-two')]; + f.beforeAdmission = (name) async { + if (name == 'a-two') throw AccountFailure.unavailable; + }; + await f.flow.signInAccount(); + expect(f.admitted, ['a-one']); + f.expired = true; + await f.flow.retry(); + expect(f.flow.stage, HostedFlowStage.signIn); + expect(f.browser.startedIntent, {'type': 'account'}); + f.expired = false; + f.subject = 'other-owner'; + f.rows = [f.row('b-one')]; + await f.store.create(f.keys.nsec); + await f.store.confirmBackup( + BackupReceipt( + backend: BuilderlabAccountClient.origin.toString(), + subject: f.subject, + pubkey: f.keys.public, + backupId: 'other-backup', + ), + ); + final completed = Completer(); + f.flow.addListener(() { + if (f.flow.stage == HostedFlowStage.complete && !completed.isCompleted) { + completed.complete(); + } + }); + f.browser.callbacks.add( + BrowserAuthCode('other-code', 'a' * 43, {'type': 'account'}), + ); + await completed.future.timeout(const Duration(seconds: 2)); + expect(f.admitted, ['a-one', 'b-one']); + }); +} diff --git a/mobile/test/shared/hosted/hosted_community_connection_test.dart b/mobile/test/shared/hosted/hosted_community_connection_test.dart new file mode 100644 index 0000000000..1f487c034c --- /dev/null +++ b/mobile/test/shared/hosted/hosted_community_connection_test.dart @@ -0,0 +1,87 @@ +import 'package:buzz/shared/hosted/hosted_relay_auth.dart'; +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/community/community_storage.dart'; +import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:buzz/shared/hosted/hosted_admission.dart'; +import 'package:buzz/shared/hosted/hosted_community_connection.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import '../../helpers/hosted_communities_fixture.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => FlutterSecureStorage.setMockInitialValues({})); + + for (final conflicting in [false, true]) { + test( + 'real admission preserves saved :443 record (conflict=$conflicting)', + () async { + final f = HostedCommunitiesFixture(); + await f.initialize(); + addTearDown(f.dispose); + final storage = CommunityStorage(); + final savedKeys = conflicting + ? nostr.Keys('2'.padLeft(64, '0')) + : f.keys; + final existing = Community.create( + name: 'Saved name', + relayUrl: 'wss://one.communities.buzz.xyz:443/', + pubkey: savedKeys.public, + nsec: savedKeys.nsec, + ); + await storage.save(existing); + final unrelated = Community.create( + name: 'Other', + relayUrl: 'wss://other.example', + pubkey: savedKeys.public, + nsec: savedKeys.nsec, + ); + await storage.save(unrelated); + var probes = 0; + final admission = HostedAdmission( + account: f.account, + relayAuth: HostedRelayAuth(f.account, enabled: false), + store: f.store, + probe: (_) async { + probes++; + }, + ); + addTearDown(admission.dispose); + final operation = connectHostedCommunity( + link: const AddCommunityDeepLink( + relayUrl: 'wss://one.communities.buzz.xyz', + name: 'one', + ), + subject: 'owner', + pubkey: f.keys.public, + isCurrent: () => true, + storage: storage, + admission: admission, + recoverStarterChannels: (_, _) async => + fail('Saved non-invite community needs no starter repair'), + persist: (community, current) async { + if (current()) await storage.save(community); + }, + ); + if (conflicting) { + await expectLater( + operation, + throwsA(isA()), + ); + expect(probes, 0); + } else { + await operation; + expect(probes, 1); + } + final after = await storage.loadAll(); + expect(after.length, 2); + expect(after.first.id, existing.id); + expect(after.first.name, existing.name); + expect(after.first.nsec, savedKeys.nsec); + expect(after.last.toJson(), unrelated.toJson()); + }, + ); + } +} diff --git a/mobile/test/shared/hosted/hosted_environment_flow_test.dart b/mobile/test/shared/hosted/hosted_environment_flow_test.dart new file mode 100644 index 0000000000..1b40094eda --- /dev/null +++ b/mobile/test/shared/hosted/hosted_environment_flow_test.dart @@ -0,0 +1,124 @@ +import 'package:buzz/shared/hosted/hosted_relay_auth.dart'; +import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:buzz/shared/hosted/account_identity_store.dart'; +import 'package:buzz/shared/hosted/browser_auth.dart'; +import 'package:buzz/shared/hosted/builderlab_account.dart'; +import 'package:buzz/shared/hosted/hosted_admission.dart'; +import 'package:buzz/shared/hosted/hosted_environment.dart'; +import 'package:buzz/shared/hosted/hosted_flow.dart'; +import 'package:buzz/shared/hosted/hosted_identity_service.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:nostr/nostr.dart' as nostr; + +class _Account extends BuilderlabAccountClient { + _Account(this.pubkey); + final String pubkey; + var reads = 0; + @override + BuilderlabAccount get account => + BuilderlabAccount(subject: 'owner', expiresAt: DateTime.utc(2100)); + @override + Future restore() async { + reads++; + return account; + } + + @override + Future> currentIdentity() async { + reads++; + return { + 'identity': {'pubkey_hex': pubkey}, + }; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => FlutterSecureStorage.setMockInitialValues({})); + test( + 'real flow and admission use only the configured community realm', + () async { + final staging = + HostedEnvironment.origin.host == 'app.builderlabstaging.xyz'; + final matching = staging + ? 'team.communities.public.builderlabstaging.xyz' + : 'team.communities.buzz.xyz'; + final opposite = staging + ? 'team.communities.buzz.xyz' + : 'team.communities.public.builderlabstaging.xyz'; + final keys = nostr.Keys('1'.padLeft(64, '0')); + final account = _Account(keys.public); + final store = AccountIdentityStore(subject: 'owner'); + await store.create(keys.nsec); + await store.confirmBackup( + BackupReceipt( + backend: HostedEnvironment.origin.origin, + subject: 'owner', + pubkey: keys.public, + backupId: 'receipt', + ), + ); + final requests = []; + final admission = HostedAdmission( + account: account, + relayAuth: HostedRelayAuth(account, enabled: false), + store: store, + probe: (_) async {}, + client: MockClient((request) async { + requests.add(request); + return http.Response('{}', 200); + }), + ); + final browser = BrowserAuth( + openBrowser: (_) async => fail('Saved account must not open browser'), + ); + var entered = 0; + final flow = HostedFlow( + account: account, + browser: browser, + store: store, + identity: HostedIdentityService(account: account, store: store), + enterCommunity: (link, subject, pubkey, isCurrent) async { + await admission.prepare(link, subject, pubkey, isCurrent: isCurrent); + entered++; + }, + ); + addTearDown(() async { + flow.dispose(); + await browser.dispose(); + account.dispose(); + admission.dispose(); + }); + await flow.start( + InviteDeepLink(relayUrl: 'wss://$opposite', code: 'test'), + ); + expect(flow.problem, HostedFlowProblem.unsupportedEnvironment); + expect(account.reads, 0); + expect(requests, isEmpty); + // Admission independently enforces the boundary, even without the UI/flow. + await expectLater( + admission.prepare( + InviteDeepLink(relayUrl: 'wss://$opposite', code: 'test'), + 'owner', + keys.public, + ), + throwsA(AccountFailure.denied), + ); + expect(account.reads, 0); + await flow.start( + InviteDeepLink(relayUrl: 'wss://$matching', code: 'test'), + ); + expect(flow.stage, HostedFlowStage.complete); + expect(entered, 1); + expect(requests.map((request) => request.url.host), [matching, matching]); + expect(requests.map((request) => request.method), ['GET', 'POST']); + expect(requests.map((request) => request.url.path), [ + '/api/join-policy', + '/api/invites/claim', + ]); + }, + ); +} diff --git a/mobile/test/shared/hosted/hosted_environment_test.dart b/mobile/test/shared/hosted/hosted_environment_test.dart new file mode 100644 index 0000000000..8afc89f813 --- /dev/null +++ b/mobile/test/shared/hosted/hosted_environment_test.dart @@ -0,0 +1,61 @@ +import 'package:buzz/shared/hosted/hosted_environment.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('community realms map only to their trusted account origin', () { + expect( + HostedEnvironment.accountOriginFor( + Uri.parse('wss://team.communities.buzz.xyz'), + ), + Uri.parse('https://app.builderlab.xyz'), + ); + expect( + HostedEnvironment.accountOriginFor( + Uri.parse('wss://team.communities.public.builderlabstaging.xyz'), + ), + Uri.parse('https://app.builderlabstaging.xyz'), + ); + for (final value in [ + 'wss://team.communities.public.builderlabstaging.xyz.attacker.test', + 'wss://communities.public.builderlabstaging.xyz', + 'wss://team.communities.public.builderlabstaging.xyz.', + 'wss://team.communities.public.builderlabstaging.xyz:8443', + 'ws://team.communities.public.builderlabstaging.xyz', + 'wss://user@team.communities.public.builderlabstaging.xyz', + 'wss://team.communities.public.builderlabstaging.xyz/path', + 'wss://team.communities.public.builderlabstaging.xyz?account=other', + 'wss://team.communities.public.builderlabstaging.xyz#fragment', + 'wss://team.builderlabstaging.xyz', + ]) { + expect( + HostedEnvironment.accountOriginFor(Uri.parse(value)), + isNull, + reason: value, + ); + } + }); + + test('authentication follows only configured DNS domain boundaries', () { + expect(HostedEnvironment.enabled, HostedEnvironment.authDomains.isNotEmpty); + for (final domain in HostedEnvironment.authDomains) { + for (final host in [ + domain, + 'team.$domain', + 'TEAM.${domain.toUpperCase()}.', + ]) { + expect(HostedEnvironment.requiresAuth(host), isTrue, reason: host); + } + expect(HostedEnvironment.requiresAuth('$domain.attacker.test'), isFalse); + expect(HostedEnvironment.requiresAuth('not$domain'), isFalse); + } + if (HostedEnvironment.authDomains.isEmpty) { + for (final host in [ + 'buzz.xyz', + 'team.communities.buzz.xyz', + 'team.communities.public.builderlabstaging.xyz', + ]) { + expect(HostedEnvironment.requiresAuth(host), isFalse); + } + } + }); +} diff --git a/mobile/test/shared/hosted/hosted_http_fixture.dart b/mobile/test/shared/hosted/hosted_http_fixture.dart new file mode 100644 index 0000000000..e3f6eef012 --- /dev/null +++ b/mobile/test/shared/hosted/hosted_http_fixture.dart @@ -0,0 +1,38 @@ +import 'dart:async'; + +import 'package:http/http.dart' as http; + +/// A transport that keeps every idle interval short but exceeds total duration. +/// It deliberately ignores abort so the caller must also cancel its reader. +class TrickleClient extends http.BaseClient { + TrickleClient(this.respond); + final http.Response Function(http.BaseRequest) respond; + bool slow = true; + bool aborted = false; + bool cancelled = false; + + @override + Future send(http.BaseRequest request) async { + await request.finalize().drain(); + if (!slow) { + final response = respond(request); + return http.StreamedResponse( + Stream.value(response.bodyBytes), + response.statusCode, + ); + } + if (request is http.Abortable) { + unawaited(request.abortTrigger?.then((_) => aborted = true)); + } + await Future.delayed(const Duration(seconds: 20)); + Timer? timer; + final stream = StreamController>( + onCancel: () { + cancelled = true; + timer?.cancel(); + }, + ); + timer = Timer.periodic(const Duration(seconds: 5), (_) => stream.add([32])); + return http.StreamedResponse(stream.stream, 200); + } +} diff --git a/mobile/test/shared/hosted/hosted_invite_starter_recovery_test.dart b/mobile/test/shared/hosted/hosted_invite_starter_recovery_test.dart new file mode 100644 index 0000000000..7091a263ca --- /dev/null +++ b/mobile/test/shared/hosted/hosted_invite_starter_recovery_test.dart @@ -0,0 +1,197 @@ +import 'package:buzz/shared/hosted/hosted_relay_auth.dart'; +import 'package:buzz/features/invites/invite_join_provider.dart'; +import 'package:buzz/shared/hosted/hosted_flow_provider.dart'; +import 'package:buzz/shared/auth/auth.dart'; +import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:buzz/shared/hosted/builderlab_account.dart'; +import 'package:buzz/shared/hosted/hosted_admission.dart'; +import 'package:buzz/shared/hosted/hosted_community_connection.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:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import '../../helpers/hosted_communities_fixture.dart'; + +final _finishProvider = Provider( + (ref) => + (Community community, bool Function() current) => + finishHostedStarterSetup(ref, community, current), +); + +class _Recovery implements InviteJoinRecovery { + _Recovery(this.run); + final Future Function() run; + @override + Future ensureStarterChannels() async { + await run(); + return 'welcome-everyone'; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const invite = InviteDeepLink( + relayUrl: 'wss://invited.communities.buzz.xyz', + code: 'single-use', + ); + late HostedCommunitiesFixture f; + late CommunityStorage storage; + late HostedAdmission admission; + late ProviderContainer container; + late Community unrelated; + late List scopes; + late List probed; + late Future Function() recover; + var claims = 0; + var current = true; + + setUp(() async { + FlutterSecureStorage.setMockInitialValues({}); + f = HostedCommunitiesFixture(); + await f.initialize(); + storage = CommunityStorage(); + unrelated = Community.create( + name: 'Other', + relayUrl: 'wss://other.example', + pubkey: f.keys.public, + nsec: f.keys.nsec, + ); + await storage.save(unrelated); + await storage.saveActiveId(unrelated.id); + claims = 0; + current = true; + scopes = []; + probed = []; + recover = () async {}; + admission = HostedAdmission( + account: f.account, + relayAuth: HostedRelayAuth(f.account, enabled: false), + store: f.store, + probe: (community) async { + probed.add(community); + }, + client: MockClient((request) async { + if (request.method == 'GET' && request.url.path == '/api/join-policy') { + return http.Response('{}', 200); + } + expect(request.method, 'POST'); + expect(request.url.path, '/api/invites/claim'); + claims++; + return http.Response('{}', claims == 1 ? 200 : 410); + }), + ); + container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + inviteJoinRecoveryProvider.overrideWithValue((scope) { + scopes.add(scope); + return _Recovery(() => recover()); + }), + ], + ); + }); + tearDown(() async { + container.dispose(); + admission.dispose(); + await f.dispose(); + }); + + Future savedInvite() async => (await storage.loadAll()) + .singleWhere((community) => community.relayUrl == invite.relayUrl); + + Future connect() => connectHostedCommunity( + link: invite, + subject: 'owner', + pubkey: f.keys.public, + isCurrent: () => current, + storage: storage, + admission: admission, + persist: (community, isCurrent) => + container.read(communityTransitionProvider).runExclusive(() async { + if (!isCurrent()) return; + await storage.save(community); + await storage.saveActiveId(community.id); + container.invalidate(communityListProvider); + container.invalidate(activeCommunityProvider); + }), + recoverStarterChannels: container.read(_finishProvider), + ); + + test( + 'pending retry still rejects a changed account binding before probe or recovery', + () async { + recover = () async { + throw StateError('Starter membership refused'); + }; + await expectLater(connect(), throwsStateError); + f.bound = nostr.Keys('2'.padLeft(64, '0')).public; + await expectLater(connect(), throwsA(AccountFailure.denied)); + expect((await savedInvite()).starterSetupIncomplete, isTrue); + expect(claims, 1); + expect(probed, hasLength(1)); + expect(scopes, hasLength(1)); + }, + ); + + test('cancellation during recovery keeps durable pending flag', () async { + recover = () async { + current = false; + }; + await expectLater(connect(), throwsA(AccountFailure.unavailable)); + expect((await savedInvite()).starterSetupIncomplete, isTrue); + expect(claims, 1); + }); + + test( + 'switching active community during recovery cannot clear pending flag', + () async { + recover = () => storage.saveActiveId(unrelated.id); + await expectLater(connect(), throwsA(AccountFailure.unavailable)); + expect((await savedInvite()).starterSetupIncomplete, isTrue); + expect(await storage.loadActiveId(), unrelated.id); + }, + ); + + test( + 'clearing pending flag preserves newer saved community metadata', + () async { + recover = () => + container.read(communityTransitionProvider).runExclusive(() async { + await storage.save( + (await savedInvite()).copyWith(name: 'Renamed while joining'), + ); + }); + await connect(); + final saved = await savedInvite(); + expect(saved.starterSetupIncomplete, isFalse); + expect(saved.name, 'Renamed while joining'); + expect((await storage.loadAll()).first.toJson(), unrelated.toJson()); + }, + ); + + test( + 'saved invite with another key is never reclaimed or overwritten', + () async { + final other = nostr.Keys('2'.padLeft(64, '0')); + final saved = Community.create( + name: 'Existing', + relayUrl: invite.relayUrl, + nsec: other.nsec, + pubkey: other.public, + starterSetupIncomplete: true, + ); + await storage.save(saved); + await expectLater( + connect(), + throwsA(isA()), + ); + expect(claims, 0); + expect(probed, isEmpty); + expect(scopes, isEmpty); + expect((await savedInvite()).toJson(), saved.toJson()); + }, + ); +} 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..954ad03be1 --- /dev/null +++ b/mobile/test/shared/hosted/hosted_multi_backup_recovery_test.dart @@ -0,0 +1,421 @@ +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; + var statusCode = 200; + late AccountIdentityStore store; + late String communityJson; + late int recoveryCalls; + late int recoveryStatus; + Future Function()? duringRecovery; + + setUp(() { + statusCode = 200; + 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(subject: 'a'); + recoveryCalls = 0; + recoveryStatus = 200; + duringRecovery = null; + }); + + Future service({ + bool includeDevice = false, + Future> Function()? inventory, + }) 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, + }), + statusCode, + ); + } + 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() + : _Inventory(inventory ?? () async => []), + ); + } + + 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 expectLater( + store.confirmBackup( + BackupReceipt( + backend: BuilderlabAccountClient.origin.origin, + subject: 'another-account', + pubkey: keys[1].public, + backupId: 'other-account-backup', + ), + ), + throwsA(AccountIdentityFailure.mismatch), + ); + 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)]; + listedKeys = [keys[0].public]; + 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')]; + listedKeys = [keys[0].public]; + 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 resolver.resolve(selectedPubkey: keys[2].public)).state, + HostedIdentityState.needsSelection, + ); + expect(await store.read(), isNull); + expect( + (await resolver.resolve(selectedPubkey: keys[1].public)).state, + HostedIdentityState.ready, + ); + await expectCommunityPreserved(); + }); + + for (final scenario in [ + 'partial', + 'omitted', + 'unexpected', + 'malformed', + 'mismatched', + 'refused', + 'empty', + ]) { + test('recovery $scenario never writes or generates', () async { + bound = keys[0].public; + switch (scenario) { + case 'partial': + recovery['failed_backup_ids'] = ['unreadable']; + case 'omitted': + bound = null; + recovery['backups'] = [backup(0)]; + case 'unexpected': + bound = null; + listedKeys = [keys[0].public]; + case 'malformed': + (recovery['backups'] as List)[1]['secret_key'] = 'invalid'; + case 'mismatched': + (recovery['backups'] as List)[1]['nostr_pubkey'] = keys[2].public; + case 'refused': + recoveryStatus = 403; + case 'empty': + bound = null; + recovery['backups'] = []; + } + final resolver = await service(); + if (['partial', 'omitted', 'unexpected', 'empty'].contains(scenario)) { + expect( + (await resolver.resolve()).state, + scenario == 'empty' + ? HostedIdentityState.needsAuthoritativeAbsence + : HostedIdentityState.recoveryBlocked, + ); + } else { + await expectLater( + resolver.resolve(), + throwsA( + scenario == 'refused' + ? isA() + : AccountFailure.invalidResponse, + ), + ); + } + expect(await store.read(), isNull); + await expectCommunityPreserved(); + }); + } + + 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); + }); + test( + 'saved identity skips recovery and conflicting binding never replaces it', + () async { + bound = keys[0].public; + listedKeys = [keys[0].public]; + await store.create(keys[0].nsec); + final resolver = await service(); + expect((await resolver.resolve()).state, HostedIdentityState.needsBackup); + bound = keys[1].public; + expect((await resolver.resolve()).state, HostedIdentityState.mismatch); + expect(recoveryCalls, 0); + expect((await store.read())!.pubkey, keys[0].public); + }, + ); + + test( + 'authoritative absence rechecks inventory before generating once', + () async { + listedKeys = []; + var reads = 0; + final blocked = await service( + inventory: () async => ++reads == 1 + ? [] + : [DeviceIdentityChoice(keys[2].public, 'Newly saved')], + ); + expect( + (await blocked.resolve()).state, + HostedIdentityState.needsAuthoritativeAbsence, + ); + expect(await store.read(), isNull); + final resolver = await service(); + final first = await resolver.resolve(); + expect(first.state, HostedIdentityState.needsBinding); + expect((await resolver.resolve()).pubkey, first.pubkey); + expect(recoveryCalls, 0); + }, + ); + + for (final scenario in ['403', '404', '500', 'missing', 'cancelled']) { + test('unproven absence ($scenario) cannot generate', () async { + var current = true; + listedKeys = scenario == 'missing' ? null : []; + statusCode = int.tryParse(scenario) ?? 200; + final resolver = await service( + inventory: () async { + current = false; + return []; + }, + ); + await expectLater( + resolver.resolve(isCurrent: () => current), + throwsA(isA()), + ); + expect(await store.read(), isNull); + expect(recoveryCalls, 0); + await expectCommunityPreserved(); + }); + } +} + +// Substitute the external device inventory, not generation authorization. +class _Inventory extends DeviceIdentitySource { + _Inventory(this.read); + final Future> Function() read; + @override + Future> choices() => read(); +} diff --git a/mobile/test/shared/hosted/hosted_profile_offer_test.dart b/mobile/test/shared/hosted/hosted_profile_offer_test.dart new file mode 100644 index 0000000000..2603735490 --- /dev/null +++ b/mobile/test/shared/hosted/hosted_profile_offer_test.dart @@ -0,0 +1,179 @@ +import 'dart:async'; + +import 'package:buzz/features/profile/profile_provider.dart'; +import 'package:buzz/shared/hosted/hosted_flow_provider.dart'; +import 'package:buzz/shared/auth/auth.dart'; +import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:buzz/shared/hosted/builderlab_account.dart'; +import 'package:buzz/shared/hosted/hosted_flow.dart'; +import 'package:buzz/shared/profile/user_profile.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'; + +final _offerProvider = Provider( + (ref) => + ( + HostedFlow flow, + BuzzDeepLink link, + String pubkey, + bool Function() current, + ) => offerHostedProfile(ref, flow, link, pubkey, current), +); + +class _Profile extends ProfileNotifier { + _Profile(this.load, this.writes); + final Future Function() load; + final List writes; + @override + Future build() => load(); + @override + Future updateDisplayName(String name) async => writes.add(name); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late HostedCommunitiesFixture f; + late CommunityStorage storage; + late ProviderContainer container; + late Community community; + late Future Function() load; + late List writes; + var current = true; + var reads = 0; + const link = AddCommunityDeepLink( + relayUrl: 'wss://joined.communities.buzz.xyz', + name: 'Joined', + ); + + setUp(() async { + FlutterSecureStorage.setMockInitialValues({}); + f = HostedCommunitiesFixture(); + await f.initialize(); + storage = CommunityStorage(); + community = Community.create( + name: 'Joined', + relayUrl: link.relayUrl, + pubkey: f.keys.public, + nsec: f.keys.nsec, + ); + await storage.save(community); + await storage.saveActiveId(community.id); + writes = []; + reads = 0; + current = true; + load = () async => null; + container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + profileProvider.overrideWith( + () => _Profile(() { + reads++; + return load(); + }, writes), + ), + ], + ); + }); + tearDown(() async { + container.dispose(); + await f.dispose(); + }); + + Future offer() => container.read(_offerProvider)( + f.flow, + link, + f.keys.public, + () => current, + ); + Future prompted() async { + for (var i = 0; i < 50 && f.flow.stage != HostedFlowStage.profile; i++) { + await Future.delayed(Duration.zero); + } + expect(f.flow.stage, HostedFlowStage.profile); + } + + test('account cancellation while loading prevents prompt', () async { + final loading = Completer(); + load = () => loading.future; + final pending = offer(); + while (reads == 0) { + await Future.delayed(Duration.zero); + } + current = false; + loading.complete(null); + await pending; + expect(f.flow.stage, HostedFlowStage.idle); + expect(writes, isEmpty); + }); + + test('changed active community blocks a pending save', () async { + final pending = offer(); + await prompted(); + final other = Community.create( + name: 'Other', + relayUrl: 'wss://other.example', + pubkey: f.keys.public, + nsec: f.keys.nsec, + ); + await storage.save(other); + await storage.saveActiveId(other.id); + container.invalidate(communityListProvider); + container.invalidate(activeCommunityProvider); + expect( + (await container.read(activeCommunityProvider.future))?.id, + other.id, + ); + await expectLater( + f.flow.saveProfile!('Wrong scope'), + throwsA(AccountFailure.unavailable), + ); + expect(writes, isEmpty); + f.flow.finishProfile(); + await pending; + }); + + test( + 'new target releases pending profile and rejects retained save', + () async { + final pending = f.flow.requestProfile((name) async => writes.add(name)); + final save = f.flow.saveProfile!; + // Unsupported environment finishes locally without browser or network IO. + final next = f.flow.start( + const AddCommunityDeepLink( + relayUrl: 'wss://other.example', + name: 'Other', + ), + ); + await pending.timeout(const Duration(seconds: 2)); + await next; + await expectLater( + save('Stale prompt'), + throwsA(AccountFailure.unavailable), + ); + expect(writes, isEmpty); + expect(f.flow.stage, isNot(HostedFlowStage.profile)); + }, + ); + + for (final cancelAdmission in [false, true]) { + test( + 'leave rejects captured save (cancel admission: $cancelAdmission)', + () async { + final pending = offer(); + await prompted(); + final save = f.flow.saveProfile!; + if (cancelAdmission) current = false; + await f.flow.leave(); + await pending; + await expectLater( + save('After leave'), + throwsA(AccountFailure.unavailable), + ); + expect(writes, isEmpty); + }, + ); + } +} diff --git a/mobile/test/shared/hosted/hpke_backup_test.dart b/mobile/test/shared/hosted/hpke_backup_test.dart new file mode 100644 index 0000000000..b45acb2534 --- /dev/null +++ b/mobile/test/shared/hosted/hpke_backup_test.dart @@ -0,0 +1,98 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:buzz/shared/hosted/hpke_backup.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final fixture = + jsonDecode( + File( + '../desktop/src-tauri/src/testdata/hpke_nsec_backup_v1.json', + ).readAsStringSync(), + ) + as Map; + final enrollment = Map.from(fixture['envelope'] as Map) + ..remove('enc') + ..remove('ciphertext'); + Uint8List hex(String value) => Uint8List.fromList( + List.generate( + value.length ~/ 2, + (i) => int.parse(value.substring(i * 2, i * 2 + 2), radix: 16), + ), + ); + enrollment['recipient_public_key'] = base64UrlEncode( + hex(fixture['recipient_public_key_sec1_hex'] as String), + ).replaceAll('=', ''); + + test('v1 AAD exactly matches desktop/server interop fixture', () { + expect( + HpkeBackup.associatedData(enrollment), + hex(fixture['aad_hex'] as String), + ); + for (final invalid in [ + {...enrollment, 'version': 2}, + {...enrollment, 'owner_id': 'owner\n'}, + { + ...enrollment, + 'backup_id': (enrollment['backup_id'] as String).toUpperCase(), + }, + ]) { + expect( + () => HpkeBackup.associatedData(invalid), + throwsA(HpkeBackupFailure.invalidEnrollment), + ); + } + }); + + test( + 'seal binds derived identity and passes exact primitives to native', + () async { + var calls = 0; + const channel = MethodChannel('buzz/hosted_hpke'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls++; + expect(call.method, 'seal'); + final args = call.arguments as Map; + expect(args['aad'], hex(fixture['aad_hex'] as String)); + expect( + args['plaintext'], + hex(fixture['nostr_secret_key_hex'] as String), + ); + expect( + args['recipient'], + hex(fixture['recipient_public_key_sec1_hex'] as String), + ); + final envelope = fixture['envelope'] as Map; + return { + 'enc': base64Url.decode( + base64Url.normalize(envelope['enc'] as String), + ), + 'ciphertext': base64Url.decode( + base64Url.normalize(envelope['ciphertext'] as String), + ), + }; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + final result = await HpkeBackup().seal( + enrollment: enrollment, + secretHex: fixture['nostr_secret_key_hex'] as String, + ); + expect(result, fixture['envelope']); + await expectLater( + HpkeBackup().seal( + enrollment: {...enrollment, 'nostr_pubkey': '0' * 64}, + secretHex: fixture['nostr_secret_key_hex'] as String, + ), + throwsA(HpkeBackupFailure.identityMismatch), + ); + expect(calls, 1); + }, + ); +} diff --git a/mobile/test/shared/hosted/identity_choice_lifecycle_test.dart b/mobile/test/shared/hosted/identity_choice_lifecycle_test.dart new file mode 100644 index 0000000000..7e4da370f8 --- /dev/null +++ b/mobile/test/shared/hosted/identity_choice_lifecycle_test.dart @@ -0,0 +1,107 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:buzz/shared/hosted/account_identity_store.dart'; +import 'package:buzz/shared/hosted/browser_auth.dart'; +import 'package:buzz/shared/hosted/device_identity_source.dart'; +import 'package:buzz/shared/hosted/hosted_flow.dart'; +import 'package:buzz/shared/hosted/hosted_identity_service.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import 'device_identity_reconciliation_test.dart' show InventoryAccount; + +class _Browser extends BrowserAuth { + Completer? cancelling; + int cleared = 0; + @override + Future cancel() async => cancelling?.future; + @override + Future clearIntent() async { + cleared++; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final first = nostr.Keys('1'.padLeft(64, '0')); + final second = nostr.Keys('2'.padLeft(64, '0')); + late InventoryAccount account; + late AccountIdentityStore store; + late _Browser browser; + late HostedFlow flow; + const target = AddCommunityDeepLink( + relayUrl: 'wss://team.communities.buzz.xyz', + name: 'Team', + ); + setUp(() { + FlutterSecureStorage.setMockInitialValues({ + 'buzz_communities': jsonEncode([ + {'name': 'First', 'pubkey': first.public, 'nsec': first.nsec}, + {'name': 'Second', 'pubkey': second.public, 'nsec': second.nsec}, + ]), + }); + account = InventoryAccount(); + store = AccountIdentityStore(subject: 'fixture'); + browser = _Browser(); + flow = HostedFlow( + account: account, + browser: browser, + store: store, + identity: HostedIdentityService( + account: account, + store: store, + deviceIdentities: DeviceIdentitySource(), + ), + enterCommunity: (_, _, _, _) async => fail('Must not bypass binding'), + ); + }); + tearDown(() async { + flow.dispose(); + await browser.dispose(); + account.dispose(); + }); + + test( + 'unoffered choices cannot restart resolution or perform any remote work', + () async { + await flow.start(target); + final calls = List.of(account.calls); + final choices = List.of(flow.identityChoices); + await flow.chooseIdentity('0' * 64); + expect(flow.stage, HostedFlowStage.chooseIdentity); + expect(account.calls, calls); + expect(flow.identityChoices, choices); + expect(await store.read(), isNull); + }, + ); + + test( + 'choices are retired on selection and on leaving; late tap cannot cancel leave', + () async { + await flow.start(target); + browser.cancelling = Completer(); + final calls = List.of(account.calls); + final leaving = flow.leave(); + await flow.chooseIdentity(first.public); + expect(account.calls, calls); + expect(await store.read(), isNull); + browser.cancelling!.complete(); + await leaving; + expect(browser.cleared, 1); + expect(flow.stage, HostedFlowStage.idle); + expect(flow.identityChoices, isEmpty); + await flow.start(target); + final choosing = flow.chooseIdentity(second.public); + expect(flow.identityChoices, isEmpty); + await choosing; + expect(flow.problem, HostedFlowProblem.denied); + final completedCalls = List.of(account.calls); + await flow.chooseIdentity(first.public); + expect(account.calls, completedCalls); + expect((await store.read())!.pubkey, second.public); + }, + ); +} diff --git a/mobile/test/shared/hosted/self_hosted_entry_test.dart b/mobile/test/shared/hosted/self_hosted_entry_test.dart new file mode 100644 index 0000000000..6e4c9039bb --- /dev/null +++ b/mobile/test/shared/hosted/self_hosted_entry_test.dart @@ -0,0 +1,327 @@ +import 'package:buzz/shared/hosted/hosted_environment.dart'; +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.', + ]) { + if (!HostedEnvironment.requiresAuth(Uri.parse(address).host)) continue; + 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)); + }, + ); + + for (final brokenEnumeration in [false, true]) { + for (final source in ['legacy', 'community', 'account']) { + test( + '$source identity with broken enumeration=$brokenEnumeration forbids generation; explicit reuse preserves saved keys', + () async { + if (source == 'account') { + await AccountIdentityStore(storage: storage).create(key.nsec); + } else if (source == 'legacy') { + storage.data['buzz_nsec'] = key.nsec; + } else { + storage.data['buzz_communities'] = jsonEncode([ + {'name': 'Saved', 'nsec': key.nsec, 'pubkey': key.public}, + ]); + storage.data['buzz_nsec'] = other.nsec; + } + storage.failEnumeration = brokenEnumeration; + final before = Map.of(storage.data); + final writesBefore = storage.writes; + final inventory = await entry.choices(); + expect(inventory.complete, !brokenEnumeration); + if (!brokenEnumeration || source != 'account') { + expect( + inventory.choices.map((choice) => choice.pubkey), + contains(key.public), + ); + } + await expectLater( + prepare(create: true), + throwsA( + brokenEnumeration + ? AccountIdentityFailure.unreadable + : AccountIdentityFailure.absent, + ), + ); + expect(storage.writes, writesBefore); + expect(probes, isEmpty); + expect(storage.data, before); + final community = await prepare(savedPubkey: key.public); + expect(community.nsec, key.nsec); + expect(community.pubkey, key.public); + expect(community.relayUrl, relay); + for (final saved in before.entries) { + expect(storage.data[saved.key], saved.value); + } + expect(probes, [community]); + }, + ); + } + } + + 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()).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( + 'explicit replacement preserves refused key and retries the chosen key', + () async { + refuseProbe = true; + await expectLater(prepare(nsec: key.nsec), throwsStateError); + refuseProbe = false; + final joined = await prepare(nsec: other.nsec); + expect(joined.pubkey, other.public); + final retained = AccountIdentityStore( + storage: storage, + ).forPairing(key.public); + expect(await retained.withKeys((keys) async => keys.public), key.public); + expect((await prepare(create: true)).pubkey, other.public); + expect(probes, hasLength(3)); + }, + ); + + test( + 'missing explicit saved identity never falls back to generation', + () async { + await expectLater( + prepare(savedPubkey: key.public, create: true), + throwsA(AccountIdentityFailure.absent), + ); + expectUntouched(); + }, + ); + + for (final boundary in ['before', 'read', 'write']) { + test( + 'cancellation at $boundary fences probe and preserves any durable key', + () async { + if (boundary == 'before') current = false; + if (boundary == 'read') storage.onRead = () => current = false; + if (boundary == 'write') storage.onWrite = () => current = false; + await expectLater(prepare(nsec: key.nsec), throwsStateError); + expect(probes, isEmpty); + expect(storage.writes, boundary == 'write' ? 1 : 0); + if (boundary == 'before') expect(storage.reads, 0); + if (boundary == 'write') { + expect(storage.data.values, contains(key.nsec)); + current = true; + storage.onWrite = null; + expect((await prepare()).pubkey, key.public); + expect(storage.writes, 1); + } else { + expect(storage.data, isEmpty); + } + }, + ); + } + + 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; + bool failEnumeration = false; + void Function()? onRead; + void Function()? onWrite; + + @override + Future> readAll({ + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async { + if (failEnumeration) throw StateError('unrelated entry cannot decrypt'); + return Map.of(data); + } + + @override + Future delete({ + required String key, + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async { + data.remove(key); + } + + @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/hosted/web_connect_binding_test.dart b/mobile/test/shared/hosted/web_connect_binding_test.dart new file mode 100644 index 0000000000..8235496f5f --- /dev/null +++ b/mobile/test/shared/hosted/web_connect_binding_test.dart @@ -0,0 +1,205 @@ +import 'dart:convert'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:buzz/shared/hosted/account_identity_store.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:buzz/shared/hosted/web_connect_binding.dart'; +import 'package:buzz/shared/hosted/hosted_environment.dart'; + +void main() { + final fields = { + 'challenge_id': '12345678-1234-1234-1234-123456789012', + 'nonce': 'a' * 43, + 'verification_code': '123456', + 'audience': 'buzz:nostr-identity', + 'action': 'bind_nostr_identity', + 'protocol': 'buzz-nostr-identity', + 'version': '1', + 'origin': HostedEnvironment.origin.toString(), + 'expires_at': '2099-01-01T00:00:00Z', + 'return': 'browser_fragment_v1', + 'callback_url': HostedEnvironment.origin.resolve('/buzz').toString(), + }; + Uri link(Map values) => + Uri(scheme: 'buzz', host: 'nostr-bind', queryParameters: values); + final keys = nostr.Keys('1'.padLeft(64, '0')); + Future withKeys(Future Function(nostr.Keys) operation) => + operation(keys); + + test('production parser recognizes web links and rejects ambiguous trust', () { + expect(parseBuzzDeepLink(link(fields)), isA()); + final otherOrigin = HostedEnvironment.origin.host == 'app.builderlab.xyz' + ? 'https://app.builderlabstaging.xyz' + : 'https://app.builderlab.xyz'; + expect( + parseBuzzDeepLink( + link({ + ...fields, + 'origin': otherOrigin, + 'callback_url': '$otherOrigin/buzz', + }), + ), + isNull, + ); + for (final entry in { + 'origin': 'https://attacker.example', + 'callback_url': 'https://app.builderlab.xyz/buzz?redirect=evil', + 'nonce': 'short', + 'verification_code': '12345', + 'version': '2', + 'expires_at': '2099-01-01T00:00:00', + }.entries) { + expect( + parseBuzzDeepLink(link({...fields, entry.key: entry.value})), + isNull, + ); + } + expect( + parseBuzzDeepLink(Uri.parse('${link(fields)}&nonce=duplicate')), + isNull, + ); + expect(parseBuzzDeepLink(link({...fields, 'extra': 'value'})), isNull); + expect( + parseBuzzDeepLink( + Uri.parse( + 'buzz://add-community?relay=wss%3A%2F%2Fexample.communities.buzz.xyz&name=Example', + ), + ), + isA(), + ); + expect( + parseBuzzDeepLink( + Uri.parse( + 'buzz://add-community?relay=wss%3A%2F%2Fexample.com%2Fpath&name=Example', + ), + ), + isNull, + ); + expect( + parseBuzzDeepLink( + Uri.parse( + 'buzz://add-community?relay=wss%3A%2F%2Fexample.com&name=Example&name=Other', + ), + ), + isNull, + ); + }); + + test( + 'signs desktop-compatible event and verifies account before browser return', + () async { + var verified = false; + final result = await WebConnectBindingService().complete( + request: parseNostrBindDeepLink(link(fields))!, + subject: 'account', + expectedPubkey: keys.public, + withKeys: withKeys, + verify: + ({ + required subject, + required challengeId, + required nonce, + required signedPayload, + }) async { + expect(subject, 'account'); + expect(challengeId, fields['challenge_id']); + final event = jsonDecode(signedPayload) as Map; + expect(event['kind'], 24243); + expect(event['content'], ''); + expect(event['pubkey'], keys.public); + expect( + event['tags'], + contains(equals(['verification_code', '123456'])), + ); + verified = true; + return keys.public; + }, + ); + expect(verified, isTrue); + expect(result, HostedEnvironment.origin.resolve('/buzz')); + expect(result.hasFragment, isFalse); + }, + ); + + test( + 'unverified or mismatched identity cannot produce browser return', + () async { + for (final mismatch in [false, true]) { + await expectLater( + WebConnectBindingService().complete( + request: parseNostrBindDeepLink(link(fields))!, + subject: 'account', + expectedPubkey: keys.public, + withKeys: withKeys, + verify: + ({ + required subject, + required challengeId, + required nonce, + required signedPayload, + }) async { + if (!mismatch) { + throw StateError('account does not own challenge'); + } + return '0' * 64; + }, + ), + throwsA(anything), + ); + } + }, + ); + + test('verification runs outside the identity store signing lock', () async { + TestWidgetsFlutterBinding.ensureInitialized(); + FlutterSecureStorage.setMockInitialValues({}); + final store = AccountIdentityStore(); + await store.create(keys.nsec); + await WebConnectBindingService() + .complete( + request: parseNostrBindDeepLink(link(fields))!, + subject: 'account', + expectedPubkey: keys.public, + withKeys: store.withKeys, + verify: + ({ + required subject, + required challengeId, + required nonce, + required signedPayload, + }) async { + return (await store.read())!.pubkey; + }, + ) + .timeout(const Duration(seconds: 2)); + }); + + test('expiry and local mismatch stop before verification', () async { + var calls = 0; + for (final expired in [false, true]) { + await expectLater( + WebConnectBindingService( + now: () => expired ? DateTime.utc(2100) : DateTime.utc(2026), + ).complete( + request: parseNostrBindDeepLink(link(fields))!, + subject: 'account', + expectedPubkey: expired ? keys.public : '0' * 64, + withKeys: withKeys, + verify: + ({ + required subject, + required challengeId, + required nonce, + required signedPayload, + }) async { + calls++; + return keys.public; + }, + ), + throwsFormatException, + ); + } + expect(calls, 0); + }); +} diff --git a/mobile/test/shared/huddle/huddle_transport_test.dart b/mobile/test/shared/huddle/huddle_transport_test.dart index 808a5eeebb..b98941d7a5 100644 --- a/mobile/test/shared/huddle/huddle_transport_test.dart +++ b/mobile/test/shared/huddle/huddle_transport_test.dart @@ -45,6 +45,23 @@ void main() { expect(transport.state.phase, HuddleTransportPhase.connected); expect(transport.state.localPeerIndex, 3); expect(transport.state.peers.keys, containsAll([1, 3])); + + final stalled = Completer>(); + var opened = false; + final waiting = HuddleTransport( + parameters: transport.parameters, + headers: () => stalled.future, + channelFactory: (_) { + opened = true; + return _ControlledWebSocketChannel(); + }, + connectTimeout: const Duration(milliseconds: 20), + ); + addTearDown(waiting.dispose); + await expectLater(waiting.connect(), throwsA(isA())); + stalled.complete({}); + await Future.delayed(Duration.zero); + expect(opened, isFalse); }); test( 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..3c1e114586 --- /dev/null +++ b/mobile/test/shared/identity_transfer/nsec_transfer_test.dart @@ -0,0 +1,163 @@ +import 'dart:io'; + +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 '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')); + + 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( + '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), + ); + + // Continue through the real share coordinator; only native boundaries + // are synthetic here. Native tests assert the file's protection class. + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const channel = MethodChannel('buzz/identity_file'); + const shareChannel = MethodChannel('dev.fluttercommunity.plus/share'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + final root = await Directory.systemTemp.createTemp('nsec-export-test-'); + addTearDown(() async { + debugDefaultTargetPlatformOverride = null; + messenger.setMockMethodCallHandler(channel, null); + messenger.setMockMethodCallHandler(shareChannel, null); + await root.delete(recursive: true); + }); + File? prepared; + var failPrepare = false; + var failShare = false; + messenger.setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'prepareExport'); + expect(call.arguments, {'text': '${keys.nsec}\n'}); + if (failPrepare) throw PlatformException(code: 'export_failed'); + final directory = await root.createTemp('buzz-identity-'); + prepared = File('${directory.path}/buzz-identity.nsec'); + await prepared!.writeAsString('${keys.nsec}\n'); + return prepared!.path; + }); + messenger.setMockMethodCallHandler(shareChannel, (call) async { + expect(call.method, 'share'); + expect(call.arguments['paths'], [prepared!.path]); + expect(await prepared!.readAsString(), '${keys.nsec}\n'); + if (failShare) throw PlatformException(code: 'share_failed'); + return 'com.apple.UIKit.activity.SaveToFiles'; + }); + Future shareFile() => + NsecTransfer.shareFile(keys.nsec, const Rect.fromLTWH(0, 0, 10, 10)); + expect(await shareFile(), isTrue); + expect(await prepared!.parent.exists(), isFalse); + failShare = true; + await expectLater(shareFile(), throwsA(isA())); + expect(await prepared!.parent.exists(), isFalse); + failPrepare = true; + prepared = null; // Any accidental share after prepare fails is an error. + await expectLater(shareFile(), throwsA(isA())); + }, + ); +} diff --git a/mobile/test/shared/relay/relay_socket_liveness_test.dart b/mobile/test/shared/relay/relay_socket_liveness_test.dart index 835ebc2b61..e3802f39b7 100644 --- a/mobile/test/shared/relay/relay_socket_liveness_test.dart +++ b/mobile/test/shared/relay/relay_socket_liveness_test.dart @@ -84,28 +84,94 @@ void main() { await server.close(); }); - test('keeps an idle but healthy peer connected', () async { - final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - server.transform(WebSocketTransformer()).listen((ws) { - // A healthy relay answers pings without sending application data. - ws.listen((_) {}, onError: (_) {}, onDone: () {}); - }); + test( + 'scoped headers stay at the upgrade origin and healthy peers stay connected', + () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final paths = []; + // A different port prevents a closed upgrade connection from masking a + // redirect attempt through connection-pool reuse. + final sink = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + sink.listen((request) async { + paths.add('sink'); + expect( + request.headers.value('Nostr-Federated-Identity'), + 'Bearer synthetic', + ); + request.response.statusCode = 403; + await request.response.close(); + }); + server.listen((request) async { + paths.add(request.uri.path); + expect( + request.headers.value('Nostr-Federated-Identity'), + 'Bearer synthetic', + ); + if (request.uri.path == '/denied') { + request.response.statusCode = 403; + await request.response.close(); + return; + } + if (request.uri.path == '/redirect') { + request.response.statusCode = 302; + request.response.headers.set( + 'Location', + 'http://127.0.0.1:${sink.port}/sink', + ); + await request.response.close(); + return; + } + final ws = await WebSocketTransformer.upgrade(request); + ws.listen((_) {}, onError: (_) {}, onDone: () {}); + }); - var tornDown = false; - final socket = RelaySocket( - wsUrl: 'ws://127.0.0.1:${server.port}', - nsec: null, - onMessage: (_) {}, - onConnected: () {}, - onDisconnected: (_) => tornDown = true, - ); - unawaited(socket.connect()); + var tornDown = false; + final socket = RelaySocket( + wsUrl: 'ws://127.0.0.1:${server.port}', + nsec: null, + onMessage: (_) {}, + onConnected: () {}, + onDisconnected: (_) => tornDown = true, + ); + socket.headers = {'Nostr-Federated-Identity': 'Bearer synthetic'}; + unawaited(socket.connect()); - await Future.delayed(testPingInterval * 4); + await Future.delayed(testPingInterval * 4); - expect(tornDown, isFalse); + expect(tornDown, isFalse); - await socket.disconnect(); - await server.close(force: true); - }); + await socket.disconnect(); + final refused = Completer(); + final redirect = RelaySocket( + wsUrl: 'ws://127.0.0.1:${server.port}/redirect', + nsec: null, + onMessage: (_) {}, + onConnected: () {}, + onDisconnected: (_) { + if (!refused.isCompleted) refused.complete(); + }, + ); + redirect.headers = {'Nostr-Federated-Identity': 'Bearer synthetic'}; + await redirect.connect(); + await refused.future; + expect(paths, ['/', '/redirect']); + redirect.dispose(); + final denial = Completer(); + final denied = RelaySocket( + wsUrl: 'ws://127.0.0.1:${server.port}/denied', + nsec: null, + onMessage: (_) {}, + onConnected: () {}, + onDisconnected: (error) { + if (!denial.isCompleted) denial.complete(error); + }, + ); + denied.headers = {'Nostr-Federated-Identity': 'Bearer synthetic'}; + await denied.connect(); + expect(relayUpgradeRefused(await denial.future), isTrue); + denied.dispose(); + await server.close(force: true); + await sink.close(force: true); + }, + ); } 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,