feat(mobile): extend key recovery and community onboarding

Add Android HPKE sealing, bounded native key-file import, guarded export,
explicit cloud-key selection, and retry-safe self-hosted setup. Route hosted
pairing through account admission and preserve first-successful batch landing.
Keep account eligibility failures and setup progress on the entry surface.

Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
Wes
2026-10-15 19:19:47 -07:00
parent a63664315c
commit 37693a81a4
56 changed files with 3427 additions and 114 deletions

View File

@@ -0,0 +1,136 @@
package xyz.block.buzz.mobile
import java.math.BigInteger
import java.security.AlgorithmParameters
import java.security.KeyFactory
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.interfaces.ECPublicKey
import java.security.spec.ECGenParameterSpec
import java.security.spec.ECParameterSpec
import java.security.spec.ECPoint
import java.security.spec.ECPublicKeySpec
import javax.crypto.Cipher
import javax.crypto.KeyAgreement
import javax.crypto.Mac
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
/** RFC 9180 base mode, DHKEM(P-256, HKDF-SHA256), HKDF-SHA256, AES-256-GCM.
* Enrollment and AAD must come from the authenticated hosted coordinator.
* Only JCA platform primitives are used; this is a single-message sender.
*/
internal object HostedHpkeBackup {
private val kemSuite = "KEM".toByteArray() + byteArrayOf(0, 16)
private val suite = "HPKE".toByteArray() + byteArrayOf(0, 16, 0, 1, 0, 2)
private val version = "HPKE-v1".toByteArray()
private val info = "buzz/nsec-backup/v1".toByteArray()
private val curve: ECParameterSpec
get() = AlgorithmParameters.getInstance("EC").apply {
init(ECGenParameterSpec("secp256r1"))
}.getParameterSpec(ECParameterSpec::class.java)
fun seal(recipient: ByteArray, plaintext: ByteArray, aad: ByteArray): Map<String, ByteArray> {
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<String, ByteArray> {
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) }
}
}

View File

@@ -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) }
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}

View File

@@ -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<out String>,
@@ -204,6 +214,10 @@ class MainActivity : FlutterFragmentActivity() {
}
override fun onDestroy() {
identityFilePlugin?.dispose()
identityFilePlugin = null
hostedHpkePlugin?.dispose()
hostedHpkePlugin = null
hapticsPlugin?.dispose()
hapticsPlugin = null
ageSignalRequest.retire()

View File

@@ -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<IllegalArgumentException> { HostedHpkeBackup.seal(bad, ByteArray(32), byteArrayOf(1)) }
}
assertFailsWith<IllegalArgumentException> { HostedHpkeBackup.seal(recipient, ByteArray(31), byteArrayOf(1)) }
assertFailsWith<IllegalArgumentException> { 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",
)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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<IllegalArgumentException> { 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<IllegalArgumentException> { 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<CharacterCodingException> { 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<IOException> { 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() }
}
}

View File

@@ -7,6 +7,7 @@
objects = {
/* Begin PBXBuildFile section */
BBAA00000000000000000011 /* IdentityFilePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBAA00000000000000000012 /* IdentityFilePlugin.swift */; };
BBAA00000000000000000001 /* HostedHpkeBackup.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBAA00000000000000000002 /* HostedHpkeBackup.swift */; };
4A71C0192F40D00100A17E02 /* MeAvatarLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01A2F40D00100A17E02 /* MeAvatarLoader.swift */; };
C0AF00000000000000000001 /* NativeConfirmationDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AF00000000000000000002 /* NativeConfirmationDialog.swift */; };
@@ -95,6 +96,7 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
BBAA00000000000000000012 /* IdentityFilePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityFilePlugin.swift; sourceTree = "<group>"; };
BBAA00000000000000000002 /* HostedHpkeBackup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostedHpkeBackup.swift; sourceTree = "<group>"; };
4A71C01A2F40D00100A17E02 /* MeAvatarLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeAvatarLoader.swift; sourceTree = "<group>"; };
C0AF00000000000000000002 /* NativeConfirmationDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeConfirmationDialog.swift; sourceTree = "<group>"; };
@@ -275,6 +277,7 @@
AB1200122F40400100A17E01 /* NativeReactionDetails.swift */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
BBAA00000000000000000002 /* HostedHpkeBackup.swift */,
BBAA00000000000000000012 /* IdentityFilePlugin.swift */,
BZZ00000000000000000024 /* PushNativeState.swift */,
BZZ00000000000000000027 /* PushEndpointGrantStore.swift */,
BZZ0000000000000000002A /* PushSnapshotBridge.swift */,
@@ -614,6 +617,7 @@
AB1200022F40400100A17E01 /* NativeReactionDetails.swift in Sources */,
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
BBAA00000000000000000001 /* HostedHpkeBackup.swift in Sources */,
BBAA00000000000000000011 /* IdentityFilePlugin.swift in Sources */,
BZZ00000000000000000023 /* PushNativeState.swift in Sources */,
BZZ00000000000000000026 /* PushEndpointGrantStore.swift in Sources */,
BZZ00000000000000000029 /* PushSnapshotBridge.swift in Sources */,

View File

@@ -46,6 +46,7 @@ import os.log
private var nativeConfirmationDialogCoordinator: NativeConfirmationDialogCoordinator?
private var nativeProfileTextEditorCoordinator: NativeProfileTextEditorCoordinator?
private var nativeMessageActionSurfaceSupportChannel: FlutterMethodChannel?
private var identityFilePlugin: IdentityFilePlugin?
private var hostedHpkeChannel: FlutterMethodChannel?
private var browserAuthBridge: BuzzBrowserAuthBridge?
private var huddleMediaPlugin: HuddleMediaPlugin?
@@ -67,6 +68,7 @@ import os.log
}
let messenger = engineBridge.applicationRegistrar.messenger()
identityFilePlugin = IdentityFilePlugin(messenger: messenger)
browserAuthBridge = BuzzBrowserAuthBridge(messenger: messenger)
hostedHpkeChannel = FlutterMethodChannel(name: "buzz/hosted_hpke", binaryMessenger: messenger)
hostedHpkeChannel?.setMethodCallHandler { call, result in

View File

@@ -0,0 +1,124 @@
import Flutter
import UIKit
import UniformTypeIdentifiers
/// Reads the user-selected document in place; never imports a plaintext copy
/// into Buzz's sandbox. The security-scoped grant lasts only for the read.
final class IdentityFilePlugin: NSObject, UIDocumentPickerDelegate,
UIAdaptivePresentationControllerDelegate
{
private let channel: FlutterMethodChannel
private var pending: FlutterResult?
private var requestID: UUID?
init(messenger: FlutterBinaryMessenger) {
channel = FlutterMethodChannel(name: "buzz/identity_file", binaryMessenger: messenger)
super.init()
channel.setMethodCallHandler { [weak self] call, result in
guard let self else {
result(FlutterError(code: "import_failed", message: nil, details: nil))
return
}
guard call.method == "import" else {
result(FlutterMethodNotImplemented)
return
}
self.pick(result: result)
}
}
private func pick(result: @escaping FlutterResult) {
guard pending == nil else {
result(FlutterError(code: "import_busy", message: nil, details: nil))
return
}
// The implicit Flutter engine can initialize before a view controller is
// attached. Resolve the live scene at the user action, never cache the
// registrar's possibly-nil launch-time view controller.
let root = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.filter { $0.activationState == .foregroundActive }
.flatMap(\.windows)
.first(where: \.isKeyWindow)?.rootViewController
guard var top = root else {
result(FlutterError(code: "import_failed", message: nil, details: nil))
return
}
while true {
if let presented = top.presentedViewController { top = presented }
else if let navigation = top as? UINavigationController,
let visible = navigation.visibleViewController { top = visible }
else if let tabs = top as? UITabBarController,
let selected = tabs.selectedViewController { top = selected }
else { break }
}
guard !top.isBeingDismissed, top.viewIfLoaded?.window != nil else {
result(FlutterError(code: "import_failed", message: nil, details: nil))
return
}
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.item], asCopy: false)
picker.allowsMultipleSelection = false
picker.delegate = self
pending = result
requestID = UUID()
top.present(picker, animated: true)
picker.presentationController?.delegate = self
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let url = urls.first, urls.count == 1 else {
finish(FlutterError(code: "import_failed", message: nil, details: nil))
return
}
guard let requestID else { return }
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
var coordinationError: NSError?
var text: String?
NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &coordinationError) { accessible in
text = Self.readBounded(accessible)
}
let value: Any? = coordinationError == nil && text != nil
? text : FlutterError(code: "import_failed", message: nil, details: nil)
DispatchQueue.main.async {
guard self?.requestID == requestID else { return }
self?.finish(value)
}
}
}
/// Reads at most 1025 bytes, including the one-byte oversize sentinel.
private static func readBounded(_ url: URL) -> String? {
guard let stream = InputStream(url: url) else { return nil }
stream.open()
var bytes = [UInt8](repeating: 0, count: 1025)
defer {
stream.close()
bytes.withUnsafeMutableBytes { $0.initializeMemory(as: UInt8.self, repeating: 0) }
}
var count = 0
while count < bytes.count {
let remaining = bytes.count - count
let read = bytes.withUnsafeMutableBufferPointer { buffer in
guard let base = buffer.baseAddress else { return -1 }
return stream.read(base.advanced(by: count), maxLength: remaining)
}
if read < 0 { return nil }
if read == 0 { break }
count += read
}
guard count > 0, count <= 1024 else { return nil }
return String(bytes: bytes.prefix(count), encoding: .utf8)
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { finish(nil) }
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { finish(nil) }
private func finish(_ value: Any?) {
let result = pending
pending = nil
requestID = nil
result?(value)
}
}

View File

@@ -40,7 +40,7 @@ import '../profile/profile_provider.dart';
import '../profile/presence_cache_provider.dart';
import '../../shared/identity_names/identity_names_provider.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../pairing/pairing_page.dart';
import '../../onboarding_app_entry.dart';
import '../pairing/pairing_provider.dart';
import 'channel.dart';
import 'channel_actions_sheet.dart';

View File

@@ -247,7 +247,7 @@ class _CommunitySwitcherPage extends HookConsumerWidget {
unawaited(
navigator.push(
MaterialPageRoute<void>(
builder: (_) => const PairingPage(addingCommunity: true),
builder: (_) => const OnboardingAppEntry(addingCommunity: true),
),
),
);

View File

@@ -29,7 +29,9 @@ class HostedConnectSheet extends HookConsumerWidget {
'${flow.addingCommunityName} is already saved with a different identity. '
'Keep that identity and skip this addition, or return to your choices?',
HostedFlowStage.chooseIdentity =>
flow.identityChoices.any((choice) => choice.fromCloud)
flow.identityChoices.where((choice) => choice.fromCloud).length > 1
? 'Choose a backed-up identity to connect to Builderlab. Your other backups and saved community keys will be kept. This app cannot change the selected Builderlab identity later.'
: flow.identityChoices.any((choice) => choice.fromCloud)
? 'Use your Builderlab identity on this device? It differs from your saved community identities. Those keys and your cloud backups will be kept. This app cannot change the selected Builderlab identity later.'
: 'Choose the identity to connect to Builderlab. It will be bound to your account and backed up. Existing keys will be kept. This app cannot change the selected Builderlab identity later.',
HostedFlowStage.signIn => 'Complete sign-in in your browser.',

View File

@@ -0,0 +1,158 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/auth/auth.dart';
import '../../shared/hosted/device_identity_source.dart';
import '../../shared/hosted/self_hosted_entry.dart';
import '../../shared/identity_transfer/nsec_import_sheet.dart';
import '../../shared/theme/theme.dart';
import '../../shared/utils/string_utils.dart';
import '../../shared/widgets/modal_presentation.dart';
/// Local preparation seam; the production service validates and retains keys.
final selfHostedEntryProvider = Provider<SelfHostedEntry>(
(ref) => SelfHostedEntry(),
);
/// Uses an explicit identity choice without involving a hosted account.
Future<bool> showSelfHostedEntry(
BuildContext context, {
required String relay,
required String name,
}) async =>
await showBuzzModalBottomSheet<bool>(
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<String?>(null);
bool mounted() => context.mounted;
Future<void> connect({
String? pubkey,
String? nsec,
bool create = false,
bool close = true,
}) async {
if (busy.value) return;
busy.value = true;
error.value = null;
try {
final community = await service.prepare(
relay: relay,
name: name,
savedPubkey: pubkey,
nsec: nsec,
create: create,
isCurrent: mounted,
);
if (!mounted()) return;
await ref
.read(authProvider.notifier)
.authenticateWithCommunity(community, shouldContinue: mounted);
if (close && context.mounted) Navigator.of(context).pop(true);
} catch (_) {
if (mounted()) {
error.value =
'Could not join. Check this community accepts your identity, then try again. Your saved keys have been kept.';
}
rethrow;
} finally {
if (mounted()) busy.value = false;
}
}
Future<void> select({String? pubkey, bool create = false}) async {
try {
await connect(pubkey: pubkey, create: create);
} catch (_) {}
}
final saved = choices.data ?? const <DeviceIdentityChoice>[];
return Padding(
padding: const EdgeInsets.all(Grid.gutter),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(Uri.parse(relay).host, style: context.textTheme.titleMedium),
const SizedBox(height: Grid.sm),
const Text(
'Choose the identity to use in this community. Your key stays on this device; Builderlab is not required.',
),
const SizedBox(height: Grid.sm),
if (choices.hasError)
const Text(
'Saved identities could not be read. Unlock your device and try again.',
),
for (final identity in saved)
OutlinedButton(
onPressed: busy.value
? null
: () => select(pubkey: identity.pubkey),
child: Column(
children: [
Text(identity.label),
Text(fullNpub(identity.pubkey) ?? identity.pubkey),
],
),
),
if (choices.connectionState == ConnectionState.done &&
!choices.hasError) ...[
OutlinedButton(
onPressed: busy.value
? null
: () async {
final imported = await showNsecImportSheet(
context,
onImport: (nsec) => connect(nsec: nsec, close: false),
);
if (imported && context.mounted) {
Navigator.of(context).pop(true);
}
},
child: const Text('Import a Nostr key'),
),
if (saved.isEmpty)
FilledButton(
onPressed: busy.value ? null : () => select(create: true),
child: const Text('Create an identity and join'),
),
if (saved.isEmpty)
const Text(
'Export a backup in Settings after joining. This device will hold your only copy until you back it up.',
),
],
if (busy.value)
const Padding(
padding: EdgeInsets.all(Grid.sm),
child: Text('Joining…'),
),
if (error.value != null)
Text(error.value!, style: TextStyle(color: context.colors.error)),
],
),
),
);
}
}

View File

@@ -48,7 +48,9 @@ class PairingPage extends HookConsumerWidget {
final bool enteringCommunityLink;
final VoidCallback? onCommunityLink;
final VoidCallback? onBuilderlab;
final VoidCallback? onImportKey;
final String? accountStatus;
final Widget? setupContent;
final String builderlabLabel;
const PairingPage({
@@ -62,7 +64,9 @@ class PairingPage extends HookConsumerWidget {
this.enteringCommunityLink = false,
this.onCommunityLink,
this.onBuilderlab,
this.onImportKey,
this.accountStatus,
this.setupContent,
this.builderlabLabel = 'Connect to Builderlab',
});
@@ -266,7 +270,9 @@ class PairingPage extends HookConsumerWidget {
ref.read(pendingDeepLinkProvider.notifier).open(uri);
},
onBuilderlab: onBuilderlab,
onImportKey: onImportKey,
accountStatus: accountStatus,
setupContent: setupContent,
builderlabLabel: builderlabLabel,
codeController: codeController,
isBusy: isBusy,

View File

@@ -19,7 +19,7 @@ class _CommunityLinkForm extends HookConsumerWidget {
!flow.accountOnly &&
(flow.stage == HostedFlowStage.working ||
flow.stage == HostedFlowStage.signIn);
final failed = ownsFlow && flow.problem != null;
final failed = ownsFlow && !flow.accountOnly && flow.problem != null;
void submit() {
if (busy || (failed && !flow.canRetry)) return;
@@ -80,7 +80,6 @@ class _CommunityLinkForm extends HookConsumerWidget {
style: context._onboardingGhostButtonStyle,
onPressed: () {
unawaited(flow.leave());
ref.read(hostedInlineEntryProvider.notifier).setActive(false);
onBack?.call();
},
child: const Text('Back'),

View File

@@ -15,7 +15,9 @@ class _PairingWelcomeView extends StatelessWidget {
final bool enteringCommunityLink;
final VoidCallback? onCommunityLink;
final VoidCallback? onBuilderlab;
final VoidCallback? onImportKey;
final String? accountStatus;
final Widget? setupContent;
final String builderlabLabel;
final ValueChanged<Uri> onOpenCommunity;
@@ -35,7 +37,9 @@ class _PairingWelcomeView extends StatelessWidget {
this.enteringCommunityLink = false,
this.onCommunityLink,
this.onBuilderlab,
this.onImportKey,
this.accountStatus,
this.setupContent,
this.builderlabLabel = 'Connect to Builderlab',
});
@@ -101,6 +105,11 @@ class _PairingWelcomeView extends StatelessWidget {
onPressed: isBusy ? null : onAlreadyUseBuzz,
child: const Text('Use pairing code'),
),
TextButton(
style: context._onboardingGhostButtonStyle,
onPressed: isBusy ? null : onImportKey,
child: const Text('Import a Nostr key'),
),
] else ...[
if (!connectionLanding) ...[
FilledButton(
@@ -293,7 +302,9 @@ class _PairingWelcomeView extends StatelessWidget {
constraints: const BoxConstraints(maxWidth: 440),
child: SizedBox(
width: double.infinity,
child: !connectionLanding
child: setupContent != null
? setupContent!
: !connectionLanding
? actions(2)
: ClipRect(
child: Stack(

View File

@@ -8,6 +8,8 @@ import 'package:http/http.dart' as http;
import 'package:nostr/nostr.dart' as nostr;
import '../../shared/auth/auth.dart';
import '../../hosted_app_gate.dart';
import '../../shared/hosted/hosted_environment.dart';
import '../../shared/community/paired_community_landing.dart';
import '../../shared/crypto/ecdh.dart';
import '../../shared/crypto/nip44.dart';
@@ -737,16 +739,6 @@ class PairingNotifier extends Notifier<PairingState> {
_validateRelayUrl(relayUrl);
state = state.copyWith(destinationRelayUrl: relayUrl);
// Validate credentials against the relay via NIP-42 WS handshake.
final credentialValidator = _credentialValidator ?? _validateCredentials;
await credentialValidator(relayUrl: relayUrl, nsec: nsec);
if (pairingGeneration != _pairingGeneration ||
state.status != PairingStatus.storing ||
_sendIdentityToSource) {
return;
}
// Store only after the shared community-entry requirements pass.
final community = Community.create(
name: Community.nameFromUrl(relayUrl),
relayUrl: relayUrl,
@@ -756,6 +748,24 @@ class PairingNotifier extends Notifier<PairingState> {
? SensitiveActionPolicy.enabled
: SensitiveActionPolicy.disabledByUser,
);
bool current() =>
pairingGeneration == _pairingGeneration &&
state.status == PairingStatus.storing &&
!_sendIdentityToSource;
if (HostedEnvironment.isReservedHost(Uri.parse(relayUrl).host)) {
final flow = ref.read(hostedFlowProvider);
await flow.stagePairedCommunity(community, isCurrent: current);
if (!current()) return;
// NIP-AB complete acknowledges durable transfer, not hosted admission.
_sendComplete(true);
_cleanup();
state = const PairingState();
unawaited(flow.retry());
return;
}
final credentialValidator = _credentialValidator ?? _validateCredentials;
await credentialValidator(relayUrl: relayUrl, nsec: nsec);
if (!current()) return;
await ref
.read(authProvider.notifier)
.authenticateWithCommunity(
@@ -869,6 +879,19 @@ class PairingNotifier extends Notifier<PairingState> {
try {
final community = _parseLegacyInput(rawInput);
if (HostedEnvironment.isReservedHost(
Uri.parse(community.relayUrl).host,
)) {
final flow = ref.read(hostedFlowProvider);
await flow.stagePairedCommunity(
community,
isCurrent: () => generation == _pairingGeneration,
);
if (generation != _pairingGeneration) return;
state = const PairingState();
unawaited(flow.retry());
return;
}
final validator = _credentialValidator ?? _validateCredentials;
await validator(relayUrl: community.relayUrl, nsec: community.nsec);

View File

@@ -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';

View File

@@ -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',

View File

@@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'features/channels/deep_link_dispatcher.dart';
import 'features/onboarding/hosted_connect_page.dart';
import 'features/onboarding/self_hosted_entry_sheet.dart';
import 'shared/auth/auth.dart';
import 'shared/theme/grid.dart';
import 'shared/deeplink/deep_link.dart';
@@ -28,7 +29,24 @@ class HostedInlineEntry extends Notifier<bool> {
@override
bool build() => false;
void setActive(bool active) => state = active;
void setActive(bool active) {
if (ref.mounted) state = active;
}
}
/// Signals a completed local admission to the currently mounted Add route.
final communityEntryCompletionProvider =
NotifierProvider<CommunityEntryCompletion, int>(
CommunityEntryCompletion.new,
);
class CommunityEntryCompletion extends Notifier<int> {
@override
int build() => 0;
void complete() {
if (ref.mounted) state++;
}
}
final hostedFlowProvider = Provider<HostedFlow>((ref) {
@@ -36,7 +54,8 @@ final hostedFlowProvider = Provider<HostedFlow>((ref) {
final browser = BrowserAuth();
final store = AccountIdentityStore();
final admission = HostedAdmission(account: account, store: store);
final flow = HostedFlow(
late final HostedFlow flow;
flow = HostedFlow(
account: account,
browser: browser,
store: store,
@@ -55,7 +74,15 @@ final hostedFlowProvider = Provider<HostedFlow>((ref) {
admission: admission,
persist: (community, current) => ref
.read(authProvider.notifier)
.authenticateWithCommunity(community, shouldContinue: current),
.authenticateWithCommunity(
flow.pairedSensitiveActionPolicy == null
? community
: community.copyWith(
sensitiveActionPolicy: flow.pairedSensitiveActionPolicy,
),
shouldContinue: current,
activate: flow.addedCommunityCount == 0,
),
);
},
);
@@ -104,6 +131,12 @@ class HostedAppGate extends HookConsumerWidget {
(flow.accountOnly &&
flow.stage == HostedFlowStage.working &&
flow.account.account == null);
final inlineAccountStatus =
(!authenticated || inlineEntry) &&
flow.accountOnly &&
(flow.stage == HostedFlowStage.working ||
(flow.stage == HostedFlowStage.blocked &&
!flow.canChangeCommunities));
return DeepLinkDispatcher(
dispatchMessageLinks: authenticated && !visible,
onboardingHandler: (routeContext, link) async {
@@ -125,29 +158,23 @@ class HostedAppGate extends HookConsumerWidget {
await ref
.read(communityListProvider.notifier)
.switchCommunity(existing.id);
if (context.mounted) {
ref.read(communityEntryCompletionProvider.notifier).complete();
}
return true;
}
final host = Uri.parse(relay).host.toLowerCase();
final reserved = HostedEnvironment.isReservedHost(host);
if (!reserved && link is AddCommunityDeepLink) {
if (routeContext.mounted) {
await showDialog<void>(
context: routeContext,
builder: (context) => AlertDialog(
title: const Text('An invite or pairing code is needed'),
content: const Text(
'To join this community in this build, enter an invite link '
'or pair with your desktop. Setup using only an address '
'is not available for this community yet.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Back'),
),
],
),
final joined = await showSelfHostedEntry(
routeContext,
relay: link.relayUrl,
name: link.name,
);
if (joined && context.mounted) {
ref.read(communityEntryCompletionProvider.notifier).complete();
}
}
return true;
}
@@ -195,8 +222,8 @@ class HostedAppGate extends HookConsumerWidget {
child: SafeArea(
child: Material(
color: Colors.transparent,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
child: Wrap(
alignment: WrapAlignment.center,
children: [
TextButton(
onPressed: flow.retry,
@@ -213,9 +240,12 @@ class HostedAppGate extends HookConsumerWidget {
),
if (visible &&
!openingSignIn &&
!inlineAccountStatus &&
(!inlineEntry ||
flow.accountOnly ||
flow.stage == HostedFlowStage.chooseIdentity))
flow.stage == HostedFlowStage.chooseIdentity ||
flow.stage == HostedFlowStage.chooseCommunities ||
flow.stage == HostedFlowStage.communityConflict ||
flow.canChangeCommunities))
HostedConnectPage(
flow: flow,
onClose: () => unawaited(flow.leave()),

View File

@@ -4,29 +4,76 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'features/pairing/pairing_page.dart';
import 'features/onboarding/hosted_connect_sheet.dart';
import 'features/pairing/pairing_provider.dart';
import 'hosted_app_gate.dart';
import 'shared/hosted/hosted_flow.dart';
import 'shared/hosted/account_identity_store.dart';
import 'shared/identity_transfer/nsec_import_sheet.dart';
import 'shared/identity_transfer/nsec_transfer.dart';
import 'shared/hosted/hosted_flow_problem_message.dart';
/// Navigation over the existing branded welcome surface.
class OnboardingAppEntry extends HookConsumerWidget {
const OnboardingAppEntry({super.key});
const OnboardingAppEntry({super.key, this.addingCommunity = false});
final bool addingCommunity;
@override
Widget build(BuildContext context, WidgetRef ref) {
final existingUser = useState(false);
final importedIdentity = useState(false);
final enteringCommunityLink = useState(false);
final flow = ref.watch(hostedFlowProvider);
useListenable(flow);
useEffect(() {
Future<void> restore() async {
await flow.initialize();
await flow.signInAccount(openBrowser: false);
if (context.mounted) await flow.signInAccount(openBrowser: false);
}
unawaited(restore());
return null;
}, [flow]);
void finishEntry() {
if (!addingCommunity) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
final route = ModalRoute.of(context);
if (route != null && route.isActive) {
route.navigator?.removeRoute(route);
}
});
}
ref.listen(communityEntryCompletionProvider, (_, _) => finishEntry());
useEffect(() {
final presentation = ref.read(hostedInlineEntryProvider.notifier);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) presentation.setActive(true);
});
void completed() {
if (flow.stage == HostedFlowStage.complete) finishEntry();
}
flow.addListener(completed);
return () {
flow.removeListener(completed);
scheduleMicrotask(() {
presentation.setActive(false);
if (addingCommunity && flow.stage != HostedFlowStage.complete) {
unawaited(flow.leave());
}
});
};
}, [flow]);
final inlineSetup =
!flow.accountOnly &&
flow.stage != HostedFlowStage.idle &&
flow.stage != HostedFlowStage.complete &&
flow.stage != HostedFlowStage.chooseIdentity &&
flow.stage != HostedFlowStage.chooseCommunities &&
flow.stage != HostedFlowStage.communityConflict &&
!flow.canChangeCommunities;
final signingIn = flow.stage == HostedFlowStage.signIn;
final busy =
flow.accountOnly &&
@@ -34,21 +81,18 @@ class OnboardingAppEntry extends HookConsumerWidget {
flow.stage == HostedFlowStage.signIn);
final connected = flow.account.account != null;
final problem = flow.accountOnly ? flow.problem : null;
final status = flow.noCommunitiesToAdd
final status = problem != null
? hostedFlowProblemMessage(problem)
: busy
? 'Connecting to Builderlab…'
: flow.noCommunitiesToAdd
? 'No communities available to add. Scan a QR code or use an invite link.'
: importedIdentity.value
? 'Identity saved. Continue with Builderlab to back it up and find your communities.'
: connected
? 'Connected to Builderlab. Your session is saved securely on this device.'
: problem != null
? switch (problem) {
HostedFlowProblem.differentAccount =>
'A different account is already saved on this device. Sign in with that account.',
HostedFlowProblem.identityUnreadable =>
'Unable to read or save your secure session. Your existing data has been kept.',
_ => 'Could not finish signing in. Please try again.',
}
? 'Connected to Builderlab.'
: null;
void back() {
ref.read(hostedInlineEntryProvider.notifier).setActive(false);
unawaited(flow.leave());
existingUser.value = false;
enteringCommunityLink.value = false;
@@ -57,12 +101,29 @@ class OnboardingAppEntry extends HookConsumerWidget {
}
return PopScope(
canPop: !existingUser.value && !enteringCommunityLink.value,
canPop:
!existingUser.value && !enteringCommunityLink.value && !inlineSetup,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) back();
},
child: PairingPage(
connectionLanding: true,
addingCommunity: addingCommunity,
onImportKey: () async {
final imported = await showNsecImportSheet(
context,
onImport: (nsec) async {
await NsecTransfer.importAccount(
nsec,
AccountIdentityStore(),
checkCurrent: () {
if (!context.mounted) throw StateError('Import cancelled');
},
);
},
);
if (context.mounted && imported) importedIdentity.value = true;
},
existingUser: existingUser.value,
enteringCommunityLink: enteringCommunityLink.value,
onCommunityLink: () => enteringCommunityLink.value = true,
@@ -72,12 +133,21 @@ class OnboardingAppEntry extends HookConsumerWidget {
? null
: () => unawaited(flow.signInAccount()),
accountStatus: status,
setupContent: inlineSetup
? Column(
mainAxisSize: MainAxisSize.min,
children: [
HostedConnectSheet(flow: flow),
TextButton(onPressed: back, child: const Text('Leave setup')),
],
)
: null,
builderlabLabel: signingIn
? 'Cancel sign-in'
: busy
? 'Connecting…'
: connected
? 'Continue with Builderlab'
: busy
? 'Signing in…'
: 'Connect to Builderlab',
onWelcomeBack: back,
onAlreadyUseBuzz: () => existingUser.value = true,

View File

@@ -60,25 +60,28 @@ class AuthNotifier extends AsyncNotifier<AuthState> {
Future<void> authenticateWithCommunity(
Community community, {
bool Function()? shouldContinue,
bool activate = true,
}) {
return ref.read(communityTransitionProvider).runExclusive(() async {
if (shouldContinue != null && !shouldContinue()) return;
await CommunityEntryRequirements.verify(community);
if (shouldContinue != null && !shouldContinue()) return;
await ref.read(communityTransitionProvider).run();
if (activate) await ref.read(communityTransitionProvider).run();
if (shouldContinue != null && !shouldContinue()) return;
final storage = ref.read(communityStorageProvider);
await storage.save(community);
await storage.saveActiveId(community.id);
if (activate) await storage.saveActiveId(community.id);
await syncStoredCommunitySnapshot(ref);
// Invalidate community providers so other consumers pick up the new data.
ref.invalidate(communityListProvider);
ref.invalidate(activeCommunityProvider);
state = AsyncData(
AuthState(status: AuthStatus.authenticated, community: community),
);
if (activate) {
state = AsyncData(
AuthState(status: AuthStatus.authenticated, community: community),
);
}
});
}

View File

@@ -97,6 +97,16 @@ class BrowserAuth {
}
});
/// Persist a verified pairing destination before acknowledging key transfer.
Future<void> retainIntent(Map<String, String> 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<void> start(Map<String, String> intent) async {
final generation = ++_generation;
final uri = await _serial(() async {

View File

@@ -16,6 +16,7 @@ import 'hosted_environment.dart';
enum AccountFailure {
signedOut,
denied,
accountSetupRequired,
unavailable,
invalidResponse,
storage,
@@ -29,7 +30,12 @@ enum AccountFailure {
}
class BuilderlabAccount {
const BuilderlabAccount({required this.subject, required this.expiresAt});
const BuilderlabAccount({
required this.subject,
required this.expiresAt,
this.needsAccountSetup = false,
});
final bool needsAccountSetup;
final String subject;
final DateTime expiresAt;
}
@@ -187,7 +193,17 @@ class BuilderlabAccountClient {
throw AccountFailure.invalidResponse;
}
if (!parsed.isAfter(DateTime.now().toUtc())) throw AccountFailure.signedOut;
return BuilderlabAccount(subject: subject, expiresAt: parsed);
final standing = result['account_standing'];
final needsSetup =
standing is Map &&
(standing['details_status'] == 'ACCOUNT_DETAILS_STATUS_MISSING' ||
standing['screening_status'] ==
'ACCOUNT_SCREENING_STATUS_NOT_SCREENED');
return BuilderlabAccount(
subject: subject,
expiresAt: parsed,
needsAccountSetup: needsSetup,
);
}
/// Public identity metadata only. Private-key recovery belongs to the identity
@@ -208,6 +224,10 @@ class BuilderlabAccountClient {
try {
return await _request(path, credential: credential, body: body);
} on AccountFailure catch (failure) {
if (failure == AccountFailure.denied &&
_account?.needsAccountSetup == true) {
throw AccountFailure.accountSetupRequired;
}
if (failure != AccountFailure.signedOut) rethrow;
}
// Routes also answer 401 for authorization failures, so only a rejected
@@ -259,7 +279,7 @@ class BuilderlabAccountClient {
.timeout(const Duration(seconds: 30));
if (kDebugMode && response.statusCode >= 400) {
debugPrint(
'Builderlab ${request.method} $path: HTTP ${response.statusCode}',
'Builderlab ${origin.host} ${request.method} $path: HTTP ${response.statusCode} contentType=${response.headers['content-type']?.split(';').first}',
);
}
if (response.statusCode == 401) throw AccountFailure.signedOut;

View File

@@ -38,6 +38,16 @@ class DeviceIdentitySource {
return store.create(candidate.nsec, checkCurrent: checkCurrent);
}
/// Supplies a freshly validated saved identity only to an admission service.
Future<T> withIdentity<T>(
String pubkey,
Future<T> Function(String nsec) operation,
) async {
final candidate = (await _read())[pubkey];
if (candidate == null) throw AccountIdentityFailure.absent;
return operation(candidate.nsec);
}
Future<Map<String, ({String nsec, String label})>> _read() async {
try {
final result = <String, ({String nsec, String label})>{};

View File

@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:url_launcher/url_launcher.dart';
import '../deeplink/deep_link.dart';
import '../community/community.dart';
import 'account_identity_store.dart';
import 'device_identity_source.dart';
import 'browser_auth.dart';
@@ -31,6 +32,7 @@ enum HostedFlowStage {
enum HostedFlowProblem {
retry,
denied,
accountSetupRequired,
differentAccount,
identityUnreadable,
backupUnavailable,
@@ -80,6 +82,8 @@ class HostedFlow extends ChangeNotifier {
List<DeviceIdentityChoice> identityChoices = const [];
BuzzDeepLink? _target;
bool accountOnly = false;
String? _pairedPubkey;
SensitiveActionPolicy? pairedSensitiveActionPolicy;
List<AddCommunityDeepLink> communities = const [];
Set<String> selectedRelays = const {};
final Set<String> _addedRelays = {};
@@ -118,6 +122,7 @@ class HostedFlow extends ChangeNotifier {
return;
}
accountOnly = false;
_restorePairing(code.intent);
final target = _decode(code.intent);
if (target == null) throw BrowserAuthFailure.restartLogin;
_target = target;
@@ -162,6 +167,7 @@ class HostedFlow extends ChangeNotifier {
accountOnly = true;
_set(HostedFlowStage.idle);
} else {
_restorePairing(retained);
_target = _decode(retained);
}
if (_target != null && stage == HostedFlowStage.idle) {
@@ -204,7 +210,64 @@ class HostedFlow extends ChangeNotifier {
});
}
Future<void> start(BuzzDeepLink target) {
/// Securely retain the transferred identity before NIP-AB completion. Hosted
/// admission still waits for account, binding and backup in the normal flow.
Future<void> stagePairedCommunity(
Community community, {
required bool Function() isCurrent,
}) async {
final uri = Uri.parse(community.relayUrl);
if (!HostedEnvironment.supportsRelay(uri)) throw AccountFailure.denied;
final nsec = community.nsec;
if (nsec == null) throw AccountIdentityFailure.absent;
final pubkey = AccountIdentityStore.publicKeyForNsec(nsec);
if (community.pubkey != null && community.pubkey != pubkey) {
throw AccountIdentityFailure.mismatch;
}
final device = await DeviceIdentitySource().choices();
if (device.isNotEmpty && !device.any((item) => item.pubkey == pubkey)) {
throw AccountIdentityFailure.mismatch;
}
final generation = ++_generation;
void check() {
if (!isCurrent() || generation != _generation || _disposed) {
throw AccountFailure.unavailable;
}
}
check();
await store.create(nsec, checkCurrent: check);
check();
final relay = uri.replace(scheme: 'wss').toString();
final target = AddCommunityDeepLink(relayUrl: relay, name: community.name);
_pairedPubkey = pubkey;
pairedSensitiveActionPolicy = community.sensitiveActionPolicy;
await browser.retainIntent(_intent(target));
check();
_target = target;
accountOnly = false;
_set(HostedFlowStage.signIn);
}
void _restorePairing(Map<String, String> intent) {
_pairedPubkey = intent['paired_pubkey'];
pairedSensitiveActionPolicy = intent['paired_protection'] == 'enabled'
? SensitiveActionPolicy.enabled
: null;
}
Map<String, String> _intent(BuzzDeepLink target) => {
..._encode(target),
'paired_pubkey': ?_pairedPubkey,
if (pairedSensitiveActionPolicy == SensitiveActionPolicy.enabled)
'paired_protection': 'enabled',
};
Future<void> start(BuzzDeepLink target, {bool resumePairing = false}) {
if (!resumePairing) {
_pairedPubkey = null;
pairedSensitiveActionPolicy = null;
}
if (target is! NostrBindDeepLink &&
target is! AddCommunityDeepLink &&
target is! InviteDeepLink) {
@@ -222,7 +285,7 @@ class HostedFlow extends ChangeNotifier {
if (generation != _generation) return;
if (restored == null) {
_set(HostedFlowStage.signIn);
await browser.start(_encode(target));
await browser.start(_intent(target));
} else {
await _continue(generation);
}
@@ -237,7 +300,7 @@ class HostedFlow extends ChangeNotifier {
await browser.cancel();
if (_disposed || generation != _generation) return;
if (target != null) {
await start(target);
await start(target, resumePairing: _pairedPubkey != null);
} else {
_set(HostedFlowStage.working);
await _enqueue(() async {
@@ -285,6 +348,10 @@ class HostedFlow extends ChangeNotifier {
isCurrent: () => !_disposed && generation == _generation,
selectedPubkey: selectedPubkey,
);
if (_pairedPubkey != null && resolved.pubkey != _pairedPubkey) {
_set(HostedFlowStage.blocked, HostedFlowProblem.identityMismatch);
return;
}
if (target is! NostrBindDeepLink) {
for (var step = 0; step < 2; step++) {
if (_disposed || generation != _generation) return;
@@ -556,6 +623,8 @@ class HostedFlow extends ChangeNotifier {
static HostedFlowProblem _problemFor(Object error) => switch (error) {
AccountFailure.denied => HostedFlowProblem.denied,
AccountFailure.accountSetupRequired =>
HostedFlowProblem.accountSetupRequired,
AccountFailure.rejected ||
HostedBackupFailure.rejected => HostedFlowProblem.requestRejected,
HostedBackupFailure.unavailable => HostedFlowProblem.backupUnavailable,
@@ -601,6 +670,8 @@ class HostedFlow extends ChangeNotifier {
++_generation;
final generation = _generation;
_target = null;
_pairedPubkey = null;
pairedSensitiveActionPolicy = null;
accountOnly = false;
_clearCommunityBatch();
noCommunitiesToAdd = noCommunities;
@@ -631,7 +702,10 @@ class HostedFlow extends ChangeNotifier {
}
static BuzzDeepLink? _decode(Map<String, String> intent) {
final fields = Map<String, String>.of(intent)..remove('type');
final fields = Map<String, String>.of(intent)
..remove('type')
..remove('paired_pubkey')
..remove('paired_protection');
final host = switch (intent['type']) {
'bind' => 'nostr-bind',
'add' => 'add-community',

View File

@@ -5,6 +5,8 @@ String hostedFlowProblemMessage(
) => switch (problem) {
HostedFlowProblem.unsupportedEnvironment =>
'This community needs a different or unsupported Builderlab environment. Use an app build configured for this community.',
HostedFlowProblem.accountSetupRequired =>
'This account is not eligible for Buzz yet.',
HostedFlowProblem.denied => 'This account does not have access.',
HostedFlowProblem.differentAccount =>
'Your browser signed in to a different account. Sign in to your original account on the web, then reopen setup. Your saved account and keys have been kept.',
@@ -19,7 +21,7 @@ String hostedFlowProblemMessage(
HostedFlowProblem.identityMismatch =>
'This account uses a different identity. Your saved identity has been kept.',
HostedFlowProblem.backupRequired =>
'Your identity needs a confirmed backup before joining. Backup setup is not available in this build yet.',
'Your identity needs a confirmed cloud backup before joining. Your saved key has been kept.',
HostedFlowProblem.bindingRequired =>
'Connect your identity from the account setup page first.',
HostedFlowProblem.identityUnavailable =>

View File

@@ -94,7 +94,16 @@ class HostedIdentityService {
final statusKeys = listed.map(_pubkey).toSet();
if (statusBound != bound) throw AccountFailure.invalidResponse;
if (local != null) {
if (statusKeys.any((key) => key != local.pubkey)) {
// A previously chosen recovery key may still await binding after a
// restart. Its account-scoped receipt and current backup listing preserve
// that choice even when the account also retains older identities.
final confirmedSelection =
statusKeys.contains(local.pubkey) &&
local.hasBackupFor(
backend: BuilderlabAccountClient.origin.origin,
subject: subject,
);
if (statusKeys.any((key) => key != local.pubkey) && !confirmedSelection) {
return const HostedIdentityResult(HostedIdentityState.mismatch);
}
return _result(local, bound, subject);
@@ -210,12 +219,29 @@ class HostedIdentityService {
} catch (_) {
throw AccountFailure.invalidResponse;
}
if (candidates.length != 1) {
return const HostedIdentityResult(HostedIdentityState.recoveryBlocked);
}
final candidate = candidates.values.single;
if (bound != null && bound != candidate.keys.public) {
return const HostedIdentityResult(HostedIdentityState.mismatch);
// An account binding is authoritative even when older backups contain
// other keys. Without a binding, distinct keys require an explicit choice;
// never let response ordering choose the user's identity.
final chosen =
bound ??
selectedPubkey ??
(candidates.length == 1 ? candidates.keys.single : null);
final candidate = candidates[chosen];
if (candidate == null) {
if (bound != null) {
return const HostedIdentityResult(HostedIdentityState.mismatch);
}
return HostedIdentityResult(
HostedIdentityState.needsSelection,
choices: [
for (final pubkey in candidates.keys)
DeviceIdentityChoice(
pubkey,
'Use Builderlab identity',
fromCloud: true,
),
],
);
}
if (device.isNotEmpty &&
!device.any((item) => item.pubkey == candidate.keys.public) &&

View File

@@ -0,0 +1,143 @@
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../community/community.dart';
import '../relay/relay_socket.dart';
import '../storage/secure_storage.dart';
import 'account_identity_store.dart';
import 'community_entry_requirements.dart';
import 'device_identity_source.dart';
/// A self-hosted attempt keeps its key before any network operation. Retrying
/// after a refused connection or process death never rotates that identity.
class SelfHostedEntry {
SelfHostedEntry({
FlutterSecureStorage? storage,
DeviceIdentitySource? identities,
AccountIdentityStore? accountStore,
Future<void> 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<void> Function(Community) _probe;
static Future<void> _tail = Future.value();
/// Saved keys available for explicit reuse, including a hosted account that
/// has not joined any community yet. This performs no account network calls.
Future<List<DeviceIdentityChoice>> choices() async {
final device = await identities.choices();
final account = await _accountStore.read();
return [
...device,
if (account != null &&
!device.any((item) => item.pubkey == account.pubkey))
DeviceIdentityChoice(account.pubkey, 'Saved account identity'),
];
}
Future<Community> prepare({
required String relay,
required String name,
String? nsec,
String? savedPubkey,
bool create = false,
required bool Function() isCurrent,
}) {
final result = _tail.then((_) async {
CommunityEntryRequirements.requireSelfHosted(relay);
final uri = Uri.parse(relay);
if (uri.scheme != 'wss' ||
uri.host.isEmpty ||
uri.userInfo.isNotEmpty ||
uri.hasQuery ||
uri.hasFragment ||
(uri.path.isNotEmpty && uri.path != '/')) {
throw const FormatException('Invalid community address');
}
void check() {
if (!isCurrent()) throw StateError('Setup cancelled');
}
check();
final key =
'buzz_pending_self_hosted_${base64Url.encode(utf8.encode(relay))}';
final retained = await _storage.read(key: key);
String? candidate = nsec;
if (savedPubkey != null) {
final account = await _accountStore.read();
candidate = account?.pubkey == savedPubkey
? await _accountStore.withKeys((keys) async {
if (keys.public != savedPubkey) {
throw AccountIdentityFailure.mismatch;
}
return keys.nsec;
})
: await identities.withIdentity(
savedPubkey,
(value) async => value,
);
}
if (retained != null) {
final retainedPubkey = AccountIdentityStore.publicKeyForNsec(retained);
if (candidate != null &&
AccountIdentityStore.publicKeyForNsec(candidate) !=
retainedPubkey) {
throw AccountIdentityFailure.occupied;
}
candidate = retained;
}
if (candidate == null) {
if (!create || (await choices()).isNotEmpty) {
throw AccountIdentityFailure.absent;
}
candidate = nostr.Keys.generate().nsec;
}
final pubkey = AccountIdentityStore.publicKeyForNsec(candidate);
check();
if (retained == null) {
await _storage.write(key: key, value: candidate);
if (await _storage.read(key: key) != candidate) {
throw AccountIdentityFailure.unreadable;
}
}
check();
final community = Community.create(
name: name,
relayUrl: relay,
pubkey: pubkey,
nsec: candidate,
);
await _probe(community);
check();
return community;
});
_tail = result.then<void>((_) {}, onError: (Object _, StackTrace _) {});
return result;
}
/// Authentication must succeed before a new community becomes active.
static Future<void> 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();
}
}
}

View File

@@ -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<void> showNsecExportSheet(
BuildContext context, {
required Community community,
required bool Function() isCurrent,
}) => showBuzzModalBottomSheet<void>(
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<String?>(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<bool> _waitForResumedFrame() async {
final binding = WidgetsBinding.instance;
if (binding.lifecycleState != AppLifecycleState.resumed) {
final resumed = Completer<void>();
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;
}

View File

@@ -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<bool> showNsecImportSheet(
BuildContext context, {
required Future<void> Function(String nsec) onImport,
}) async =>
await showBuzzModalBottomSheet<bool>(
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<void> Function(String nsec) onImport;
final Future<String?> Function()? pickFile;
@override
Widget build(BuildContext context) {
final input = useTextEditingController();
final publicKey = useState<String?>(null);
final error = useState<String?>(null);
final busy = useState(false);
Future<void> 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<void> 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',
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,105 @@
import 'dart:convert';
import 'dart:io';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import '../hosted/account_identity_store.dart';
import '../community/community.dart';
import '../security/sensitive_action_authorizer.dart';
/// Manual transfer boundaries. Exceptions contain no key material.
abstract final class NsecTransfer {
/// Validates a pasted nsec and returns only its public identity.
static String preview(String text) {
if (text.length > 1024) throw AccountIdentityFailure.invalidKey;
return AccountIdentityStore.publicKeyForNsec(text.trim());
}
/// Creates the hosted account slot without replacing an occupied identity.
static Future<AccountIdentitySnapshot> importAccount(
String text,
AccountIdentityStore store, {
void Function()? checkCurrent,
}) => store.create(text.trim(), checkCurrent: checkCurrent);
/// Reads a single small UTF-8 nsec file. Cancellation returns null.
static Future<String?> pickFile() async {
if (defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS) {
final text = await const MethodChannel(
'buzz/identity_file',
).invokeMethod<String>('import');
if (text == null) return null;
preview(text);
return text.trim();
}
final file = await openFile();
if (file == null) return null;
return readFile(file);
}
/// Validates a small UTF-8 private-key file before returning transient input.
static Future<String> readFile(XFile file) async {
try {
if (await file.length() > 1024) {
throw AccountIdentityFailure.invalidKey;
}
final text = utf8.decode(await file.readAsBytes()).trim();
preview(text);
return text;
} catch (_) {
throw AccountIdentityFailure.invalidKey;
}
}
/// Authorizes the selected community identity immediately before export.
static Future<bool> exportCommunity({
required Community community,
required Future<DeviceAuthResult> Function(bool biometricOnly) authorize,
required bool Function() isCurrent,
required Future<bool> 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<bool> shareFile(String nsec, Rect origin) async {
preview(nsec);
if (Platform.isAndroid) {
return await const MethodChannel('buzz/identity_file').invokeMethod<bool>(
'export',
{'text': '${nsec.trim()}\n', 'name': 'buzz-identity.nsec'},
) ??
false;
}
final directory = await (await getTemporaryDirectory()).createTemp(
'buzz-identity-',
);
try {
final file = File('${directory.path}/buzz-identity.nsec');
await file.writeAsString('${nsec.trim()}\n', flush: true);
final result = await SharePlus.instance.share(
ShareParams(
files: [XFile(file.path, mimeType: 'text/plain')],
sharePositionOrigin: origin,
),
);
return result.status == ShareResultStatus.success;
} finally {
await directory.delete(recursive: true);
}
}
}

View File

@@ -25,7 +25,7 @@ class LocalSensitiveActionAuthorizer implements SensitiveActionAuthorizer {
Future<DeviceAuthResult> authorizeIdentityAction({
required bool biometricOnly,
}) => _authorize(
localizedReason: 'Confirm sending your Buzz identity to desktop',
localizedReason: 'Confirm exporting your Buzz identity',
biometricOnly: biometricOnly,
);

View File

@@ -516,6 +516,7 @@ class _RecordingPairAuthNotifier extends _UnauthenticatedAuthNotifier {
Future<void> authenticateWithCommunity(
Community community, {
bool Function()? shouldContinue,
bool activate = true,
}) async {
if (shouldContinue != null && !shouldContinue()) return;
imports += 1;

View File

@@ -790,6 +790,7 @@ class _RecordingAuthNotifier extends AuthNotifier {
Future<void> authenticateWithCommunity(
Community community, {
bool Function()? shouldContinue,
bool activate = true,
}) async {
if (shouldContinue != null && !shouldContinue()) return;
final storage = ref.read(communityStorageProvider);

View File

@@ -36,6 +36,8 @@ void main() {
await tester.pump();
await tester.pump(const Duration(milliseconds: 250));
expect(tester.getTopLeft(logo), position);
await tester.ensureVisible(find.text('Continue'));
await tester.pump();
await tester.tap(find.text('Continue').hitTestable());
await tester.pump();
expect(
@@ -46,6 +48,8 @@ void main() {
find.byKey(const Key('community-link-input')),
'https://team.example.com/invite/abc123',
);
await tester.ensureVisible(find.text('Continue'));
await tester.pump();
await tester.tap(find.text('Continue').hitTestable());
await tester.pump();
final container = ProviderScope.containerOf(

View File

@@ -26,7 +26,7 @@ class _Flow extends HostedFlow {
@override
Future<void> initialize() async {}
@override
Future<void> start(BuzzDeepLink target) async {
Future<void> start(BuzzDeepLink target, {bool resumePairing = false}) async {
started = target;
}
}

View File

@@ -17,6 +17,8 @@ void main() {
testWidgets(
'welcome discovery selection and empty-state handoff use the real flow',
(tester) async {
await tester.binding.setSurfaceSize(const Size(440, 956));
addTearDown(() => tester.binding.setSurfaceSize(null));
// Keep the process-wide secure-store queue in one widget-test zone.
{
FlutterSecureStorage.setMockInitialValues({});
@@ -135,35 +137,21 @@ void main() {
findsOneWidget,
);
f.rows = [f.row('denied')];
f.beforeAdmission = (_) async => throw AccountFailure.denied;
f.rejectCurrent = true;
await tester.tap(find.text('Continue with Builderlab'));
await tester.pumpAndSettle();
expect(find.byType(BuzzSheetHeader), findsOneWidget);
expect(
find.text(
'No communities available to add. Scan a QR code or use an invite link.',
),
findsNothing,
);
final semantics = tester.ensureSemantics();
await tester.pumpAndSettle();
expect(
tester
.binding
.renderViews
.single
.owner!
.semanticsOwner!
.rootSemanticsNode!
.toStringDeep(),
isNot(contains('Scan a QR code')),
);
semantics.dispose();
expect(find.text('Scan a QR code'), findsOneWidget);
await tester.tap(find.byTooltip('Close sheet').hitTestable());
await tester.pumpAndSettle();
expect(find.byType(BuzzSheetHeader), findsNothing);
expect(find.text('Scan a QR code'), findsOneWidget);
expect(find.text('This account does not have access.'), findsOneWidget);
await tester.ensureVisible(find.text('Scan a QR code'));
expect(find.text('Scan a QR code').hitTestable(), findsOneWidget);
await tester.ensureVisible(find.text('Use pairing code'));
expect(find.text('Use pairing code').hitTestable(), findsOneWidget);
await tester.ensureVisible(find.text('Continue with Builderlab'));
f.rejectCurrent = false;
await tester.tap(find.text('Continue with Builderlab'));
await tester.pumpAndSettle();
expect(f.admitted, ['denied']);
expect(find.text('This account does not have access.'), findsNothing);
}
},
);

View File

@@ -146,8 +146,8 @@ void main() {
expect(find.text('Second community'), findsOneWidget);
}
expect(await store.read(), isNull);
await tester.ensureVisible(find.text('Leave setup'));
await tester.tap(find.text('Leave setup'));
// Choice sheets use the shared close control to leave setup.
await tester.tap(find.byTooltip('Close sheet').hitTestable());
await tester.pumpAndSettle();
expect(flow.stage, HostedFlowStage.idle);
expect(flow.identityChoices, isEmpty);

View File

@@ -0,0 +1,414 @@
import 'dart:async';
import 'dart:convert';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/onboarding/hosted_connect_page.dart';
import 'package:buzz/features/onboarding/self_hosted_entry_sheet.dart';
import 'package:buzz/hosted_app_gate.dart';
import 'package:buzz/onboarding_app_entry.dart';
import 'package:buzz/shared/auth/auth.dart';
import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart';
import 'package:buzz/shared/hosted/builderlab_account.dart';
import 'package:buzz/shared/hosted/hosted_flow.dart';
import 'package:buzz/shared/hosted/account_identity_store.dart';
import 'package:buzz/shared/hosted/device_identity_source.dart';
import 'package:buzz/shared/hosted/hosted_identity_service.dart';
import 'package:buzz/shared/hosted/hosted_community_connection.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:buzz/shared/hosted/self_hosted_entry.dart';
import 'package:buzz/shared/widgets/buzz_sheet_header.dart';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../helpers/hosted_communities_fixture.dart';
import '../../helpers/widget_helpers.dart';
class _NoChannels extends ChannelsNotifier {
@override
Future<List<Channel>> 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<Uri>.empty();
final f = HostedCommunitiesFixture();
await f.initialize(widget: true);
addTearDown(() async {
PendingDeepLinkNotifier.debugUriStreamOverride = null;
await f.dispose();
});
var probes = 0;
await tester.pumpWidget(
WidgetHelpers.testable(
disableAnimations: true,
overrides: [
hostedFlowProvider.overrideWithValue(f.flow),
channelsProvider.overrideWith(_NoChannels.new),
selfHostedEntryProvider.overrideWithValue(
SelfHostedEntry(
probe: (_) async {
probes++;
},
),
),
],
child: HostedAppGate(
authenticated: true,
child: Builder(
builder: (context) => Center(
child: FilledButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(
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<void> openEntry() async {
await tester.tap(find.text('Add community'));
await tester.pumpAndSettle();
expect(find.byType(OnboardingAppEntry), findsOneWidget);
}
Future<void> pair() async {
await tester.tap(find.text('Use pairing code').hitTestable());
await tester.pumpAndSettle();
await tester.enterText(
find.byType(TextField).hitTestable(),
base64Url.encode(
utf8.encode(
jsonEncode({
'relayUrl': 'https://paired.communities.buzz.xyz',
'nsec': f.keys.nsec,
'pubkey': f.keys.public,
}),
),
),
);
await tester.tap(find.text('Connect').hitTestable());
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
}
// A root-navigator choice modal must remain actionable above pushed Add.
f.rows = [f.row('one'), f.row('two')];
f.beforeAdmission = (name) async {
if (name == 'two') throw const SavedCommunityIdentityConflict();
};
await openEntry();
await tester.tap(find.text('Continue with Builderlab').hitTestable());
await tester.pumpAndSettle();
expect(f.flow.stage, HostedFlowStage.chooseCommunities);
expect(find.text('Choose your communities').hitTestable(), findsOneWidget);
expect(find.byType(CheckboxListTile).hitTestable(), findsNWidgets(2));
expect(find.text('Continue').hitTestable(), findsOneWidget);
await tester.tap(find.text('Continue').hitTestable());
await tester.pumpAndSettle();
expect(f.flow.stage, HostedFlowStage.communityConflict);
expect(
find.text('Keep saved identity and skip').hitTestable(),
findsOneWidget,
);
expect(find.text('Back to choices').hitTestable(), findsOneWidget);
await tester.tap(find.text('Back to choices').hitTestable());
await tester.pumpAndSettle();
expect(find.byType(CheckboxListTile).hitTestable(), findsOneWidget);
expect(find.text('two').hitTestable(), findsOneWidget);
f.beforeAdmission = null;
await tester.tap(find.text('Continue').hitTestable());
await tester.pumpAndSettle();
expect(f.admitted, ['one', 'two']);
expect(find.byType(OnboardingAppEntry), findsNothing);
expect(find.text('Add community').hitTestable(), findsOneWidget);
f.admitted.clear();
f.rows = [];
await f.flow.leave();
await openEntry();
final admission = Completer<void>();
f.beforeAdmission = (_) => admission.future;
await pair();
expect(f.flow.accountOnly, isFalse);
expect(f.flow.stage, HostedFlowStage.working);
expect(find.text('Leave setup').hitTestable(), findsOneWidget);
expect(
find.byType(CircularProgressIndicator).hitTestable(),
findsOneWidget,
);
expect(find.byType(BuzzSheetHeader), findsNothing);
expect(find.byType(HostedConnectPage), findsNothing);
admission.completeError(AccountFailure.unavailable);
await tester.pumpAndSettle();
expect(f.flow.stage, HostedFlowStage.blocked);
expect(
find
.text('We could not finish connecting. You can try again.')
.hitTestable(),
findsOneWidget,
);
expect(find.text('Try again').hitTestable(), findsOneWidget);
expect(find.text('Leave setup').hitTestable(), findsOneWidget);
expect(find.byType(BuzzSheetHeader), findsNothing);
f.beforeAdmission = null;
await tester.tap(find.text('Try again').hitTestable());
await tester.pumpAndSettle();
expect(f.admitted, ['paired']);
expect(find.byType(OnboardingAppEntry), findsNothing);
expect(find.text('Add community').hitTestable(), findsOneWidget);
// Cancelling a paired sign-in keeps the Add route usable and clears intent.
await f.flow.leave();
await openEntry();
f.expired = true;
await pair();
await tester.pumpAndSettle();
expect(f.flow.stage, HostedFlowStage.signIn);
expect(
find.text('Complete sign-in in your browser.').hitTestable(),
findsOneWidget,
);
expect(find.text('Leave setup').hitTestable(), findsOneWidget);
await tester.tap(find.text('Leave setup').hitTestable());
await tester.pumpAndSettle();
expect(f.flow.stage, HostedFlowStage.idle);
expect(find.text('Enter a community link').hitTestable(), findsOneWidget);
expect(container.read(hostedInlineEntryProvider), isTrue);
f.expired = false;
// Drive the real deep-link dispatcher, sheet, service and auth persistence.
await tester.tap(find.text('Enter a community link').hitTestable());
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('community-link-input')).hitTestable(),
'new-community.example',
);
await tester.tap(find.text('Continue').hitTestable());
await tester.pumpAndSettle();
expect(find.byType(SelfHostedEntrySheet), findsOneWidget);
await tester.tap(find.text('Saved account identity').hitTestable());
await tester.pumpAndSettle();
expect(probes, 1);
expect(find.byType(SelfHostedEntrySheet), findsNothing);
expect(find.byType(OnboardingAppEntry), findsNothing);
expect(find.text('Add community').hitTestable(), findsOneWidget);
final saved = await container.read(communityStorageProvider).loadAll();
expect(saved.single.relayUrl, 'wss://new-community.example');
expect(saved.single.pubkey, f.keys.public);
expect(
await container.read(communityStorageProvider).loadActiveId(),
saved.single.id,
);
// Reopening an already saved URL switches to it and also closes Add.
await openEntry();
await tester.tap(find.text('Enter a community link').hitTestable());
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('community-link-input')).hitTestable(),
'new-community.example',
);
await tester.tap(find.text('Continue').hitTestable());
await tester.pumpAndSettle();
expect(find.byType(OnboardingAppEntry), findsNothing);
expect(find.text('Add community').hitTestable(), findsOneWidget);
expect(probes, 1);
expect(
await container.read(communityStorageProvider).loadActiveId(),
saved.single.id,
);
// Popping Add releases ownership; a later invite uses the app-level UI.
await openEntry();
await tester.tap(find.text('Enter a community link').hitTestable());
await tester.pumpAndSettle();
await tester.tap(find.text('Back').hitTestable());
await tester.pumpAndSettle();
expect(container.read(hostedInlineEntryProvider), isTrue);
await tester.pageBack();
await tester.pumpAndSettle();
expect(find.byType(OnboardingAppEntry), findsNothing);
expect(container.read(hostedInlineEntryProvider), isFalse);
f.expired = true;
container
.read(pendingDeepLinkProvider.notifier)
.open(
Uri(
scheme: 'buzz',
host: 'join',
queryParameters: {
'relay': 'wss://invited.communities.buzz.xyz',
'code': 'invite',
},
),
);
await tester.pumpAndSettle();
expect(f.flow.stage, HostedFlowStage.signIn);
expect(find.text('Start login again').hitTestable(), findsOneWidget);
expect(find.text('Leave setup').hitTestable(), findsOneWidget);
await tester.tap(find.text('Leave setup').hitTestable());
await tester.pumpAndSettle();
f.expired = false;
// Disposing the entire scope while an entry owns presentation is safe.
await openEntry();
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
expect(tester.takeException(), isNull);
// A new coordinator restores the paired target and presents its waiting
// state on the entry route, without depending on accountOnly discovery.
await f.flow.stagePairedCommunity(
Community.create(
name: 'Restored',
relayUrl: 'wss://restored.communities.buzz.xyz',
pubkey: f.keys.public,
nsec: f.keys.nsec,
),
isCurrent: () => true,
);
final restartedBrowser = DiscoveryBrowser();
final restarted = HostedFlow(
account: f.account,
browser: restartedBrowser,
store: f.store,
identity: f.flow.identity,
enterCommunity: (link, subject, pubkey, current) async {},
);
addTearDown(() async {
restarted.dispose();
await restartedBrowser.dispose();
});
await tester.pumpWidget(
WidgetHelpers.testable(
disableAnimations: true,
overrides: [hostedFlowProvider.overrideWithValue(restarted)],
child: const HostedAppGate(
authenticated: false,
child: OnboardingAppEntry(),
),
),
);
await tester.pumpAndSettle();
expect(restarted.stage, HostedFlowStage.signIn);
expect(
find.text('Complete sign-in in your browser.').hitTestable(),
findsOneWidget,
);
expect(find.text('Leave setup').hitTestable(), findsOneWidget);
expect(find.byType(BuzzSheetHeader), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
expect(tester.takeException(), isNull);
// Identity selection is also a real root-navigator sheet above Add.
final first = nostr.Keys('1'.padLeft(64, '0'));
final second = nostr.Keys('2'.padLeft(64, '0'));
FlutterSecureStorage.setMockInitialValues({
'buzz_communities': jsonEncode([
{'name': 'First', 'nsec': first.nsec, 'pubkey': first.public},
{'name': 'Second', 'nsec': second.nsec, 'pubkey': second.public},
]),
});
final choiceAccount = _ChoiceAccount();
final choiceStore = AccountIdentityStore();
final choiceBrowser = DiscoveryBrowser();
final choiceFlow = HostedFlow(
account: choiceAccount,
browser: choiceBrowser,
store: choiceStore,
identity: HostedIdentityService(
account: choiceAccount,
store: choiceStore,
deviceIdentities: DeviceIdentitySource(),
),
enterCommunity: (_, _, _, _) async =>
fail('Selection cannot bypass binding'),
);
addTearDown(() async {
choiceFlow.dispose();
choiceAccount.dispose();
await choiceBrowser.dispose();
});
await tester.pumpWidget(
WidgetHelpers.testable(
disableAnimations: true,
overrides: [
hostedFlowProvider.overrideWithValue(choiceFlow),
channelsProvider.overrideWith(_NoChannels.new),
],
child: HostedAppGate(
authenticated: true,
child: Builder(
builder: (context) => Center(
child: FilledButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
const OnboardingAppEntry(addingCommunity: true),
),
),
child: const Text('Add community'),
),
),
),
),
),
);
await tester.pumpAndSettle();
await openEntry();
await tester.tap(find.text('Continue with Builderlab').hitTestable());
await tester.pumpAndSettle();
expect(choiceFlow.stage, HostedFlowStage.chooseIdentity);
expect(find.text('First').hitTestable(), findsOneWidget);
expect(find.text('Second').hitTestable(), findsOneWidget);
await tester.tap(find.text('Second').hitTestable());
await tester.pumpAndSettle();
expect((await choiceStore.read())!.pubkey, second.public);
expect(choiceFlow.stage, HostedFlowStage.blocked);
expect(
find.text('This account does not have access.').hitTestable(),
findsOneWidget,
);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
expect(tester.takeException(), isNull);
});
}
class _ChoiceAccount extends BuilderlabAccountClient {
@override
BuilderlabAccount get account =>
BuilderlabAccount(subject: 'fixture', expiresAt: DateTime.utc(2100));
@override
Future<BuilderlabAccount?> restore() async => account;
@override
Future<Map<String, dynamic>> currentIdentity() async => {};
@override
Future<Map<String, dynamic>> post(
String path,
Map<String, dynamic> body,
) async {
if (path.endsWith('/status')) return {'backup_nostr_pubkeys': []};
throw AccountFailure.denied;
}
}

View File

@@ -0,0 +1,120 @@
import 'dart:async';
import 'dart:convert';
import 'package:buzz/features/pairing/pairing_provider.dart';
import 'package:buzz/hosted_app_gate.dart';
import 'package:buzz/shared/community/community.dart';
import 'package:buzz/shared/hosted/hosted_flow.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../../helpers/hosted_communities_fixture.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => FlutterSecureStorage.setMockInitialValues({}));
for (final backup in [true, false]) {
test(
'hosted pairing requires backup=$backup through real coordinator',
() async {
final f = HostedCommunitiesFixture();
await f.initialize(backup: backup);
addTearDown(f.dispose);
var legacyProbes = 0;
final notifier = PairingNotifier(
credentialValidator: ({required relayUrl, required nsec}) async {
legacyProbes++;
},
);
final container = ProviderContainer(
overrides: [
hostedFlowProvider.overrideWithValue(f.flow),
pairingProvider.overrideWith(() => notifier),
],
);
addTearDown(container.dispose);
final settled = Completer<void>();
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 = <String>[];
final resumed = HostedFlow(
account: f.account,
browser: browser,
store: f.store,
identity: f.flow.identity,
enterCommunity: (link, subject, pubkey, current) async {
if (current()) admitted.add(pubkey);
},
);
addTearDown(() async {
resumed.dispose();
await browser.dispose();
});
await resumed.initialize();
expect(resumed.stage, HostedFlowStage.signIn);
expect(
resumed.pairedSensitiveActionPolicy,
SensitiveActionPolicy.enabled,
);
await resumed.retry();
expect(admitted, [f.keys.public]);
expect(resumed.stage, HostedFlowStage.complete);
},
);
test('hosted pairing never replaces occupied account key', () async {
final f = HostedCommunitiesFixture();
await f.initialize();
addTearDown(f.dispose);
final container = ProviderContainer(
overrides: [hostedFlowProvider.overrideWithValue(f.flow)],
);
addTearDown(container.dispose);
await container
.read(pairingProvider.notifier)
.pair(_payload(nostr.Keys('2'.padLeft(64, '0'))));
expect(container.read(pairingProvider).status, PairingStatus.error);
expect((await f.store.read())?.pubkey, f.keys.public);
expect(f.admitted, isEmpty);
});
}
String _payload(nostr.Keys keys) => base64Url.encode(
utf8.encode(
jsonEncode({
'relayUrl': 'https://paired.communities.buzz.xyz',
'nsec': keys.nsec,
'pubkey': keys.public,
}),
),
);

View File

@@ -1285,6 +1285,7 @@ class FakeAuthNotifier extends AsyncNotifier<AuthState>
Future<void> authenticateWithCommunity(
Community community, {
bool Function()? shouldContinue,
bool activate = true,
}) async {
if (shouldContinue != null && !shouldContinue()) return;
lastCommunity = community;

View File

@@ -30,8 +30,6 @@ class DiscoveryBrowser extends BrowserAuth {
@override
Future<void> initialize() async {}
@override
Future<Map<String, String>?> retainedIntent() async => null;
@override
Future<void> clearIntent() async {}
@override
Future<void> cancel() async {}
@@ -55,6 +53,7 @@ class WidgetDiscoveryAccount extends BuilderlabAccountClient {
Map<String, dynamic> body,
) async {
if (path.endsWith('/current')) {
if (fixture.rejectCurrent) throw AccountFailure.denied;
return {
'identity': {'pubkey_hex': fixture.bound},
};
@@ -77,6 +76,7 @@ class HostedCommunitiesFixture {
List<Map<String, dynamic>> rows = [];
String? bound;
bool rejectList = false;
bool rejectCurrent = false;
bool expired = false;
String subject = 'owner';
Future<void> Function(String)? beforeAdmission;

View File

@@ -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(

View File

@@ -61,6 +61,76 @@ void main() {
},
);
for (final scenario in [
(
'ACCOUNT_DETAILS_STATUS_MISSING',
'ACCOUNT_SCREENING_STATUS_NOT_SCREENED',
true,
),
(
'ACCOUNT_DETAILS_STATUS_COMPLETE',
'ACCOUNT_SCREENING_STATUS_NOT_SCREENED',
true,
),
(
'ACCOUNT_DETAILS_STATUS_COMPLETE',
'ACCOUNT_SCREENING_STATUS_REJECTED',
false,
),
(
'ACCOUNT_DETAILS_STATUS_COMPLETE',
'ACCOUNT_SCREENING_STATUS_PASSED',
false,
),
]) {
test(
'account screening ${scenario.$2} maps denial and preserves session',
() async {
final client = BuilderlabAccountClient(
httpClient: MockClient((request) async {
if (request.url.path.endsWith('/exchange')) {
return http.Response(
jsonEncode({
'session_credential': 'fixture',
'expires_at': expiry,
}),
200,
);
}
if (request.url.path.endsWith('/me')) {
return http.Response(
jsonEncode({
'subject': 'fixture',
'expires_at': expiry,
'roles': ['ROLE_USER'],
'enabled_products': [],
'account_standing': {
'details_status': scenario.$1,
'screening_status': scenario.$2,
},
}),
200,
);
}
return http.Response('forbidden', 403);
}),
);
addTearDown(client.dispose);
await client.exchange(code: 'fixture', verifier: verifier);
expect(client.account?.needsAccountSetup, scenario.$3);
await expectLater(
client.currentIdentity(),
throwsA(
scenario.$3
? AccountFailure.accountSetupRequired
: AccountFailure.denied,
),
);
expect(client.account, isNotNull);
},
);
}
test('invalid verification cannot persist an exchanged credential', () async {
final transport = MockClient(
(request) async => http.Response(

View File

@@ -139,7 +139,7 @@ void main() {
);
expect(await AccountIdentityStore().read(), isNull);
});
test('distinct recovered keys block even if one matches binding', () async {
test('distinct recovered keys select the matching account binding', () async {
(recovery['backups'] as List).add({
'backup_id': 'two',
'nostr_pubkey': other.public,
@@ -151,11 +151,8 @@ void main() {
account: await client(),
store: AccountIdentityStore(),
);
expect(
(await service.resolve()).state,
HostedIdentityState.recoveryBlocked,
);
expect(await AccountIdentityStore().read(), isNull);
expect((await service.resolve()).state, HostedIdentityState.ready);
expect((await AccountIdentityStore().read())!.pubkey, key.public);
});
test('empty recovery and unbound does not generate', () async {

View File

@@ -0,0 +1,327 @@
import 'dart:convert';
import 'package:buzz/shared/hosted/account_identity_store.dart';
import 'package:buzz/shared/hosted/builderlab_account.dart';
import 'package:buzz/shared/hosted/device_identity_source.dart';
import 'package:buzz/shared/hosted/hosted_identity_service.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:nostr/nostr.dart' as nostr;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
// Public test vectors only; no user signing material.
final keys = [for (var i = 1; i <= 3; i++) nostr.Keys('$i'.padLeft(64, '0'))];
Map<String, dynamic> 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<String, dynamic> recovery;
late List<String> listedKeys;
late AccountIdentityStore store;
late String communityJson;
late int recoveryCalls;
late int recoveryStatus;
Future<void> Function()? duringRecovery;
setUp(() {
bound = null;
listedKeys = [keys[0].public, keys[1].public];
recovery = {
'backups': [backup(0), backup(1)],
'failed_backup_ids': [],
};
communityJson = jsonEncode([
{'name': 'Existing', 'nsec': keys[2].nsec, 'pubkey': keys[2].public},
]);
FlutterSecureStorage.setMockInitialValues({
'buzz_communities': communityJson,
});
store = AccountIdentityStore();
recoveryCalls = 0;
recoveryStatus = 200;
duringRecovery = null;
});
Future<HostedIdentityService> service({bool includeDevice = false}) async {
final expiry = DateTime.now()
.toUtc()
.add(const Duration(hours: 1))
.toIso8601String();
final account = BuilderlabAccountClient(
httpClient: MockClient((request) async {
final path = request.url.path;
if (path.endsWith('/exchange')) {
return http.Response(
jsonEncode({'session_credential': 'token', 'expires_at': expiry}),
200,
);
}
if (path.endsWith('/me')) {
return http.Response(
jsonEncode({'subject': 'a', 'expires_at': expiry}),
200,
);
}
if (path.endsWith('/current')) {
return http.Response(
jsonEncode({
if (bound != null) 'identity': {'pubkey_hex': bound},
}),
200,
);
}
if (path.endsWith('/status')) {
return http.Response(
jsonEncode({
'current_nostr_pubkey': bound,
'backup_nostr_pubkeys': listedKeys,
}),
200,
);
}
if (path.endsWith('/recovery')) {
recoveryCalls++;
await duringRecovery?.call();
return http.Response(jsonEncode(recovery), recoveryStatus);
}
return http.Response('{}', 404);
}),
);
await account.exchange(code: 'code', verifier: 'a' * 43);
return HostedIdentityService(
account: account,
store: store,
deviceIdentities: includeDevice ? DeviceIdentitySource() : null,
canGenerateAfterLegacyReconciliation: () async => true,
);
}
Future<void> expectCommunityPreserved() async {
expect(
await const FlutterSecureStorage().read(key: 'buzz_communities'),
communityJson,
);
}
test(
'bound identity selects matching backup, persists receipt and resumes',
() async {
bound = keys[1].public;
final resolver = await service();
final result = await resolver.resolve(selectedPubkey: keys[0].public);
expect(result.state, HostedIdentityState.ready);
expect(result.pubkey, keys[1].public);
final saved = (await store.read())!;
expect(saved.backup!.backupId, 'backup-1');
expect(
saved.hasBackupFor(
backend: BuilderlabAccountClient.origin.origin,
subject: 'a',
),
isTrue,
);
expect((await resolver.resolve()).pubkey, keys[1].public);
expect(recoveryCalls, 1);
await expectCommunityPreserved();
},
);
test(
'unbound distinct keys expose public choices and persist only selection',
() async {
final resolver = await service(includeDevice: true);
final pending = await resolver.resolve();
expect(pending.state, HostedIdentityState.needsSelection);
expect(pending.choices.map((choice) => choice.pubkey), [
keys[0].public,
keys[1].public,
]);
expect(pending.choices.every((choice) => choice.fromCloud), isTrue);
expect(await store.read(), isNull);
final chosen = await resolver.resolve(selectedPubkey: keys[1].public);
expect(chosen.state, HostedIdentityState.needsBinding);
expect(chosen.pubkey, keys[1].public);
expect((await store.read())!.backup!.backupId, 'backup-1');
await expectCommunityPreserved();
},
);
test(
'selected unbound recovery resumes binding after restart without choosing again',
() async {
final first = await service();
final selected = await first.resolve(selectedPubkey: keys[1].public);
expect(selected.state, HostedIdentityState.needsBinding);
final receipt = (await store.read())!.backup!;
final restarted = await service();
final resumed = await restarted.resolve();
expect(resumed.state, HostedIdentityState.needsBinding);
expect(resumed.pubkey, keys[1].public);
expect((await store.read())!.backup!.backupId, receipt.backupId);
expect(recoveryCalls, 1);
await expectCommunityPreserved();
},
);
test(
'local key without a current-account receipt cannot bypass distinct backup conflict',
() async {
await store.create(keys[1].nsec);
final resolver = await service();
expect((await resolver.resolve()).state, HostedIdentityState.mismatch);
await store.confirmBackup(
BackupReceipt(
backend: BuilderlabAccountClient.origin.origin,
subject: 'another-account',
pubkey: keys[1].public,
backupId: 'other-account-backup',
),
);
expect((await resolver.resolve()).state, HostedIdentityState.mismatch);
expect(recoveryCalls, 0);
expect((await store.read())!.pubkey, keys[1].public);
},
);
test(
'receipt does not bypass conflict when selected key is no longer listed',
() async {
final resolver = await service();
await resolver.resolve(selectedPubkey: keys[1].public);
listedKeys = [keys[0].public];
expect((await resolver.resolve()).state, HostedIdentityState.mismatch);
expect((await store.read())!.pubkey, keys[1].public);
expect(recoveryCalls, 1);
},
);
test(
'unknown or removed selection asks again without selecting another key',
() async {
final resolver = await service();
expect(
(await resolver.resolve(selectedPubkey: keys[2].public)).state,
HostedIdentityState.needsSelection,
);
recovery['backups'] = [backup(0)];
expect(
(await resolver.resolve(selectedPubkey: keys[1].public)).state,
HostedIdentityState.needsSelection,
);
expect(await store.read(), isNull);
},
);
test(
'duplicate backups of one key do not create an ambiguous choice',
() async {
recovery['backups'] = [backup(0), backup(0, id: 'duplicate')];
final result = await (await service()).resolve();
expect(result.state, HostedIdentityState.needsBinding);
expect(result.pubkey, keys[0].public);
},
);
test('missing bound backup never accepts a different selected key', () async {
bound = keys[2].public;
expect(
(await (await service()).resolve(selectedPubkey: keys[0].public)).state,
HostedIdentityState.mismatch,
);
expect(await store.read(), isNull);
});
test('different device key still requires explicit cloud adoption', () async {
bound = keys[1].public;
final resolver = await service(includeDevice: true);
final pending = await resolver.resolve();
expect(pending.state, HostedIdentityState.needsSelection);
expect(pending.choices.single.pubkey, keys[1].public);
expect(await store.read(), isNull);
expect(
(await resolver.resolve(selectedPubkey: keys[1].public)).state,
HostedIdentityState.ready,
);
await expectCommunityPreserved();
});
test('partial recovery blocks even a valid matching bound backup', () async {
bound = keys[1].public;
recovery['failed_backup_ids'] = ['unreadable'];
expect(
(await (await service()).resolve()).state,
HostedIdentityState.recoveryBlocked,
);
expect(await store.read(), isNull);
});
test(
'malformed extra backup blocks before saving the valid bound backup',
() async {
bound = keys[0].public;
(recovery['backups'] as List)[1]['secret_key'] = 'invalid';
await expectLater(
(await service()).resolve(),
throwsA(AccountFailure.invalidResponse),
);
expect(await store.read(), isNull);
},
);
test(
'recovery failure and empty recovery never authorize generation',
() async {
final resolver = await service();
recoveryStatus = 403;
await expectLater(resolver.resolve(), throwsA(isA<AccountFailure>()));
expect(await store.read(), isNull);
recoveryStatus = 200;
recovery['backups'] = [];
expect(
(await resolver.resolve()).state,
HostedIdentityState.needsAuthoritativeAbsence,
);
expect(await store.read(), isNull);
},
);
test(
'occupied account slot is never replaced by a recovered choice',
() async {
final resolver = await service();
duringRecovery = () async {
await store.create(keys[2].nsec);
};
await expectLater(
resolver.resolve(selectedPubkey: keys[1].public),
throwsA(AccountIdentityFailure.occupied),
);
expect((await store.read())!.pubkey, keys[2].public);
await expectCommunityPreserved();
},
);
test('cancelled recovery cannot persist the selected identity', () async {
var current = true;
final resolver = await service();
duringRecovery = () async {
current = false;
};
await expectLater(
resolver.resolve(
selectedPubkey: keys[1].public,
isCurrent: () => current,
),
throwsA(AccountFailure.unavailable),
);
expect(await store.read(), isNull);
});
}

View File

@@ -0,0 +1,312 @@
import 'dart:convert';
import 'package:buzz/shared/community/community.dart';
import 'package:buzz/shared/hosted/account_identity_store.dart';
import 'package:buzz/shared/hosted/device_identity_source.dart';
import 'package:buzz/shared/hosted/self_hosted_entry.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nostr/nostr.dart' as nostr;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const relay = 'wss://community.example';
// Public test vectors, never real user signing material.
final key = nostr.Keys('1'.padLeft(64, '0'));
final other = nostr.Keys('2'.padLeft(64, '0'));
late _Storage storage;
late List<Community> 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<Community> prepare({
String address = relay,
String? nsec,
String? savedPubkey,
bool create = false,
}) => entry.prepare(
relay: address,
name: 'Community',
nsec: nsec,
savedPubkey: savedPubkey,
create: create,
isCurrent: () => current,
);
void expectUntouched() {
expect(storage.writes, 0);
expect(probes, isEmpty);
}
test(
'reserved hosted domains refuse imported and generated identities',
() async {
for (final address in [
'wss://team.communities.buzz.xyz',
'wss://team.communities.public.builderlabstaging.xyz',
'wss://BUILDERLAB.XYZ.',
]) {
await expectLater(
prepare(address: address, nsec: key.nsec),
throwsStateError,
);
await expectLater(
prepare(address: address, create: true),
throwsStateError,
);
}
expectUntouched();
expect(storage.data, isEmpty);
},
);
test('invalid addresses and invalid nsec never persist or probe', () async {
for (final address in [
'ws://community.example',
'wss://user@community.example',
'wss://community.example/path',
'wss://community.example?token=value',
'wss://community.example#fragment',
]) {
await expectLater(
prepare(address: address, nsec: key.nsec),
throwsFormatException,
);
}
for (final nsec in ['invalid', key.public, key.npub]) {
await expectLater(
prepare(nsec: nsec),
throwsA(AccountIdentityFailure.invalidKey),
);
}
expectUntouched();
expect(storage.data, isEmpty);
});
test(
'failed probe retains generated key across a new service and retry',
() async {
refuseProbe = true;
await expectLater(prepare(create: true), throwsStateError);
expect(storage.writes, 1);
final persisted = Map<String, String>.of(storage.data);
final first = probes.single;
expect(first.pubkey, AccountIdentityStore.publicKeyForNsec(first.nsec!));
refuseProbe = false;
entry = makeEntry();
final retried = await prepare(create: true);
expect(retried.pubkey, first.pubkey);
expect(retried.nsec, first.nsec);
expect(storage.data, persisted);
expect(storage.writes, 1);
expect(probes, hasLength(2));
},
);
test('existing local identity prevents generating a replacement', () async {
storage.data['buzz_nsec'] = key.nsec;
final before = Map<String, String>.of(storage.data);
await expectLater(
prepare(create: true),
throwsA(AccountIdentityFailure.absent),
);
expectUntouched();
expect(storage.data, before);
});
test(
'explicit saved identity reuses key and preserves community records',
() async {
final raw = jsonEncode([
{'name': 'Saved', 'nsec': key.nsec, 'pubkey': key.public},
]);
storage.data['buzz_communities'] = raw;
storage.data['buzz_nsec'] = other.nsec;
final community = await prepare(savedPubkey: key.public);
expect(community.nsec, key.nsec);
expect(community.pubkey, key.public);
expect(community.relayUrl, relay);
expect(storage.data['buzz_communities'], raw);
expect(storage.data['buzz_nsec'], other.nsec);
expect(storage.writes, 1);
expect(probes, [community]);
},
);
test(
'account-only saved identity blocks generation and supports explicit reuse',
() async {
final accountStore = AccountIdentityStore(storage: storage);
await accountStore.create(key.nsec);
final before = Map<String, String>.of(storage.data);
final writesBefore = storage.writes;
final choices = await entry.choices();
expect(choices.map((choice) => choice.pubkey), [key.public]);
await expectLater(
prepare(create: true),
throwsA(AccountIdentityFailure.absent),
);
expect(storage.writes, writesBefore);
expect(probes, isEmpty);
expect(storage.data, before);
final community = await prepare(savedPubkey: key.public);
expect(community.pubkey, key.public);
expect(community.nsec, key.nsec);
expect(
storage.data[AccountIdentityStore.storageKey],
before[AccountIdentityStore.storageKey],
);
},
);
test(
'account and community choices deduplicate the same saved identity',
() async {
await AccountIdentityStore(storage: storage).create(key.nsec);
storage.data['buzz_nsec'] = key.nsec;
expect((await entry.choices()).map((choice) => choice.pubkey), [
key.public,
]);
},
);
test('unreadable account key never permits new generation', () async {
storage.data[AccountIdentityStore.storageKey] = 'broken';
await expectLater(
prepare(create: true),
throwsA(AccountIdentityFailure.unreadable),
);
expectUntouched();
});
test(
'different import cannot replace retained key after a failed probe',
() async {
refuseProbe = true;
await expectLater(prepare(nsec: key.nsec), throwsStateError);
final before = Map<String, String>.of(storage.data);
await expectLater(
prepare(nsec: other.nsec),
throwsA(AccountIdentityFailure.occupied),
);
expect(storage.data, before);
expect(storage.writes, 1);
expect(probes, hasLength(1));
},
);
test(
'missing explicit saved identity never falls back to generation',
() async {
await expectLater(
prepare(savedPubkey: key.public, create: true),
throwsA(AccountIdentityFailure.absent),
);
expectUntouched();
},
);
test('cancellation before work performs no read, save or probe', () async {
current = false;
await expectLater(prepare(nsec: key.nsec), throwsStateError);
expect(storage.reads, 0);
expectUntouched();
});
test('cancellation during read prevents durable save and probe', () async {
storage.onRead = () {
current = false;
};
await expectLater(prepare(nsec: key.nsec), throwsStateError);
expectUntouched();
expect(storage.data, isEmpty);
});
test(
'cancellation after durable save keeps key and prevents probe',
() async {
storage.onWrite = () {
current = false;
};
await expectLater(prepare(nsec: key.nsec), throwsStateError);
expect(storage.writes, 1);
expect(storage.data.values, contains(key.nsec));
expect(probes, isEmpty);
current = true;
storage.onWrite = null;
expect((await prepare()).pubkey, key.public);
expect(storage.writes, 1);
},
);
test('unconfirmed durable write cannot reach the relay probe', () async {
storage.dropWrites = true;
await expectLater(
prepare(nsec: key.nsec),
throwsA(AccountIdentityFailure.unreadable),
);
expect(probes, isEmpty);
expect(storage.data, isEmpty);
});
}
class _Storage extends Fake implements FlutterSecureStorage {
final data = <String, String>{};
int writes = 0;
int reads = 0;
bool dropWrites = false;
void Function()? onRead;
void Function()? onWrite;
@override
Future<String?> 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<void> 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();
}
}

View File

@@ -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);
});
}

View File

@@ -0,0 +1,180 @@
import 'package:buzz/shared/community/community.dart';
import 'package:buzz/shared/hosted/account_identity_store.dart';
import 'package:buzz/shared/identity_transfer/nsec_transfer.dart';
import 'package:buzz/shared/security/sensitive_action_authorizer.dart';
import 'dart:convert';
import 'package:file_selector/file_selector.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:nostr/nostr.dart' as nostr;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final keys = nostr.Keys('1'.padLeft(64, '0'));
final other = nostr.Keys('2'.padLeft(64, '0'));
setUp(() => FlutterSecureStorage.setMockInitialValues({}));
for (final platform in [TargetPlatform.android, TargetPlatform.iOS]) {
test(
'native $platform import validates text and preserves cancellation/error',
() async {
debugDefaultTargetPlatformOverride = platform;
const channel = MethodChannel('buzz/identity_file');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
addTearDown(() {
debugDefaultTargetPlatformOverride = null;
messenger.setMockMethodCallHandler(channel, null);
});
String? response = '${keys.nsec}\n';
var failRead = false;
messenger.setMockMethodCallHandler(channel, (call) async {
expect(call.method, 'import');
expect(call.arguments, isNull);
if (failRead) throw PlatformException(code: 'import_failed');
return response;
});
expect(await NsecTransfer.pickFile(), keys.nsec);
response = null;
expect(await NsecTransfer.pickFile(), isNull);
response = keys.public;
await expectLater(
NsecTransfer.pickFile(),
throwsA(AccountIdentityFailure.invalidKey),
);
response = 'x' * 1025;
await expectLater(
NsecTransfer.pickFile(),
throwsA(AccountIdentityFailure.invalidKey),
);
failRead = true;
await expectLater(
NsecTransfer.pickFile(),
throwsA(isA<PlatformException>()),
);
},
);
}
test('paste parsing rejects wrong key type, invalid scalar and checksum', () {
expect(NsecTransfer.preview(' ${keys.nsec}\n'), keys.public);
for (final value in [
keys.public,
'nsec1bad',
nostr.Nip19.encode(prefix: nostr.Nip19Prefix.nsec, data: '0' * 64),
]) {
expect(
() => NsecTransfer.preview(value),
throwsA(AccountIdentityFailure.invalidKey),
);
}
});
test('file import validates size, UTF-8, and key type', () async {
XFile file(List<int> bytes) => XFile.fromData(Uint8List.fromList(bytes));
expect(
await NsecTransfer.readFile(file(utf8.encode('${keys.nsec}\n'))),
keys.nsec,
);
for (final bytes in [
utf8.encode('x' * 1025),
[0xff],
utf8.encode(keys.public),
]) {
await expectLater(
NsecTransfer.readFile(file(bytes)),
throwsA(AccountIdentityFailure.invalidKey),
);
}
});
test(
'manual import is create-only, preserves receipt and occupied keys',
() async {
final store = AccountIdentityStore();
final receipt = BackupReceipt(
backend: 'https://app.builderlab.xyz',
subject: 'a',
pubkey: keys.public,
backupId: 'backup',
);
await store.create(keys.nsec, recoveredBackup: receipt);
final restored = await NsecTransfer.importAccount(
' ${keys.nsec}\n',
store,
);
expect(restored.backup?.backupId, 'backup');
await expectLater(
NsecTransfer.importAccount(other.nsec, store),
throwsA(AccountIdentityFailure.occupied),
);
expect(await store.withKeys((k) async => k.public), keys.public);
expect((await store.read())?.backup?.backupId, 'backup');
},
);
test(
'manual import fences cancelled operations before secure write',
() async {
final store = AccountIdentityStore();
await expectLater(
NsecTransfer.importAccount(
keys.nsec,
store,
checkCurrent: () => throw StateError('cancelled'),
),
throwsStateError,
);
expect(await store.read(), isNull);
},
);
test(
'export respects biometric policy, auth denial and selected identity',
() async {
final community = Community.create(
name: 'Test',
relayUrl: 'wss://test.invalid',
nsec: keys.nsec,
pubkey: keys.public,
sensitiveActionPolicy: SensitiveActionPolicy.enabled,
);
var shares = 0;
var current = true;
var result = DeviceAuthResult.cancelled;
Future<bool> 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),
);
},
);
}

View File

@@ -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,

View File

@@ -0,0 +1,23 @@
# Android HPKE verification
Activate the repository Hermit environment and use a Gradle-supported JDK (21).
The Android SDK and Flutter dependencies must already be installed.
```sh
mobile/android/gradlew -p mobile/android :app:testDebugUnitTest \
--tests 'xyz.block.buzz.mobile.HostedHpke*Test'
python3 mobile/tool/hpke_android/check_interop.py
```
The focused JVM tests cover the published RFC 9180 P-256/HKDF-SHA256/AES-256-GCM
base-mode known-answer vector, point/input rejection, fresh encapsulation,
registered Flutter codec dispatch, plaintext buffer clearing and redacted errors.
The macOS interoperability check invokes the **actual compiled Android sender**
with the repository's public test-only backend fixture. Apple CryptoKit decrypts
its output with the fixture recipient key; changed ciphertext and AAD must fail.
`--classes PATH` selects separately compiled classes, including isolated mutation
builds. Never supply real identities or backups to these verification tools.
These checks do not prove the full hosted network flow, Android document-picker
UI, or Android platform-provider behavior; those need device workflow evidence.

View File

@@ -0,0 +1,20 @@
import java.util.Base64;
import xyz.block.buzz.mobile.HostedHpkeBackup;
/** Seals test-only fixture bytes with the actual compiled Android sender. */
public final class Seal {
private static byte[] hex(String value) {
byte[] result = new byte[value.length() / 2];
for (int i = 0; i < result.length; i++) {
result[i] = (byte) Integer.parseInt(value.substring(i * 2, i * 2 + 2), 16);
}
return result;
}
public static void main(String[] args) {
var sealed = HostedHpkeBackup.INSTANCE.seal(
hex(args[0]), hex(args[1]), hex(args[2]));
var base64 = Base64.getUrlEncoder().withoutPadding();
System.out.println(base64.encodeToString(sealed.get("enc")));
System.out.println(base64.encodeToString(sealed.get("ciphertext")));
}
}

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""After :app:compileDebugKotlin, decrypt the Android output using Apple HPKE.
Requires macOS Swift/CryptoKit, Java 17+, and the Gradle-resolved Kotlin stdlib.
Only the repository's public test fixture is used. All artifacts are temporary.
"""
import argparse
import json
import os
from pathlib import Path
import subprocess
import tempfile
root = Path(__file__).resolve().parents[3]
cache = Path(os.environ.get("GRADLE_USER_HOME", Path.home() / ".gradle"))
stdlib = sorted((cache / "caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib").glob("*/*/kotlin-stdlib-[0-9]*.jar"))
stdlib = [p for p in stdlib if not p.name.endswith(("-sources.jar", "-javadoc.jar"))][-1]
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--classes", type=Path, default=root / "mobile/build/app/tmp/kotlin-classes/debug")
classes = parser.parse_args().classes
fixture = json.loads((root / "desktop/src-tauri/src/testdata/hpke_nsec_backup_v1.json").read_text())
with tempfile.TemporaryDirectory(prefix="buzz-android-hpke-") as temp:
classpath = os.pathsep.join(map(str, (classes, stdlib, temp)))
subprocess.run(["javac", "--release", "17", "-cp", classpath, "-d", temp, str(Path(__file__).with_name("Seal.java"))], check=True)
output = subprocess.check_output([
"java", "-cp", classpath, "Seal", fixture["recipient_public_key_sec1_hex"],
fixture["nostr_secret_key_hex"], fixture["aad_hex"],
], text=True).splitlines()
fixture["envelope"].update(enc=output[0], ciphertext=output[1])
sealed = Path(temp) / "android.json"
sealed.write_text(json.dumps(fixture))
executable = str(Path(temp) / "interop")
subprocess.run([
"swiftc", str(root / "mobile/ios/Runner/HostedHpkeBackup.swift"),
str(root / "mobile/tool/onboarding/hpke_interop.swift"), "-o", executable,
], check=True)
subprocess.run([executable, str(sealed)], check=True)
# Authentication must reject changes to the actual Android ciphertext and AAD.
for field in ("aad_hex", "ciphertext"):
changed = json.loads(sealed.read_text())
if field == "aad_hex":
changed[field] = "00" + changed[field][2:]
else:
cipher = changed["envelope"][field]
changed["envelope"][field] = ("B" if cipher[0] == "A" else "A") + cipher[1:]
bad = Path(temp) / "tampered.json"
bad.write_text(json.dumps(changed))
result = subprocess.run([executable, str(bad)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if result.returncode == 0:
raise RuntimeError(f"Accepted modified {field}")
print("Android sender → CryptoKit receiver: decrypt and tamper rejection passed")