From 239def6237feb4cb48dda7be9d3d4de3bb694128 Mon Sep 17 00:00:00 2001 From: Kyle Santiago Date: Mon, 19 Oct 2026 17:41:08 -0400 Subject: [PATCH 1/4] Rotate the bunker URL on every client revoke, and revoke in the engine from App Permissions --- .../keep/nip46/BunkerRevokeRotationTest.kt | 131 ++++++++++++++++++ .../io/privkey/keep/nip46/BunkerScreen.kt | 98 +++++++++---- .../io/privkey/keep/nip46/BunkerService.kt | 81 +++++++++-- .../keep/nip55/AppPermissionsScreen.kt | 23 +-- .../main/res/values/strings_connections.xml | 11 +- 5 files changed, 293 insertions(+), 51 deletions(-) create mode 100644 app/src/androidTest/kotlin/io/privkey/keep/nip46/BunkerRevokeRotationTest.kt diff --git a/app/src/androidTest/kotlin/io/privkey/keep/nip46/BunkerRevokeRotationTest.kt b/app/src/androidTest/kotlin/io/privkey/keep/nip46/BunkerRevokeRotationTest.kt new file mode 100644 index 00000000..f4baee0c --- /dev/null +++ b/app/src/androidTest/kotlin/io/privkey/keep/nip46/BunkerRevokeRotationTest.kt @@ -0,0 +1,131 @@ +package io.privkey.keep.nip46 + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import io.privkey.keep.nip55.FrostSignFixture +import io.privkey.keep.nip55.FrostSignTestSupport +import io.privkey.keep.uniffi.BunkerApprovalRequest +import io.privkey.keep.uniffi.BunkerApprovalResult +import io.privkey.keep.uniffi.BunkerCallbacks +import io.privkey.keep.uniffi.BunkerHandler +import io.privkey.keep.uniffi.BunkerLogEvent +import io.privkey.keep.uniffi.BunkerRememberDuration +import io.privkey.keep.uniffi.ConnectAuthorization +import io.privkey.keep.uniffi.KeepMobile +import io.privkey.keep.uniffi.ParsedBunkerUrl +import io.privkey.keep.uniffi.parseBunkerUrl +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Revoking a client rotates the bunker credentials even when the bunker service is not + * running, so a revoked client that kept the old bunker URL cannot connect again with it. + * Starts a real bunker against a public relay, so it is manual-only like the FROST tests, + * and it rotates the test device's own bunker URL. + */ +@RunWith(AndroidJUnit4::class) +class BunkerRevokeRotationTest { + + private var mobile: KeepMobile? = null + + @After + fun tearDown() { + mobile?.destroy() + mobile = null + } + + @Test(timeout = 180_000L) + fun rotatingWithTheServiceStoppedRetiresTheOldBunkerUrl() = runBlocking { + val mobile = setUpMobile() + + val before = startedBunkerUrl(mobile) + assertNotNull("the bunker URL must carry a connect secret", before.secret) + // A restart alone keeps the URL, so the change below comes from the rotation. + val restarted = startedBunkerUrl(mobile) + assertEquals(before.secret, restarted.secret) + assertEquals(before.pubkey, restarted.pubkey) + + BunkerService.rotateBunkerCredentials(mobile) + assertNull("the rotation must not have needed the service", BunkerService.current()) + + val after = startedBunkerUrl(mobile) + assertNotNull(after.secret) + assertNotEquals("the old connect secret must be retired", before.secret, after.secret) + assertNotEquals("the old bunker key must be retired", before.pubkey, after.pubkey) + } + + @Test(timeout = 180_000L) + fun aClientRevokedBeforeThisVersionGetsTheUrlRotatedOnce() { + val mobile = setUpMobile() + val context = InstrumentationRegistry.getInstrumentation().targetContext + val markers = context.getSharedPreferences(BunkerService.BUNKER_MARKERS_PREFS, Context.MODE_PRIVATE) + val probe = "ab".repeat(32) + val alreadyDenylisted = probe in Nip46ClientStore.getDenylist(context) + try { + markers.edit().remove(BunkerService.KEY_ROTATED_FOR_EARLIER_REVOKES).commit() + Nip46ClientStore.addToDenylist(context, probe) + + val before = startedBunkerUrl(mobile) + assertTrue(BunkerService.rotateOnceForEarlierRevokes(context, mobile)) + val after = startedBunkerUrl(mobile) + assertNotEquals(before.secret, after.secret) + assertNotEquals(before.pubkey, after.pubkey) + + assertFalse("the rotation happens once", BunkerService.rotateOnceForEarlierRevokes(context, mobile)) + assertEquals(after.secret, startedBunkerUrl(mobile).secret) + } finally { + if (!alreadyDenylisted) Nip46ClientStore.removeFromDenylist(context, probe) + } + } + + @Test(timeout = 180_000L) + fun withNothingRevokedTheUrlIsLeftAlone() { + val mobile = setUpMobile() + val context = InstrumentationRegistry.getInstrumentation().targetContext + assumeTrue("the device denylist must be empty", Nip46ClientStore.getDenylist(context).isEmpty()) + context.getSharedPreferences(BunkerService.BUNKER_MARKERS_PREFS, Context.MODE_PRIVATE) + .edit().remove(BunkerService.KEY_ROTATED_FOR_EARLIER_REVOKES).commit() + + val before = startedBunkerUrl(mobile) + assertFalse(BunkerService.rotateOnceForEarlierRevokes(context, mobile)) + assertEquals(before.secret, startedBunkerUrl(mobile).secret) + } + + private fun setUpMobile(): KeepMobile { + val manual = InstrumentationRegistry.getArguments().getString(FrostSignFixture.MANUAL_ARG) + assumeTrue("manual-only test; pass -e ${FrostSignFixture.MANUAL_ARG} 1", manual == "1") + assumeTrue("the bunker service must not be running", BunkerService.current() == null) + val storage = FrostSignTestSupport.noAuthStorage() + val mobile = KeepMobile(storage).also { this.mobile = it } + FrostSignTestSupport.importShareNoAuth(mobile, storage, FrostSignFixture.SHARE1_EXPORT_DATA, "bunker-rotate-setup") + FrostSignTestSupport.initializeWithDecryptContext(mobile, storage, "bunker-rotate-connect") + return mobile + } + + private fun startedBunkerUrl(mobile: KeepMobile): ParsedBunkerUrl = + BunkerHandler(mobile).use { handler -> + try { + handler.startBunker(listOf(FrostSignFixture.RELAY), RejectAll) + parseBunkerUrl(requireNotNull(handler.getBunkerUrl()) { "a started bunker must publish its URL" }) + } finally { + handler.stopBunker() + } + } + + private object RejectAll : BunkerCallbacks { + override fun onLog(event: BunkerLogEvent) = Unit + override fun requestApproval(request: BunkerApprovalRequest) = + BunkerApprovalResult(approved = false, remember = BunkerRememberDuration.JUST_THIS_TIME) + override fun onConnect(pubkey: String, name: String, authorization: ConnectAuthorization) = Unit + } +} diff --git a/app/src/main/kotlin/io/privkey/keep/nip46/BunkerScreen.kt b/app/src/main/kotlin/io/privkey/keep/nip46/BunkerScreen.kt index bcd18e50..27f9da30 100644 --- a/app/src/main/kotlin/io/privkey/keep/nip46/BunkerScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/nip46/BunkerScreen.kt @@ -16,7 +16,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import io.privkey.keep.MAX_BUNKER_RELAYS @@ -115,6 +117,7 @@ fun BunkerScreen( var authorizedClients by remember { mutableStateOf>(emptySet()) } var showAddDialog by remember { mutableStateOf(false) } var showRevokeAllDialog by remember { mutableStateOf(false) } + var pendingRevoke by remember { mutableStateOf(null) } var showRotateUrlDialog by remember { mutableStateOf(false) } val isEnabled = bunkerStatus == BunkerStatus.RUNNING || bunkerStatus == BunkerStatus.STARTING @@ -207,17 +210,18 @@ fun BunkerScreen( } Spacer(modifier = Modifier.height(8.dp)) + } - OutlinedButton( - onClick = { showRotateUrlDialog = true }, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.connections_bunker_rotate_url_button)) - } - - Spacer(modifier = Modifier.height(16.dp)) + // Offered while stopped too: a URL handed out earlier stays valid until rotated. + OutlinedButton( + onClick = { showRotateUrlDialog = true }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.connections_bunker_rotate_url_button)) } + Spacer(modifier = Modifier.height(16.dp)) + BunkerToggleCard( enabled = isEnabled, canEnable = relays.isNotEmpty(), @@ -259,39 +263,49 @@ fun BunkerScreen( AuthorizedClientsCard( clients = authorizedClients, - onRevoke = { pubkey -> + onRevoke = { pubkey -> pendingRevoke = pubkey }, + onRevokeAll = { showRevokeAllDialog = true } + ) + } + } + + if (showAddDialog) { + AddBunkerRelayDialog( + relays = relays, + onRelaysUpdated = { updated -> + relays = updated + saveBunkerRelays(updated) + }, + onDismiss = { showAddDialog = false } + ) + } + + pendingRevoke?.let { pubkey -> + RevokeClientDialog( + onConfirm = { + pendingRevoke = null scope.launch { runCatching { - withContext(Dispatchers.IO) { + withContext(NonCancellable + Dispatchers.IO) { Nip46ClientStore.addToDenylist(context, pubkey) val saved = BunkerConfigStore.update(keepMobile) { config -> BunkerConfigInfo(config.enabled, config.authorizedClients.filter { it.lowercase() != pubkey.lowercase() }) } BunkerService.forgetPendingAuth(pubkey) BunkerService.revokeClientInEngine(pubkey) + BunkerService.rotateBunkerCredentials(keepMobile) saved } }.onSuccess { saved -> authorizedClients = saved.authorizedClients.toSet() Toast.makeText(context, toastClientRevoked, Toast.LENGTH_SHORT).show() }.onFailure { + if (it is CancellationException) throw it Toast.makeText(context, toastRevokeFailed, Toast.LENGTH_SHORT).show() } } }, - onRevokeAll = { showRevokeAllDialog = true } - ) - } - } - - if (showAddDialog) { - AddBunkerRelayDialog( - relays = relays, - onRelaysUpdated = { updated -> - relays = updated - saveBunkerRelays(updated) - }, - onDismiss = { showAddDialog = false } + onDismiss = { pendingRevoke = null } ) } @@ -301,18 +315,20 @@ fun BunkerScreen( scope.launch { val revoked = authorizedClients runCatching { - withContext(Dispatchers.IO) { + withContext(NonCancellable + Dispatchers.IO) { Nip46ClientStore.addAllToDenylist(context, revoked) BunkerConfigStore.update(keepMobile) { config -> BunkerConfigInfo(config.enabled, emptyList()) } revoked.forEach { BunkerService.forgetPendingAuth(it) } BunkerService.revokeAllClientsInEngine() + BunkerService.rotateBunkerCredentials(keepMobile) } }.onSuccess { authorizedClients = emptySet() Toast.makeText(context, toastAllClientsRevoked, Toast.LENGTH_SHORT).show() }.onFailure { + if (it is CancellationException) throw it Toast.makeText(context, toastRevokeAllFailed, Toast.LENGTH_SHORT).show() } } @@ -327,12 +343,13 @@ fun BunkerScreen( showRotateUrlDialog = false scope.launch { runCatching { - withContext(Dispatchers.IO) { - BunkerService.rotateBunkerUrlInService() + withContext(NonCancellable + Dispatchers.IO) { + BunkerService.rotateBunkerCredentials(keepMobile) } }.onSuccess { Toast.makeText(context, toastUrlRotated, Toast.LENGTH_SHORT).show() }.onFailure { + if (it is CancellationException) throw it Toast.makeText(context, toastRotateUrlFailed, Toast.LENGTH_SHORT).show() } } @@ -471,6 +488,35 @@ private fun AddBunkerRelayDialog( ) } +@Composable +private fun RevokeClientDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.connections_bunker_revoke_client_title)) }, + text = { + Text(stringResource(R.string.connections_bunker_revoke_client_text)) + }, + confirmButton = { + TextButton( + onClick = onConfirm, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text(stringResource(R.string.connections_bunker_revoke_client_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.connections_bunker_revoke_all_cancel)) + } + } + ) +} + @Composable private fun RevokeAllClientsDialog( onConfirm: () -> Unit, diff --git a/app/src/main/kotlin/io/privkey/keep/nip46/BunkerService.kt b/app/src/main/kotlin/io/privkey/keep/nip46/BunkerService.kt index 49616f94..63705b2c 100644 --- a/app/src/main/kotlin/io/privkey/keep/nip46/BunkerService.kt +++ b/app/src/main/kotlin/io/privkey/keep/nip46/BunkerService.kt @@ -79,6 +79,8 @@ class BunkerService : Service() { internal const val APPROVAL_TIMEOUT_MS = 55_000L private const val MAX_TRACKED_CLIENTS = 1000 internal const val MAX_AUTHORIZED_CLIENTS = 100 + internal const val BUNKER_MARKERS_PREFS = "keep_bunker_markers" + internal const val KEY_ROTATED_FOR_EARLIER_REVOKES = "rotated_for_earlier_revokes" private val HEX_PUBKEY_REGEX = Regex("^[a-fA-F0-9]{64}$") @@ -148,11 +150,30 @@ class BunkerService : Service() { } } - /** Rotate the bunker credentials (new URL) via the running service. */ - internal suspend fun rotateBunkerUrlInService() { - val service = current() - ?: throw IllegalStateException("Bunker service not running") - service.rotateBunkerUrl() + /** + * Rotate the bunker credentials whether or not the service is running. Revoking + * a client needs this: the connect secret in the bunker URL is reusable, so a + * revoked client that kept the URL could connect again under a new key. + */ + internal suspend fun rotateBunkerCredentials(keepMobile: io.privkey.keep.uniffi.KeepMobile) { + current()?.takeIf { it.serving }?.let { return it.rotateBunkerUrl() } + BunkerHandler(keepMobile).use { it.rotateBunkerCredentials() } + // A service that began starting during the rotation may have loaded the old keys. + current()?.takeIf { it.serving }?.rotateBunkerUrl() + } + + /** + * Versions before revoke rotated the URL left every revoked client holding a + * working connect secret, so rotate once if anything was ever revoked. + * Returns whether it rotated. + */ + internal fun rotateOnceForEarlierRevokes(context: Context, keepMobile: io.privkey.keep.uniffi.KeepMobile): Boolean { + val markers = context.getSharedPreferences(BUNKER_MARKERS_PREFS, Context.MODE_PRIVATE) + if (markers.getBoolean(KEY_ROTATED_FOR_EARLIER_REVOKES, false)) return false + val rotate = Nip46ClientStore.getDenylist(context).isNotEmpty() + if (rotate) BunkerHandler(keepMobile).use { it.rotateBunkerCredentials() } + markers.edit().putBoolean(KEY_ROTATED_FOR_EARLIER_REVOKES, true).commit() + return rotate } fun queueNostrConnectRequest(request: NostrConnectRequest): Boolean { @@ -262,9 +283,11 @@ class BunkerService : Service() { private val startStopMutex = Mutex() private val approvalIdCounter = AtomicInteger(0) private val approvalIds = ConcurrentHashMap() - private var bunkerHandler: BunkerHandler? = null + @Volatile private var bunkerHandler: BunkerHandler? = null private var networkManager: NetworkConnectivityManager? = null - private var keepMobileRef: io.privkey.keep.uniffi.KeepMobile? = null + @Volatile private var keepMobileRef: io.privkey.keep.uniffi.KeepMobile? = null + // Set before the first start is launched and cleared first thing in onDestroy. + @Volatile private var serving = false private var permissionStore: PermissionStore? = null private var eventLogStore: EventLogStore? = null @@ -287,6 +310,7 @@ class BunkerService : Service() { if (keepMobile == null) { if (BuildConfig.DEBUG) Log.e(TAG, "KeepMobile not available") _status.value = BunkerStatus.ERROR + serving = false stopSelf() return START_NOT_STICKY } @@ -295,6 +319,7 @@ class BunkerService : Service() { val bunkerConfig = runCatching { keepMobile.getBunkerConfig() }.getOrNull() if (bunkerConfig?.enabled != true) { + serving = false stopSelf() return START_NOT_STICKY } @@ -311,6 +336,7 @@ class BunkerService : Service() { if (db == null) { if (BuildConfig.DEBUG) Log.e(TAG, "Permission database not available") _status.value = BunkerStatus.ERROR + serving = false stopSelf() return START_NOT_STICKY } @@ -322,10 +348,12 @@ class BunkerService : Service() { if (relays.isEmpty()) { if (BuildConfig.DEBUG) Log.e(TAG, "No bunker relays configured") _status.value = BunkerStatus.ERROR + serving = false stopSelf() return START_NOT_STICKY } + serving = true serviceScope.launch { startBunker(keepMobile, relays) } @@ -374,12 +402,15 @@ class BunkerService : Service() { // keys. rotateBunkerCredentials stops the running handler (if any) and // replaces the persisted transport+connect secrets. Clear the displayed // URL immediately: it is now invalid and must not linger on screen. - startStopMutex.withLock { + val restart = startStopMutex.withLock { val handler = bunkerHandler ?: BunkerHandler(keepMobile) handler.rotateBunkerCredentials() _bunkerUrl.value = null - _status.value = BunkerStatus.STARTING + // A service being destroyed keeps the rotation but must not start again. + if (serving) _status.value = BunkerStatus.STARTING + serving } + if (!restart) return // Restart so a fresh handler derives and publishes the rotated URL. Run on // the service's own scope so publishing completes even if the UI that // triggered the rotation is disposed mid-restart. @@ -393,6 +424,7 @@ class BunkerService : Service() { } private suspend fun startBunkerLocked(keepMobile: io.privkey.keep.uniffi.KeepMobile, relays: List, attempt: Int): Boolean { + if (!serving) return false try { val safeRelays = withContext(Dispatchers.IO) { withTimeoutOrNull(10_000L) { filterRelaysPreConnection(relays) } @@ -408,6 +440,9 @@ class BunkerService : Service() { _status.value = BunkerStatus.STARTING bunkerHandler?.let { runCatching { it.stopBunker() } } + if (rotateOnceForEarlierRevokes(this, keepMobile)) { + logActivity(EventLogCategory.BUNKER, EventLogLevel.WARN, "bunker", "URL rotated because a client was revoked before this version; pair clients again") + } val handler = BunkerHandler(keepMobile) bunkerHandler = handler @@ -466,6 +501,26 @@ class BunkerService : Service() { handler.startBunker(safeRelays, callbacks) } + // onDestroy cannot stop a handler whose start was still running. + if (!serving) { + runCatching { handler.stopBunker() } + return false + } + + // A revoke made while the engine was stopped could not drop the client's + // persisted grants, and this start has just restored them. Ceiling: one + // storage write per denylisted client on every start, and grants live until + // the sweep reaches them. Remove once the pinned core clears persisted grants + // on a revoke without a running bunker and drops them on rotation. + try { + Nip46ClientStore.getDenylist(this) + .filter { HEX_PUBKEY_REGEX.matches(it) } + .forEach { handler.revokeClient(it) } + } catch (e: Exception) { + runCatching { handler.stopBunker() } + throw e + } + val url = handler.getBunkerUrl() _bunkerUrl.value = url _status.value = handler.getBunkerStatus() @@ -701,7 +756,12 @@ class BunkerService : Service() { // A connect approval is not persisted here: the core can still refuse the connect // after this returns, at its own deadline or on its own checks. onConnect reports // the connects it accepted, and persists them. - return resultRef.get() ?: REJECTED + val result = resultRef.get() ?: return REJECTED + // The client may have been revoked while its prompt was open. + if (result.approved && !isConnectRequest && runCatching { Nip46ClientStore.isDenylisted(this, pk) }.getOrDefault(true)) { + return REJECTED + } + return result } private fun startApprovalActivity(requestId: String, request: BunkerApprovalRequest, isConnectRequest: Boolean) { @@ -772,6 +832,7 @@ class BunkerService : Service() { } override fun onDestroy() { + serving = false networkManager?.unregister() networkManager = null bunkerHandler?.stopBunker() diff --git a/app/src/main/kotlin/io/privkey/keep/nip55/AppPermissionsScreen.kt b/app/src/main/kotlin/io/privkey/keep/nip55/AppPermissionsScreen.kt index 8b2bba73..75a62055 100644 --- a/app/src/main/kotlin/io/privkey/keep/nip55/AppPermissionsScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/nip55/AppPermissionsScreen.kt @@ -34,8 +34,10 @@ import io.privkey.keep.storage.toSelection import io.privkey.keep.storage.toSignPolicy import io.privkey.keep.uniffi.BunkerConfigInfo import io.privkey.keep.uniffi.SignPolicyStore +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -185,7 +187,7 @@ private fun RevokeAllPermissionsDialog( onClick = { coroutineScope.launch { try { - withContext(Dispatchers.IO) { + withContext(NonCancellable + Dispatchers.IO) { permissionStore.revokePermission(packageName) if (isNip46) { val pubkey = packageName.removePrefix("nip46:") @@ -195,6 +197,7 @@ private fun RevokeAllPermissionsDialog( onDismissDialog() onDismissScreen() } catch (e: Exception) { + if (e is CancellationException) throw e if (BuildConfig.DEBUG) Log.e("AppPermissions", "Revoke failed: ${e::class.simpleName}") onDismissDialog() Toast.makeText(context, toastRevokeFailed, Toast.LENGTH_SHORT).show() @@ -406,19 +409,17 @@ private fun AppPermissionsListContent( } private suspend fun revokeNip46Client(context: android.content.Context, pubkey: String) { - runCatching { Nip46ClientStore.addToDenylist(context, pubkey) } - .onFailure { if (BuildConfig.DEBUG) Log.e("AppPermissions", "Failed to denylist NIP-46 client: ${it::class.simpleName}") } + val mobile = checkNotNull((context.applicationContext as? KeepMobileApp)?.getKeepMobile()) { "Keep not initialized" } + Nip46ClientStore.addToDenylist(context, pubkey) runCatching { Nip46ClientStore.removeClient(context, pubkey) } .onFailure { if (BuildConfig.DEBUG) Log.e("AppPermissions", "Failed to remove NIP-46 client: ${it::class.simpleName}") } - runCatching { - val mobile = (context.applicationContext as? KeepMobileApp)?.getKeepMobile() - if (mobile != null) { - BunkerConfigStore.update(mobile) { config -> - BunkerConfigInfo(config.enabled, config.authorizedClients.filter { it.lowercase() != pubkey.lowercase() }) - } - } - }.onFailure { if (BuildConfig.DEBUG) Log.e("AppPermissions", "Failed to revoke bunker client: ${it::class.simpleName}") } + BunkerConfigStore.update(mobile) { config -> + BunkerConfigInfo(config.enabled, config.authorizedClients.filter { it.lowercase() != pubkey.lowercase() }) + } BunkerService.forgetPendingAuth(pubkey) + // The engine keeps the client's remembered kinds, which sign without reaching the denylist. + BunkerService.revokeClientInEngine(pubkey) + BunkerService.rotateBunkerCredentials(mobile) } @Composable diff --git a/app/src/main/res/values/strings_connections.xml b/app/src/main/res/values/strings_connections.xml index d14e620a..de7651c6 100644 --- a/app/src/main/res/values/strings_connections.xml +++ b/app/src/main/res/values/strings_connections.xml @@ -69,9 +69,9 @@ Add at least one relay first Failed to save bunker config Failed to save relay config - Client revoked + Client revoked. The bunker URL has changed. Failed to revoke client - All clients revoked + All clients revoked. The bunker URL has changed. Failed to revoke clients Bunker Relays Add relay @@ -92,7 +92,10 @@ Private/internal relay addresses are not allowed No valid relay URLs found in bunker URL Revoke All Clients - This will disconnect all authorized clients. They will need to reconnect and be approved again. + This will disconnect all authorized clients and change the bunker URL, so the old URL can no longer connect. Clients will need to pair again with the new URL. + Revoke Client + This client loses access, and the bunker URL changes so the old URL can no longer connect. Your other clients will need to pair again with the new URL. + Revoke Revoke All Cancel Rotate Bunker URL @@ -171,7 +174,7 @@ Back Disconnect Client? Disconnect App? - This will remove all saved permissions and revoke authorization for %1$s. The client will need to reconnect. + This will remove all saved permissions and revoke authorization for %1$s. The bunker URL also changes, so your other clients will need to pair again with the new URL. This will remove all saved permissions for %1$s. The app will need to request permission again. Disconnect Cancel From bb1b3a35a1feb6f9d0dd3d45a8b2347d592d5df1 Mon Sep 17 00:00:00 2001 From: Kyle Santiago Date: Mon, 19 Oct 2026 19:21:46 -0400 Subject: [PATCH 2/4] Add an end-to-end test of bunker client revocation against a real NIP-46 client --- .../privkey/keep/nip46/BunkerRevokeE2ETest.kt | 485 +++++ .../keep/nip55/FrostSignTestSupport.kt | 19 +- testharness/nip46probe/.gitignore | 1 + testharness/nip46probe/Cargo.lock | 1664 +++++++++++++++++ testharness/nip46probe/Cargo.toml | 14 + testharness/nip46probe/README.md | 27 + testharness/nip46probe/run-revoke-e2e.sh | 42 + testharness/nip46probe/src/main.rs | 119 ++ 8 files changed, 2362 insertions(+), 9 deletions(-) create mode 100644 app/src/androidTest/kotlin/io/privkey/keep/nip46/BunkerRevokeE2ETest.kt create mode 100644 testharness/nip46probe/.gitignore create mode 100644 testharness/nip46probe/Cargo.lock create mode 100644 testharness/nip46probe/Cargo.toml create mode 100644 testharness/nip46probe/README.md create mode 100755 testharness/nip46probe/run-revoke-e2e.sh create mode 100644 testharness/nip46probe/src/main.rs diff --git a/app/src/androidTest/kotlin/io/privkey/keep/nip46/BunkerRevokeE2ETest.kt b/app/src/androidTest/kotlin/io/privkey/keep/nip46/BunkerRevokeE2ETest.kt new file mode 100644 index 00000000..e0671ed4 --- /dev/null +++ b/app/src/androidTest/kotlin/io/privkey/keep/nip46/BunkerRevokeE2ETest.kt @@ -0,0 +1,485 @@ +package io.privkey.keep.nip46 + +import android.app.Activity +import android.content.Context +import android.util.Log +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.ComposeTimeoutException +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.printToString +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry +import androidx.test.runner.lifecycle.Stage +import io.privkey.keep.KeepMobileApp +import io.privkey.keep.nip55.AppPermissionsScreen +import io.privkey.keep.nip55.EventLogCategory +import io.privkey.keep.nip55.EventLogLevel +import io.privkey.keep.nip55.EventLogStore +import io.privkey.keep.nip55.FrostSignFixture +import io.privkey.keep.nip55.FrostSignTestSupport +import io.privkey.keep.nip55.FrostSignTestSupport.withCipher +import io.privkey.keep.nip55.Nip55Database +import io.privkey.keep.nip55.PermissionStore +import io.privkey.keep.storage.AndroidKeystoreStorage +import io.privkey.keep.ui.theme.KeepAndroidTheme +import io.privkey.keep.uniffi.BunkerConfigInfo +import io.privkey.keep.uniffi.BunkerRememberDuration +import io.privkey.keep.uniffi.BunkerStatus +import io.privkey.keep.uniffi.KeepMobile +import io.privkey.keep.uniffi.RelayConfigInfo +import io.privkey.keep.uniffi.parseBunkerUrl +import io.privkey.keep.uniffi.truncateStr +import kotlinx.coroutines.runBlocking +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.security.SecureRandom + +/** + * End-to-end: a real NIP-46 client on the host (testharness/nip46probe) talks to the + * real BunkerService over a public relay, and each revoke path is driven through its + * real screen. Manual-only; run it with testharness/nip46probe/run-revoke-e2e.sh, + * which serves the probe requests this test writes. + * + * It imports a throwaway nsec as a temporary account (the fixture share is 2-of-3 + * and cannot sign alone). The device's own state is saved to a file before anything + * changes and restored after the run, or at the start of the next one if this run + * was killed. Every run, and every recovery, ends with a fresh bunker URL and every + * client the test approved on the denylist, so no test client keeps a way in. + */ +@RunWith(AndroidJUnit4::class) +class BunkerRevokeE2ETest { + + @get:Rule + val compose = createComposeRule() + + private val context: Context get() = InstrumentationRegistry.getInstrumentation().targetContext + private val app: KeepMobileApp get() = context.applicationContext as KeepMobileApp + private val dir by lazy { File(context.filesDir, "bunker-e2e") } + private val savedFile by lazy { File(context.filesDir, "bunker-e2e-saved.json") } + private val clientsFile by lazy { File(context.filesDir, "bunker-e2e-clients.txt") } + private val approvedClients = mutableSetOf() + private var requests = 0 + private var screen by mutableStateOf(Screen.None) + + private sealed interface Screen { + data object None : Screen + data class Bunker(val generation: Int) : Screen + data class Disconnect(val pubkey: String) : Screen + } + + private class Client(val secret: String, val pubkey: String) + + @Test(timeout = 20 * 60_000L) + fun aRevokedClientCannotConnectOrSignOnAnyRevokePath() { + assumeTrue("manual-only; run testharness/nip46probe/run-revoke-e2e.sh", InstrumentationRegistry.getArguments().getString(MANUAL_ARG) == "1") + val mobile = requireNotNull(app.getKeepMobile()) + val storage = requireNotNull(app.getStorage()) + recoverFromInterruptedRun(mobile, storage) + assumeTrue("the bunker service must not be running", BunkerService.current() == null) + val saved = SavedState.capture(context, mobile, storage) + saved.writeTo(savedFile) + var passed = false + try { + importThrowawayAccount(mobile, storage) + val bunkerRelay = InstrumentationRegistry.getArguments().getString(RELAY_ARG) ?: FrostSignFixture.RELAY + mobile.saveRelayConfig(null, RelayConfigInfo(saved.relays.frostRelays, saved.relays.profileRelays, listOf(bunkerRelay))) + mobile.saveBunkerConfig(BunkerConfigInfo(true, emptyList())) + markers().edit().putBoolean(BunkerService.KEY_ROTATED_FOR_EARLIER_REVOKES, true).commit() + // Fresh keys, so requests an earlier run left on the relay never reach this bunker. + runBlocking { BunkerService.rotateBunkerCredentials(mobile) } + compose.setContent { KeepAndroidTheme { Host(mobile) } } + + // A secret-matched connect, and a sign the user approves with "always". + BunkerService.start(context) + val url1 = awaitRunning(previous = null) + val c1 = newClient() + assertOk(settled("connect", c1.secret, url1)) + awaitAuthorized(mobile, c1) + val signed = probeAsync("sign", c1.secret, url1, "1", "90") + approve(c1.pubkey) + assertOk(awaitProbe(signed)) + + // Control: after a plain restart the remembered grant signs with no connect and no prompt. + stopService() + BunkerService.start(context) + assertEquals(url1, awaitRunning(previous = null)) + assertOk(settled("sign", c1.secret, url1, "1", "30")) + + // Revoke from the Bunker screen while the bunker is stopped. + stopService() + screen = Screen.Bunker(1) + revokeListedClient(c1.pubkey) + assertTrue(Nip46ClientStore.isDenylisted(context, c1.pubkey)) + BunkerService.start(context) + val url2 = awaitRunning(previous = null) + assertRotated(url1, url2) + val swept = System.currentTimeMillis() + assertRefused("the start must revoke the restored grant of a revoked client", settled("sign", c1.secret, url2, "1", "15"), c1.pubkey, swept) + assertDead(url1, url2) + + // Revoke from the Bunker screen while the bunker is running. + val c3 = newClient() + assertOk(settled("connect", c3.secret, url2)) + awaitAuthorized(mobile, c3) + screen = Screen.Bunker(2) + revokeListedClient(c3.pubkey) + val url3 = awaitRunning(previous = url2) + assertRotated(url2, url3) + assertTrue(Nip46ClientStore.isDenylisted(context, c3.pubkey)) + assertDead(url2, url3) + + // A prompt answered after its client was revoked is refused. + val c5 = newClient() + assertOk(settled("connect", c5.secret, url3)) + awaitAuthorized(mobile, c5) + val asked = System.currentTimeMillis() + val late = probeAsync("sign", c5.secret, url3, "1", "90") + val requestId = awaitApprovalPrompt(c5.pubkey) + Nip46ClientStore.addToDenylist(context, c5.pubkey) + respond(requestId, c5.pubkey) + assertRefused("an approval given after the revoke must not sign", awaitProbe(late), c5.pubkey, asked) + + // Disconnect from App Permissions while the bunker is running. + val c6 = newClient() + assertOk(settled("connect", c6.secret, url3)) + awaitAuthorized(mobile, c6) + screen = Screen.Disconnect(c6.pubkey) + compose.waitUntil(30_000) { compose.onAllNodesWithText("Disconnect Client").fetchSemanticsNodes().isNotEmpty() } + compose.onNodeWithText("Disconnect Client").performScrollTo().performClick() + compose.onNodeWithText("Disconnect").performClick() + val url4 = awaitRunning(previous = url3) + assertRotated(url3, url4) + assertTrue(Nip46ClientStore.isDenylisted(context, c6.pubkey)) + assertFalse(c6.pubkey in mobile.getBunkerConfig().authorizedClients) + assertDead(url3, url4) + passed = true + } finally { + screen = Screen.None + val failures = cleanUp(mobile, storage, saved) + // A failed cleanup fails a passing run, but never hides the failure of a failing one. + if (passed && failures.isNotEmpty()) throw AssertionError("cleanup failed: ${failures.joinToString()}") + } + } + + @androidx.compose.runtime.Composable + private fun Host(mobile: KeepMobile) { + val url by BunkerService.bunkerUrl.collectAsState() + val status by BunkerService.status.collectAsState() + when (val current = screen) { + Screen.None -> Unit + is Screen.Bunker -> androidx.compose.runtime.key(current.generation) { + BunkerScreen(mobile, url, status, onToggleBunker = {}, onDismiss = {}) + } + is Screen.Disconnect -> AppPermissionsScreen( + packageName = "nip46:${current.pubkey}", + permissionStore = PermissionStore(Nip55Database.getInstance(context)), + onDismiss = {} + ) + } + } + + /** + * Leaves no test client a way in: a fresh bunker URL, every approved test client + * denylisted so the start sweep drops any grant it kept, and the request files (which + * hold client secrets and URLs) deleted. Each step runs whatever happened before it. + */ + private fun cleanUp(mobile: KeepMobile, storage: AndroidKeystoreStorage, saved: SavedState): List { + val failures = mutableListOf() + fun step(name: String, block: () -> Unit) = runCatching(block).onFailure { + Log.e(TAG, "cleanup step '$name' failed", it) + failures += "$name: $it" + } + step("stop the bunker") { stopService() } + step("rotate the bunker URL") { runBlocking { BunkerService.rotateBunkerCredentials(mobile) } } + failures += saved.restore(context, mobile, storage, extraDenylist = approvedClients) + step("delete the request files") { check(!dir.exists() || dir.deleteRecursively()) } + if (failures.isEmpty()) step("delete the saved state") { check(savedFile.delete() && (!clientsFile.exists() || clientsFile.delete())) } + return failures + } + + // A run killed mid-way (a crash, the timeout) leaves its saved state and the throwaway + // account behind; put the device back before saving its state again. + private fun recoverFromInterruptedRun(mobile: KeepMobile, storage: AndroidKeystoreStorage) { + if (savedFile.exists()) { + val failures = mutableListOf() + fun step(name: String, block: () -> Unit) = runCatching(block).onFailure { + Log.e(TAG, "recovery step '$name' failed", it) + failures += "$name: $it" + } + // The killed run left the bunker enabled, so the app may have started it again. + step("stop the bunker") { stopService() } + step("rotate the bunker URL") { runBlocking { BunkerService.rotateBunkerCredentials(mobile) } } + val clients = if (clientsFile.exists()) clientsFile.readLines().filter { it.isNotBlank() }.toSet() else emptySet() + failures += SavedState.readFrom(savedFile).restore(context, mobile, storage, extraDenylist = clients) + check(failures.isEmpty()) { + "could not restore the state an interrupted run saved (see README): ${failures.joinToString()}" + } + dir.deleteRecursively() + check(savedFile.delete() && (!clientsFile.exists() || clientsFile.delete())) + } + clientsFile.delete() + dropThrowawayAccount(mobile, storage) + } + + // The list rows have no semantics of their own, so every row's label and Revoke button + // are siblings; the button for a client is the one on the same line as its label. + private fun revokeListedClient(pubkey: String) { + val label = truncateStr(pubkey, 8u, 6u) + try { + compose.waitUntil(30_000) { compose.onAllNodesWithText(label).fetchSemanticsNodes().size == 1 } + } catch (e: ComposeTimeoutException) { + throw AssertionError("expected ${pubkey.take(8)} listed once:\n" + compose.onRoot().printToString(), e) + } + compose.onNodeWithText(label).performScrollTo() + val row = compose.onNodeWithText(label).fetchSemanticsNode().boundsInRoot + val buttons = compose.onAllNodesWithContentDescription("Revoke") + val index = buttons.fetchSemanticsNodes().indexOfFirst { it.boundsInRoot.center.y in row.top..row.bottom } + check(index >= 0) { "no Revoke button beside ${pubkey.take(8)}:\n" + compose.onRoot().printToString() } + buttons[index].performClick() + compose.onNodeWithText("Revoke").performClick() + // The row goes only after the revoke and its rotation both complete. + compose.waitUntil(60_000) { compose.onAllNodesWithText(label).fetchSemanticsNodes().isEmpty() } + } + + private fun importThrowawayAccount(mobile: KeepMobile, storage: AndroidKeystoreStorage) { + val info = withCipher(storage, "bunker-e2e-import", storage.getCipherForEncryption()) { + mobile.importNsec(THROWAWAY_NSEC.toByteArray(), "bunker-e2e") + } + check(info.groupPubkey == THROWAWAY_GROUP) { "unexpected throwaway key ${info.groupPubkey}" } + if (storage.getActiveShareKey() != THROWAWAY_GROUP) switchAccount(mobile, storage, THROWAWAY_GROUP) + FrostSignTestSupport.initializeWithDecryptContext(mobile, storage, "bunker-e2e-init") + } + + private fun stopService() { + BunkerService.stop(context) + awaitTrue("the service stops") { BunkerService.current() == null && BunkerService.status.value != BunkerStatus.RUNNING } + } + + private fun awaitRunning(previous: String?): String { + var url: String? = null + awaitTrue("the bunker runs with a new URL", 120_000) { + url = BunkerService.bunkerUrl.value + BunkerService.status.value == BunkerStatus.RUNNING && url != null && url != previous + } + return url!! + } + + private fun awaitAuthorized(mobile: KeepMobile, client: Client) = + awaitTrue("the connect from ${client.pubkey.take(8)} is authorized") { client.pubkey in mobile.getBunkerConfig().authorizedClients } + + private fun assertRotated(old: String, new: String) { + val before = parseBunkerUrl(old) + val after = parseBunkerUrl(new) + assertNotEquals("the bunker key must change", before.pubkey, after.pubkey) + assertNotEquals("the connect secret must change", before.secret, after.secret) + } + + // A TIMEOUT proves nothing on its own, so the same relay must answer a connect to the + // new URL right after. The probe reports UNSENT rather than TIMEOUT when no relay took + // the request, so a request that never left cannot pass for a dead URL. + private fun assertDead(old: String, new: String) { + var result = probe("connect", newClient().secret, old, "15") + repeat(2) { if (result.startsWith("UNSENT")) result = probe("connect", newClient().secret, old, "15") } + assertEquals("the old URL must be dead", "TIMEOUT", result) + assertOk(settled("connect", newClient().secret, new)) + } + + private fun awaitApprovalPrompt(pubkey: String): String { + var requestId: String? = null + awaitTrue("the approval prompt opens", 60_000) { + requestId = resumedApproval(pubkey)?.intent?.getStringExtra(Nip46ApprovalActivity.EXTRA_REQUEST_ID) + requestId != null + } + return requestId!! + } + + private fun resumedApproval(pubkey: String): Activity? { + var found: Activity? = null + InstrumentationRegistry.getInstrumentation().runOnMainSync { + found = ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED) + .firstOrNull { it is Nip46ApprovalActivity && it.intent.getStringExtra(Nip46ApprovalActivity.EXTRA_APP_PUBKEY) == pubkey } + } + return found + } + + // Answers the way the prompt's Approve button does after its biometric check, which + // is the one step this test cannot drive. + private fun respond(requestId: String, pubkey: String) { + // Only an approved client can hold a persisted grant; every other client only ever + // saw URLs that are rotated away. Recorded first, so a killed run's recovery can + // denylist it too. + clientsFile.appendText(pubkey + "\n") + approvedClients += pubkey + BunkerService.respondToApproval(requestId, approved = true, clientPubkey = pubkey, remember = BunkerRememberDuration.FOREVER) + val prompt = resumedApproval(pubkey) ?: return + InstrumentationRegistry.getInstrumentation().runOnMainSync { prompt.finish() } + } + + private fun approve(pubkey: String) = respond(awaitApprovalPrompt(pubkey), pubkey) + + private fun newClient(): Client { + val secret = ByteArray(32).also { SecureRandom().nextBytes(it) }.joinToString("") { "%02x".format(it) } + val pubkey = probe("pubkey", secret) + check(pubkey.matches(Regex("[0-9a-f]{64}"))) { "probe returned no pubkey: $pubkey" } + return Client(secret, pubkey) + } + + private fun probe(vararg args: String): String = awaitProbe(probeAsync(*args)) + + // A bunker reports RUNNING a moment before its relay subscription is live, and a relay + // can drop a request, so a positive check retries an unanswered one. Retrying only ever + // turns a lost request into an answer; checks that expect a TIMEOUT use probe(). + private fun settled(vararg args: String): String { + repeat(2) { probe(*args).let { if (it != "TIMEOUT" && !it.startsWith("UNSENT")) return it } } + return probe(*args) + } + + private fun probeAsync(vararg args: String): Int { + dir.mkdirs() + val n = ++requests + val tmp = File(dir, "req-$n.tmp") + tmp.writeText(args.joinToString(" ")) + check(tmp.renameTo(File(dir, "req-$n"))) + return n + } + + private fun awaitProbe(n: Int): String { + val res = File(dir, "res-$n") + awaitTrue("the host answers request $n", 180_000) { res.exists() } + return res.readText().trim() + } + + private fun awaitTrue(what: String, timeoutMs: Long = 60_000, condition: () -> Boolean) { + val deadline = System.currentTimeMillis() + timeoutMs + while (!condition()) { + check(System.currentTimeMillis() < deadline) { "timed out waiting until $what" } + Thread.sleep(250) + } + } + + private fun assertOk(result: String) = assertTrue("expected OK, got: $result", result.startsWith("OK ")) + + // The core does not answer a client that holds no permission, so a refusal can reach the + // probe as a TIMEOUT. The bunker's own log entry is what proves the request arrived and failed. + private fun assertRefused(message: String, result: String, pubkey: String, since: Long) { + assertFalse("$message; got: $result", result.startsWith("OK ")) + val log = EventLogStore.getInstance(Nip55Database.getInstance(context)) + awaitTrue("the bunker logs the refused sign from ${pubkey.take(8)}") { + runBlocking { log.getRecent(200) }.any { + it.timestamp >= since && it.category == EventLogCategory.BUNKER.name && it.level == EventLogLevel.WARN.name && + it.source == pubkey.take(8) && it.message.startsWith("sign_event") + } + } + } + + private fun markers() = context.getSharedPreferences(BunkerService.BUNKER_MARKERS_PREFS, Context.MODE_PRIVATE) + + private class SavedState( + val activeKey: String?, + val relays: RelayConfigInfo, + val bunker: BunkerConfigInfo, + val denylist: Set, + val marker: Boolean, + ) { + /** Restores each part on its own, so one failure does not skip the rest. Returns the failures. */ + fun restore(context: Context, mobile: KeepMobile, storage: AndroidKeystoreStorage, extraDenylist: Set): List { + val failures = mutableListOf() + fun step(name: String, block: () -> Unit) = runCatching(block).onFailure { + Log.e(TAG, "restore step '$name' failed", it) + failures += "$name: $it" + } + step("relay config") { mobile.saveRelayConfig(null, relays) } + step("bunker config") { mobile.saveBunkerConfig(bunker) } + // Only adds: the run never removes an entry, and the user may have revoked more since. + step("denylist") { Nip46ClientStore.addAllToDenylist(context, denylist + extraDenylist) } + step("rotation marker") { + check(context.getSharedPreferences(BunkerService.BUNKER_MARKERS_PREFS, Context.MODE_PRIVATE) + .edit().putBoolean(BunkerService.KEY_ROTATED_FOR_EARLIER_REVOKES, marker).commit()) + } + step("active account") { activeKey?.takeIf { it != THROWAWAY_GROUP }?.let { switchAccount(mobile, storage, it) } } + step("throwaway account") { dropThrowawayAccount(mobile, storage) } + return failures + } + + fun writeTo(file: File) { + val json = JSONObject() + .put("activeKey", activeKey ?: JSONObject.NULL) + .put("frostRelays", JSONArray(relays.frostRelays)) + .put("profileRelays", JSONArray(relays.profileRelays)) + .put("bunkerRelays", JSONArray(relays.bunkerRelays)) + .put("bunkerEnabled", bunker.enabled) + .put("authorizedClients", JSONArray(bunker.authorizedClients)) + .put("denylist", JSONArray(denylist.toList())) + .put("marker", marker) + val tmp = File(file.path + ".tmp") + tmp.writeText(json.toString()) + check(tmp.renameTo(file)) + } + + companion object { + fun capture(context: Context, mobile: KeepMobile, storage: AndroidKeystoreStorage) = SavedState( + activeKey = storage.getActiveShareKey(), + relays = mobile.getRelayConfig(null), + bunker = mobile.getBunkerConfig(), + denylist = Nip46ClientStore.getDenylist(context), + marker = context.getSharedPreferences(BunkerService.BUNKER_MARKERS_PREFS, Context.MODE_PRIVATE) + .getBoolean(BunkerService.KEY_ROTATED_FOR_EARLIER_REVOKES, false), + ) + + fun readFrom(file: File): SavedState { + val json = JSONObject(file.readText()) + fun list(name: String) = json.getJSONArray(name).let { a -> List(a.length()) { a.getString(it) } } + return SavedState( + activeKey = if (json.isNull("activeKey")) null else json.getString("activeKey"), + relays = RelayConfigInfo(list("frostRelays"), list("profileRelays"), list("bunkerRelays")), + bunker = BunkerConfigInfo(json.getBoolean("bunkerEnabled"), list("authorizedClients")), + denylist = list("denylist").toSet(), + marker = json.getBoolean("marker"), + ) + } + } + } + + private companion object { + const val TAG = "BunkerRevokeE2ETest" + const val MANUAL_ARG = "bunkerRevokeE2e" + const val RELAY_ARG = "bunkerRelay" + // The NIP-19 test vector key: public, used only for this temporary account. + const val THROWAWAY_NSEC = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5" + const val THROWAWAY_GROUP = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e" + + fun switchAccount(mobile: KeepMobile, storage: AndroidKeystoreStorage, key: String) = + withCipher(storage, "bunker-e2e-switch", requireNotNull(storage.getCipherForShareDecryption(key)) { "no cipher for $key" }) { + mobile.setActiveShare(key) + } + + // Matched by its key, so no account of the device's own is ever deleted. + fun dropThrowawayAccount(mobile: KeepMobile, storage: AndroidKeystoreStorage) { + val shares = mobile.listShares() + if (shares.none { it.groupPubkey == THROWAWAY_GROUP }) return + if (storage.getActiveShareKey() == THROWAWAY_GROUP) { + shares.firstOrNull { it.groupPubkey != THROWAWAY_GROUP }?.let { switchAccount(mobile, storage, it.groupPubkey) } + } + mobile.deleteShareByKey(THROWAWAY_GROUP) + } + } +} diff --git a/app/src/androidTest/kotlin/io/privkey/keep/nip55/FrostSignTestSupport.kt b/app/src/androidTest/kotlin/io/privkey/keep/nip55/FrostSignTestSupport.kt index f620ea11..d4a25da4 100644 --- a/app/src/androidTest/kotlin/io/privkey/keep/nip55/FrostSignTestSupport.kt +++ b/app/src/androidTest/kotlin/io/privkey/keep/nip55/FrostSignTestSupport.kt @@ -5,6 +5,7 @@ import io.privkey.keep.storage.AndroidKeystoreStorage import io.privkey.keep.uniffi.KeepMobile import org.junit.Assert.assertEquals import org.junit.AssumptionViolatedException +import javax.crypto.Cipher object FrostSignTestSupport { @@ -27,14 +28,8 @@ object FrostSignTestSupport { "delete it. Manually clear the device before running this test." ) } - val cipher = storage.getCipherForEncryption() - storage.setRequestIdContext(requestId) - storage.setPendingCipher(requestId, cipher) - try { + withCipher(storage, requestId, storage.getCipherForEncryption()) { mobile.importShare(exportData, FrostSignFixture.PASSPHRASE, "test") - } finally { - storage.clearRequestIdContext() - storage.clearPendingCipher(requestId) } } @@ -43,11 +38,17 @@ object FrostSignTestSupport { storage: AndroidKeystoreStorage, requestId: String, ) { - val cipher = requireNotNull(storage.getCipherForDecryption()) { "decryption cipher must not be null" } + withCipher(storage, requestId, requireNotNull(storage.getCipherForDecryption()) { "decryption cipher must not be null" }) { + mobile.initialize(listOf(FrostSignFixture.RELAY)) + } + } + + /** Runs [block] with [cipher] staged as the keystore context the core reads for [requestId]. */ + fun withCipher(storage: AndroidKeystoreStorage, requestId: String, cipher: Cipher, block: () -> T): T { storage.setPendingCipher(requestId, cipher) storage.setRequestIdContext(requestId) try { - mobile.initialize(listOf(FrostSignFixture.RELAY)) + return block() } finally { storage.clearRequestIdContext() storage.clearPendingCipher(requestId) diff --git a/testharness/nip46probe/.gitignore b/testharness/nip46probe/.gitignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/testharness/nip46probe/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/testharness/nip46probe/Cargo.lock b/testharness/nip46probe/Cargo.lock new file mode 100644 index 00000000..49af5115 --- /dev/null +++ b/testharness/nip46probe/Cargo.lock @@ -0,0 +1,1664 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-utility" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a34a3b57207a7a1007832416c3e4862378c8451b4e8e093e436f48c2d3d2c151" +dependencies = [ + "futures-util", + "gloo-timers", + "tokio", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-wsocket" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c92385c7c8b3eb2de1b78aeca225212e4c9a69a78b802832759b108681a5069" +dependencies = [ + "async-utility", + "futures", + "futures-util", + "js-sys", + "tokio", + "tokio-rustls", + "tokio-socks", + "tokio-tungstenite", + "url", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "atomic-destructor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "serde", + "unicode-normalization", +] + +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2d6094e2a1ba3c93b5a596fe5a10d1a10c3c6e06785cde89f693a044c01aa40" +dependencies = [ + "bitcoin-internals", +] + +[[package]] +name = "bitcoin-internals" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a30a22d1f112dde8e16be7b45c63645dc165cef254f835b3e1e9553e485cfa64" +dependencies = [ + "hex-conservative 0.3.2", +] + +[[package]] +name = "bitcoin-io" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" +dependencies = [ + "bitcoin-consensus-encoding", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "bitcoin-io", + "hex-conservative 0.2.2", + "serde", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex-conservative" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830e599c2904b08f0834ee6337d8fe8f0ed4a63b5d9e7a7f49c0ffa06d08d360" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "negentropy" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d66662cb5ca384d4991509755ed5a42dd8e31c4282bf881938e506fc3146ec" + +[[package]] +name = "nip46probe" +version = "0.1.0" +dependencies = [ + "nostr", + "nostr-sdk", + "rustls", + "serde_json", + "tokio", +] + +[[package]] +name = "nostr" +version = "0.44.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d3d987ea7078dc36947cde532637c472a229426702e4331dd7667325378bd9" +dependencies = [ + "aes", + "base64", + "bech32", + "bip39", + "bitcoin_hashes", + "cbc", + "chacha20", + "chacha20poly1305", + "getrandom 0.2.17", + "hex", + "instant", + "scrypt", + "secp256k1", + "serde", + "serde_json", + "unicode-normalization", + "url", +] + +[[package]] +name = "nostr-database" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1" +dependencies = [ + "lru", + "nostr", + "tokio", +] + +[[package]] +name = "nostr-gossip" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6" +dependencies = [ + "nostr", +] + +[[package]] +name = "nostr-relay-pool" +version = "0.44.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c85c54d6ca9aae4ae2bf19a7663ba9db5f45f783f1d24aff55f006386b8b99a1" +dependencies = [ + "async-utility", + "async-wsocket", + "atomic-destructor", + "hex", + "lru", + "negentropy", + "nostr", + "nostr-database", + "tokio", + "tracing", +] + +[[package]] +name = "nostr-sdk" +version = "0.44.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393" +dependencies = [ + "async-utility", + "nostr", + "nostr-database", + "nostr-gossip", + "nostr-relay-pool", + "tokio", + "tracing", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2", +] + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "rand 0.8.6", + "secp256k1-sys", + "serde", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.0", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.0", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-socks" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.21", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/testharness/nip46probe/Cargo.toml b/testharness/nip46probe/Cargo.toml new file mode 100644 index 00000000..371ca2f7 --- /dev/null +++ b/testharness/nip46probe/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "nip46probe" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +nostr = { version = "0.44", features = ["nip46"] } +nostr-sdk = { version = "0.44", features = ["nip44"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } +serde_json = "1.0" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } + +[workspace] diff --git a/testharness/nip46probe/README.md b/testharness/nip46probe/README.md new file mode 100644 index 00000000..6ef6ac20 --- /dev/null +++ b/testharness/nip46probe/README.md @@ -0,0 +1,27 @@ +# nip46probe + +Host-side NIP-46 client for the bunker revoke end-to-end test, `BunkerRevokeE2ETest`, which is a **manual-only** instrumented test (skipped by default). + +The test runs the real `BunkerService` against a public relay and drives each revoke path through its real screen: Revoke on the Bunker screen with the bunker stopped and running, and Disconnect in App Permissions. After each one it asks this probe, a real NIP-46 client on the host, to connect or sign over the relay and checks the answer: + +- a client signs with a remembered grant after a plain restart (control), and is refused after a revoke made while the bunker was stopped; +- the old bunker URL no longer connects after a revoke, and the new one does over the same relay; +- a prompt approved after its client was revoked does not sign. + +The probe and the test exchange requests through the app's `files/bunker-e2e` directory over `adb shell run-as`. Each request reaches the probe on stdin, so client secrets and bunker URLs never appear in a process listing. The probe prints one line per request: `OK`, `ERR`, `TIMEOUT`, or `UNSENT` when no relay took the request, so a request that never left cannot pass for a dead URL. + +## Run + +```bash +./gradlew installDebug installDebugAndroidTest +testharness/nip46probe/run-revoke-e2e.sh +``` + +Notes: +- Needs `cargo`, `adb` with one device attached, and network access to the relay in `FrostSignFixture.RELAY`. Set `BUNKER_RELAY` to use another relay. +- Turn the bunker off in the app first. With it on, the app starts the bunker as soon as the test launches it, and the test skips rather than take over a running bunker. +- The test imports a temporary account from a public test-vector key (the fixture share is 2-of-3 and cannot sign alone). It saves the device's accounts, relay and bunker settings, denylist and rotation marker to a file before changing anything, and restores them afterwards, or at the start of the next run if this one was killed. +- Every run ends with a fresh bunker URL, and the test clients it approved (two per run) stay on the device's denylist, so no test client keeps a way in. Clients paired with the device's bunker before the run need to pair again. +- If a killed run's state can no longer be restored (for example, the account that was active has since been deleted), every later run fails at the start and says so. Set the device up by hand, then remove the saved state with `adb shell run-as io.privkey.keep rm files/bunker-e2e-saved.json files/bunker-e2e-clients.txt`. +- The prompt's Approve button requires a biometric, so the test answers the prompt the way that button does once the biometric succeeds. +- `Cargo.lock` is committed so the probe builds against the same `nostr` versions as the core. Build output lands in `target/` and is not committed. diff --git a/testharness/nip46probe/run-revoke-e2e.sh b/testharness/nip46probe/run-revoke-e2e.sh new file mode 100755 index 00000000..e5d7d034 --- /dev/null +++ b/testharness/nip46probe/run-revoke-e2e.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Runs BunkerRevokeE2ETest on the connected device and serves the probe requests it +# writes to the app's files/bunker-e2e directory. Expects the debug app and its test +# APK to be installed (./gradlew installDebug installDebugAndroidTest). Set +# BUNKER_RELAY to run the bunker on a relay other than the fixture relay. +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +pkg=io.privkey.keep +log="$(mktemp)" +probe_log="$log.probe" + +cargo build --release --quiet --manifest-path "$here/Cargo.toml" +probe="$here/target/release/nip46probe" + +on_device() { adb shell "run-as $pkg sh -c '$1'"; } + +on_device "rm -rf files/bunker-e2e" +adb shell am instrument -w -r -e bunkerRevokeE2e 1 ${BUNKER_RELAY:+-e bunkerRelay "$BUNKER_RELAY"} \ + -e class io.privkey.keep.nip46.BunkerRevokeE2ETest "$pkg.test/androidx.test.runner.AndroidJUnitRunner" >"$log" 2>&1 & +instrumentation=$! +trap 'kill "$instrumentation" 2>/dev/null || true; cat "$log"; echo "probe diagnostics: $probe_log"' EXIT + +n=1 +while kill -0 "$instrumentation" 2>/dev/null; do + if req="$(on_device "cat files/bunker-e2e/req-$n" 2>/dev/null)" && [ -n "$req" ]; then + # The request goes in on stdin so client secrets never appear in a process listing. + # A probe that fails still answers, so the test reports it instead of waiting out its timeout. + out="$(printf '%s\n' "$req" | NIP46PROBE_DEBUG=1 "$probe" 2>>"$probe_log" | tail -n 1)" || true + [ -n "$out" ] || out="ERR probe failed, see $probe_log" + encoded="$(printf '%s' "$out" | base64 -w0)" + if on_device "echo $encoded | base64 -d > files/bunker-e2e/res-$n.tmp && mv files/bunker-e2e/res-$n.tmp files/bunker-e2e/res-$n"; then + echo "request $n: ${req%% *} -> ${out:0:120}" + n=$((n + 1)) + fi + else + sleep 1 + fi +done +wait "$instrumentation" || true + +grep -q '^OK (1 test)' "$log" && ! grep -q 'INSTRUMENTATION_STATUS_CODE: -[34]' "$log" diff --git a/testharness/nip46probe/src/main.rs b/testharness/nip46probe/src/main.rs new file mode 100644 index 00000000..26aa658e --- /dev/null +++ b/testharness/nip46probe/src/main.rs @@ -0,0 +1,119 @@ +//! Host-side NIP-46 client for BunkerRevokeE2ETest. Reads one request line on stdin, +//! so client secrets and bunker URLs never appear on a command line: +//! +//! pubkey +//! connect [timeout_secs] +//! sign [timeout_secs] +//! +//! `sign` sends sign_event without connecting first, so it can exercise a grant the +//! bunker restored from disk. Prints one line: `OK `, `ERR `, `TIMEOUT`, +//! or `UNSENT ` when no relay took the subscription or the request. + +use std::time::Duration; + +use nostr::nips::nip46::NostrConnectURI; +use nostr_sdk::prelude::*; + +#[tokio::main] +async fn main() { + rustls::crypto::ring::default_provider().install_default().ok(); + let mut line = String::new(); + std::io::stdin().read_line(&mut line).expect("request on stdin"); + let args: Vec = line.split_whitespace().map(str::to_owned).collect(); + let keys = Keys::parse(&args[1]).expect("client secret key"); + let timeout = |i: usize| args.get(i).map(|s| s.parse().expect("timeout")).unwrap_or(25); + match args[0].as_str() { + "pubkey" => println!("{}", keys.public_key().to_hex()), + "connect" => { + let (signer, relays, secret) = bunker(&args[2]); + let mut params = vec![signer.to_hex()]; + params.extend(secret); + println!("{}", request(&keys, signer, &relays, "connect", params, timeout(3)).await); + } + "sign" => { + let (signer, relays, _) = bunker(&args[2]); + let kind: u16 = args[3].parse().expect("kind"); + let event = serde_json::json!({ + "kind": kind, + "content": "keep-android bunker revoke e2e", + "tags": [], + "created_at": Timestamp::now().as_secs() + }) + .to_string(); + println!("{}", request(&keys, signer, &relays, "sign_event", vec![event], timeout(4)).await); + } + other => panic!("unknown command {other}"), + } +} + +fn debug(line: String) { + if std::env::var_os("NIP46PROBE_DEBUG").is_some() { + eprintln!("{line}"); + } +} + +fn bunker(url: &str) -> (PublicKey, Vec, Option) { + match NostrConnectURI::parse(url).expect("bunker url") { + NostrConnectURI::Bunker { remote_signer_public_key, relays, secret } => (remote_signer_public_key, relays, secret), + NostrConnectURI::Client { .. } => panic!("expected a bunker:// url"), + } +} + +async fn request(keys: &Keys, signer: PublicKey, relays: &[RelayUrl], method: &str, params: Vec, timeout: u64) -> String { + let client = Client::new(keys.clone()); + for relay in relays { + client.add_relay(relay.clone()).await.expect("add relay"); + } + client.connect().await; + client.wait_for_connection(Duration::from_secs(10)).await; + // Signers may backdate their replies by a few seconds, so look back rather than from now; + // the reply is matched on the request id below. + let since = Timestamp::from_secs(Timestamp::now().as_secs().saturating_sub(60)); + let filter = Filter::new().kind(Kind::NostrConnect).pubkey(keys.public_key()).since(since); + let subscribed = client.subscribe(filter, None).await.expect("subscribe"); + if subscribed.success.is_empty() { + client.disconnect().await; + return format!("UNSENT subscribe failed {:?}", subscribed.failed); + } + let mut notifications = client.notifications(); + + // Random, so a reply to an earlier request from the same client key never matches. + let id = Keys::generate().public_key().to_hex(); + let body = serde_json::json!({ "id": id, "method": method, "params": params }).to_string(); + let content = nip44::encrypt(keys.secret_key(), &signer, &body, nip44::Version::V2).expect("encrypt"); + let event = EventBuilder::new(Kind::NostrConnect, content) + .tag(Tag::public_key(signer)) + .sign_with_keys(keys) + .expect("sign request"); + let sent = client.send_event(&event).await.expect("send request"); + debug(format!("sent {} to {:?}, failed {:?}", event.id, sent.success, sent.failed)); + // A request no relay took cannot be answered; never let it pass for a TIMEOUT. + if sent.success.is_empty() { + client.disconnect().await; + return format!("UNSENT {:?}", sent.failed); + } + + let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout); + let out = loop { + let left = deadline.saturating_duration_since(tokio::time::Instant::now()); + let Ok(received) = tokio::time::timeout(left, notifications.recv()).await else { + break "TIMEOUT".to_string(); + }; + let Ok(RelayPoolNotification::Event { event, .. }) = received else { continue }; + debug(format!("received kind {} from {} at {}", event.kind, event.pubkey, event.created_at)); + if event.pubkey != signer { + continue; + } + let Ok(plain) = nip44::decrypt(keys.secret_key(), &signer, &event.content) else { continue }; + let Ok(response) = serde_json::from_str::(&plain) else { continue }; + if response["id"] != id { + continue; + } + match response["error"].as_str().filter(|e| !e.is_empty()) { + Some(err) => break format!("ERR {err}"), + None => break format!("OK {}", response["result"]), + } + }; + client.disconnect().await; + out +} From 69fec9dc7aaf05ff90926e0c6ebf795635979ab6 Mon Sep 17 00:00:00 2001 From: Kyle Santiago Date: Mon, 19 Oct 2026 19:54:48 -0400 Subject: [PATCH 3/4] Revoke NIP-46 clients through one function serialized with connect saves --- .../privkey/keep/nip46/BunkerRevokeE2ETest.kt | 67 +++++++++-- .../io/privkey/keep/nip46/BunkerScreen.kt | 28 ++--- .../io/privkey/keep/nip46/BunkerService.kt | 111 +++++++++++++----- .../keep/nip55/AppPermissionsScreen.kt | 13 +- .../main/res/values/strings_connections.xml | 6 +- .../keep/nip46/RevokedClientSetTest.kt | 36 ++++++ 6 files changed, 194 insertions(+), 67 deletions(-) create mode 100644 app/src/test/kotlin/io/privkey/keep/nip46/RevokedClientSetTest.kt diff --git a/app/src/androidTest/kotlin/io/privkey/keep/nip46/BunkerRevokeE2ETest.kt b/app/src/androidTest/kotlin/io/privkey/keep/nip46/BunkerRevokeE2ETest.kt index e0671ed4..3e2a7543 100644 --- a/app/src/androidTest/kotlin/io/privkey/keep/nip46/BunkerRevokeE2ETest.kt +++ b/app/src/androidTest/kotlin/io/privkey/keep/nip46/BunkerRevokeE2ETest.kt @@ -8,6 +8,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.test.ComposeTimeoutException +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onAllNodesWithText @@ -45,6 +48,7 @@ import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Assume.assumeTrue import org.junit.Rule @@ -76,6 +80,7 @@ class BunkerRevokeE2ETest { private val dir by lazy { File(context.filesDir, "bunker-e2e") } private val savedFile by lazy { File(context.filesDir, "bunker-e2e-saved.json") } private val clientsFile by lazy { File(context.filesDir, "bunker-e2e-clients.txt") } + private val testClients = mutableSetOf() private val approvedClients = mutableSetOf() private var requests = 0 private var screen by mutableStateOf(Screen.None) @@ -162,6 +167,7 @@ class BunkerRevokeE2ETest { val c6 = newClient() assertOk(settled("connect", c6.secret, url3)) awaitAuthorized(mobile, c6) + awaitStored(c6) screen = Screen.Disconnect(c6.pubkey) compose.waitUntil(30_000) { compose.onAllNodesWithText("Disconnect Client").fetchSemanticsNodes().isNotEmpty() } compose.onNodeWithText("Disconnect Client").performScrollTo().performClick() @@ -170,7 +176,29 @@ class BunkerRevokeE2ETest { assertRotated(url3, url4) assertTrue(Nip46ClientStore.isDenylisted(context, c6.pubkey)) assertFalse(c6.pubkey in mobile.getBunkerConfig().authorizedClients) + assertNull("the client's stored entry must go", Nip46ClientStore.getClient(context, c6.pubkey)) assertDead(url3, url4) + + // Revoke All from the Bunker screen while the bunker is running. + val c7 = newClient() + val c8 = newClient() + for (c in listOf(c7, c8)) { + assertOk(settled("connect", c.secret, url4)) + awaitAuthorized(mobile, c) + awaitStored(c) + } + screen = Screen.Bunker(3) + compose.waitUntil(30_000) { compose.onAllNodesWithText(truncateStr(c8.pubkey, 8u, 6u)).fetchSemanticsNodes().isNotEmpty() } + compose.onNodeWithText("Revoke All").performScrollTo().performClick() + compose.onNode(hasText("Revoke All") and hasAnyAncestor(isDialog())).performClick() + val url5 = awaitRunning(previous = url4) + assertRotated(url4, url5) + for (c in listOf(c7, c8)) { + assertTrue(Nip46ClientStore.isDenylisted(context, c.pubkey)) + assertFalse(c.pubkey in mobile.getBunkerConfig().authorizedClients) + assertNull("the client's stored entry must go", Nip46ClientStore.getClient(context, c.pubkey)) + } + assertDead(url4, url5) passed = true } finally { screen = Screen.None @@ -210,7 +238,7 @@ class BunkerRevokeE2ETest { } step("stop the bunker") { stopService() } step("rotate the bunker URL") { runBlocking { BunkerService.rotateBunkerCredentials(mobile) } } - failures += saved.restore(context, mobile, storage, extraDenylist = approvedClients) + failures += saved.restore(context, mobile, storage, testClients, approvedClients) step("delete the request files") { check(!dir.exists() || dir.deleteRecursively()) } if (failures.isEmpty()) step("delete the saved state") { check(savedFile.delete() && (!clientsFile.exists() || clientsFile.delete())) } return failures @@ -228,8 +256,12 @@ class BunkerRevokeE2ETest { // The killed run left the bunker enabled, so the app may have started it again. step("stop the bunker") { stopService() } step("rotate the bunker URL") { runBlocking { BunkerService.rotateBunkerCredentials(mobile) } } - val clients = if (clientsFile.exists()) clientsFile.readLines().filter { it.isNotBlank() }.toSet() else emptySet() - failures += SavedState.readFrom(savedFile).restore(context, mobile, storage, extraDenylist = clients) + val recorded = if (clientsFile.exists()) clientsFile.readLines().mapNotNull { line -> line.split(" ").takeIf { it.size == 2 } } else emptyList() + failures += SavedState.readFrom(savedFile).restore( + context, mobile, storage, + testClients = recorded.map { it[1] }.toSet(), + approvedClients = recorded.filter { it[0] == APPROVED }.map { it[1] }.toSet(), + ) check(failures.isEmpty()) { "could not restore the state an interrupted run saved (see README): ${failures.joinToString()}" } @@ -283,6 +315,9 @@ class BunkerRevokeE2ETest { return url!! } + private fun awaitStored(client: Client) = + awaitTrue("the client ${client.pubkey.take(8)} is stored") { Nip46ClientStore.getClient(context, client.pubkey) != null } + private fun awaitAuthorized(mobile: KeepMobile, client: Client) = awaitTrue("the connect from ${client.pubkey.take(8)} is authorized") { client.pubkey in mobile.getBunkerConfig().authorizedClients } @@ -327,7 +362,7 @@ class BunkerRevokeE2ETest { // Only an approved client can hold a persisted grant; every other client only ever // saw URLs that are rotated away. Recorded first, so a killed run's recovery can // denylist it too. - clientsFile.appendText(pubkey + "\n") + clientsFile.appendText("$APPROVED $pubkey\n") approvedClients += pubkey BunkerService.respondToApproval(requestId, approved = true, clientPubkey = pubkey, remember = BunkerRememberDuration.FOREVER) val prompt = resumedApproval(pubkey) ?: return @@ -340,6 +375,9 @@ class BunkerRevokeE2ETest { val secret = ByteArray(32).also { SecureRandom().nextBytes(it) }.joinToString("") { "%02x".format(it) } val pubkey = probe("pubkey", secret) check(pubkey.matches(Regex("[0-9a-f]{64}"))) { "probe returned no pubkey: $pubkey" } + // Recorded before use, so a killed run's recovery knows it. + clientsFile.appendText("$CLIENT $pubkey\n") + testClients += pubkey return Client(secret, pubkey) } @@ -401,7 +439,18 @@ class BunkerRevokeE2ETest { val marker: Boolean, ) { /** Restores each part on its own, so one failure does not skip the rest. Returns the failures. */ - fun restore(context: Context, mobile: KeepMobile, storage: AndroidKeystoreStorage, extraDenylist: Set): List { + /** + * The denylist ends as the saved one plus every test client that was approved (and + * so may hold a grant). The other test clients, which only ever saw URLs that were + * rotated away, come off it again; no entry of the user's own is removed. + */ + fun restore( + context: Context, + mobile: KeepMobile, + storage: AndroidKeystoreStorage, + testClients: Set, + approvedClients: Set, + ): List { val failures = mutableListOf() fun step(name: String, block: () -> Unit) = runCatching(block).onFailure { Log.e(TAG, "restore step '$name' failed", it) @@ -409,8 +458,10 @@ class BunkerRevokeE2ETest { } step("relay config") { mobile.saveRelayConfig(null, relays) } step("bunker config") { mobile.saveBunkerConfig(bunker) } - // Only adds: the run never removes an entry, and the user may have revoked more since. - step("denylist") { Nip46ClientStore.addAllToDenylist(context, denylist + extraDenylist) } + step("denylist") { + (testClients - approvedClients - denylist).forEach { Nip46ClientStore.removeFromDenylist(context, it) } + Nip46ClientStore.addAllToDenylist(context, denylist + approvedClients) + } step("rotation marker") { check(context.getSharedPreferences(BunkerService.BUNKER_MARKERS_PREFS, Context.MODE_PRIVATE) .edit().putBoolean(BunkerService.KEY_ROTATED_FOR_EARLIER_REVOKES, marker).commit()) @@ -464,6 +515,8 @@ class BunkerRevokeE2ETest { const val MANUAL_ARG = "bunkerRevokeE2e" const val RELAY_ARG = "bunkerRelay" // The NIP-19 test vector key: public, used only for this temporary account. + const val CLIENT = "client" + const val APPROVED = "approved" const val THROWAWAY_NSEC = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5" const val THROWAWAY_GROUP = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e" diff --git a/app/src/main/kotlin/io/privkey/keep/nip46/BunkerScreen.kt b/app/src/main/kotlin/io/privkey/keep/nip46/BunkerScreen.kt index 27f9da30..8b48a995 100644 --- a/app/src/main/kotlin/io/privkey/keep/nip46/BunkerScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/nip46/BunkerScreen.kt @@ -287,20 +287,16 @@ fun BunkerScreen( scope.launch { runCatching { withContext(NonCancellable + Dispatchers.IO) { - Nip46ClientStore.addToDenylist(context, pubkey) - val saved = BunkerConfigStore.update(keepMobile) { config -> - BunkerConfigInfo(config.enabled, config.authorizedClients.filter { it.lowercase() != pubkey.lowercase() }) - } - BunkerService.forgetPendingAuth(pubkey) - BunkerService.revokeClientInEngine(pubkey) - BunkerService.rotateBunkerCredentials(keepMobile) - saved + BunkerService.revokeClients(context, keepMobile, listOf(pubkey)) } }.onSuccess { saved -> authorizedClients = saved.authorizedClients.toSet() Toast.makeText(context, toastClientRevoked, Toast.LENGTH_SHORT).show() }.onFailure { if (it is CancellationException) throw it + // A revoke can fail after it has changed the config, so show what is saved now. + runCatching { withContext(Dispatchers.IO) { keepMobile.getBunkerConfig() } } + .onSuccess { saved -> authorizedClients = saved.authorizedClients.toSet() } Toast.makeText(context, toastRevokeFailed, Toast.LENGTH_SHORT).show() } } @@ -313,22 +309,18 @@ fun BunkerScreen( RevokeAllClientsDialog( onConfirm = { scope.launch { - val revoked = authorizedClients runCatching { withContext(NonCancellable + Dispatchers.IO) { - Nip46ClientStore.addAllToDenylist(context, revoked) - BunkerConfigStore.update(keepMobile) { config -> - BunkerConfigInfo(config.enabled, emptyList()) - } - revoked.forEach { BunkerService.forgetPendingAuth(it) } - BunkerService.revokeAllClientsInEngine() - BunkerService.rotateBunkerCredentials(keepMobile) + BunkerService.revokeClients(context, keepMobile, null) } - }.onSuccess { - authorizedClients = emptySet() + }.onSuccess { saved -> + authorizedClients = saved.authorizedClients.toSet() Toast.makeText(context, toastAllClientsRevoked, Toast.LENGTH_SHORT).show() }.onFailure { if (it is CancellationException) throw it + // A revoke can fail after it has changed the config, so show what is saved now. + runCatching { withContext(Dispatchers.IO) { keepMobile.getBunkerConfig() } } + .onSuccess { saved -> authorizedClients = saved.authorizedClients.toSet() } Toast.makeText(context, toastRevokeAllFailed, Toast.LENGTH_SHORT).show() } } diff --git a/app/src/main/kotlin/io/privkey/keep/nip46/BunkerService.kt b/app/src/main/kotlin/io/privkey/keep/nip46/BunkerService.kt index 63705b2c..5b539a11 100644 --- a/app/src/main/kotlin/io/privkey/keep/nip46/BunkerService.kt +++ b/app/src/main/kotlin/io/privkey/keep/nip46/BunkerService.kt @@ -57,6 +57,18 @@ import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference +/** + * The clients a revoke covers: [pubkeys], or for Revoke All (null) every client the config, + * the authorized cache or a connect still being saved knows about. Lowercased, since pubkeys + * are compared lowercased everywhere else. + */ +internal fun revokedClientSet( + pubkeys: Collection?, + configured: Collection, + cached: Collection, + pending: Collection, +): Set = (pubkeys ?: (configured + cached + pending)).map { it.lowercase() }.toSet() + class BunkerService : Service() { companion object { @@ -151,21 +163,59 @@ class BunkerService : Service() { } /** - * Rotate the bunker credentials whether or not the service is running. Revoking - * a client needs this: the connect secret in the bunker URL is reusable, so a - * revoked client that kept the URL could connect again under a new key. + * The one revoke path for NIP-46 clients: [pubkeys], or every client when null. + * Denylists them, drops them from the app's lists and the engine, then rotates the + * bunker URL, since the connect secret in it is reusable and a revoked client that + * kept it could connect again under a new key. Returns the saved bunker config. + * + * Stopgap until the core owns revocation: a single core revoke that records the + * revocation, drops live and persisted grants and invalidates the connect secret + * replaces this sequence, and the Kotlin denylist with it. + */ + internal suspend fun revokeClients( + context: Context, + keepMobile: io.privkey.keep.uniffi.KeepMobile, + pubkeys: Collection?, + ): io.privkey.keep.uniffi.BunkerConfigInfo { + var revoked = emptySet() + val saved = BunkerConfigStore.update(keepMobile) { config -> + val service = current() + revoked = revokedClientSet( + pubkeys, + config.authorizedClients, + service?.authorizedClientsCache?.let { synchronized(it) { it.toList() } }.orEmpty(), + service?.pendingAuthSaves?.let { synchronized(it) { it.toList() } }.orEmpty(), + ) + // Out of pendingAuthSaves and denylisted before the config drops them, so neither the + // gate nor a connect still being saved sees them unblocked. + revoked.forEach { forgetPendingAuth(it) } + Nip46ClientStore.addAllToDenylist(context, revoked) + io.privkey.keep.uniffi.BunkerConfigInfo(config.enabled, config.authorizedClients.filter { it.lowercase() !in revoked }) + } + revoked.forEach { pubkey -> + runCatching { Nip46ClientStore.removeClient(context, pubkey) } + .onFailure { if (BuildConfig.DEBUG) Log.e(TAG, "Failed to remove NIP-46 client: ${it::class.simpleName}") } + } + if (pubkeys == null) revokeAllClientsInEngine() else revoked.forEach { revokeClientInEngine(it) } + rotateBunkerCredentials(keepMobile) + return saved + } + + /** + * Rotate the bunker credentials whether or not the service is running. */ internal suspend fun rotateBunkerCredentials(keepMobile: io.privkey.keep.uniffi.KeepMobile) { current()?.takeIf { it.serving }?.let { return it.rotateBunkerUrl() } BunkerHandler(keepMobile).use { it.rotateBunkerCredentials() } // A service that began starting during the rotation may have loaded the old keys. + // Remove once the pinned core's rotation stops a bunker started from any handler. current()?.takeIf { it.serving }?.rotateBunkerUrl() } /** * Versions before revoke rotated the URL left every revoked client holding a * working connect secret, so rotate once if anything was ever revoked. - * Returns whether it rotated. + * Returns whether it rotated. Lives here only while the denylist does. */ internal fun rotateOnceForEarlierRevokes(context: Context, keepMobile: io.privkey.keep.uniffi.KeepMobile): Boolean { val markers = context.getSharedPreferences(BUNKER_MARKERS_PREFS, Context.MODE_PRIVATE) @@ -643,31 +693,37 @@ class BunkerService : Service() { pendingAuthSaves.add(pk) serviceScope.launch(Dispatchers.IO) { try { - if (clearDenylist) { - runCatching { Nip46ClientStore.removeFromDenylist(this@BunkerService, pk) } - } else if (runCatching { Nip46ClientStore.isDenylisted(this@BunkerService, pk) }.getOrDefault(true)) { - if (BuildConfig.DEBUG) Log.w(TAG, "Ignoring connect from revoked client") - return@launch - } - val mobile = keepMobileRef + val mobile = keepMobileRef ?: return@launch val evicted = mutableListOf() - if (mobile != null) { - runCatching { - BunkerConfigStore.update(mobile) { config -> - if (config.authorizedClients.none { it.lowercase() == pk }) { - val combined = config.authorizedClients + pk - val capped = combined.takeLast(MAX_AUTHORIZED_CLIENTS) - evicted.addAll(combined.dropLast(capped.size)) - io.privkey.keep.uniffi.BunkerConfigInfo(config.enabled, capped) - } else { - config - } + var accepted = false + runCatching { + BunkerConfigStore.update(mobile) { config -> + // Decided under the same lock as revokeClients, which drops a client from + // pendingAuthSaves when it revokes it, so a revoke that lands while this + // connect is being saved is never undone by it. + when { + pk !in pendingAuthSaves -> return@update config + clearDenylist -> Nip46ClientStore.removeFromDenylist(this@BunkerService, pk) + runCatching { Nip46ClientStore.isDenylisted(this@BunkerService, pk) }.getOrDefault(true) -> return@update config + } + accepted = true + if (config.authorizedClients.none { it.lowercase() == pk }) { + val combined = config.authorizedClients + pk + val capped = combined.takeLast(MAX_AUTHORIZED_CLIENTS) + evicted.addAll(combined.dropLast(capped.size)) + io.privkey.keep.uniffi.BunkerConfigInfo(config.enabled, capped) + } else { + config } - }.onSuccess { - authorizedClientsCache.add(pk) - }.onFailure { - if (BuildConfig.DEBUG) Log.e(TAG, "Failed to persist authorized client: ${it::class.simpleName}") } + }.onSuccess { + if (accepted) authorizedClientsCache.add(pk) + }.onFailure { + if (BuildConfig.DEBUG) Log.e(TAG, "Failed to persist authorized client: ${it::class.simpleName}") + } + if (!accepted) { + if (BuildConfig.DEBUG) Log.w(TAG, "Ignoring connect from revoked client") + return@launch } evicted.forEach { authorizedClientsCache.remove(it.lowercase()) @@ -757,7 +813,8 @@ class BunkerService : Service() { // after this returns, at its own deadline or on its own checks. onConnect reports // the connects it accepted, and persists them. val result = resultRef.get() ?: return REJECTED - // The client may have been revoked while its prompt was open. + // The client may have been revoked while its prompt was open. Stopgap until the core + // re-checks the client's permission after the prompt, before it signs. if (result.approved && !isConnectRequest && runCatching { Nip46ClientStore.isDenylisted(this, pk) }.getOrDefault(true)) { return REJECTED } diff --git a/app/src/main/kotlin/io/privkey/keep/nip55/AppPermissionsScreen.kt b/app/src/main/kotlin/io/privkey/keep/nip55/AppPermissionsScreen.kt index 75a62055..00936ce8 100644 --- a/app/src/main/kotlin/io/privkey/keep/nip55/AppPermissionsScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/nip55/AppPermissionsScreen.kt @@ -26,13 +26,11 @@ import androidx.core.graphics.drawable.toBitmap import io.privkey.keep.BuildConfig import io.privkey.keep.R import io.privkey.keep.KeepMobileApp -import io.privkey.keep.nip46.BunkerConfigStore import io.privkey.keep.nip46.BunkerService import io.privkey.keep.nip46.Nip46ClientStore import io.privkey.keep.storage.SignPolicy import io.privkey.keep.storage.toSelection import io.privkey.keep.storage.toSignPolicy -import io.privkey.keep.uniffi.BunkerConfigInfo import io.privkey.keep.uniffi.SignPolicyStore import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -410,16 +408,7 @@ private fun AppPermissionsListContent( private suspend fun revokeNip46Client(context: android.content.Context, pubkey: String) { val mobile = checkNotNull((context.applicationContext as? KeepMobileApp)?.getKeepMobile()) { "Keep not initialized" } - Nip46ClientStore.addToDenylist(context, pubkey) - runCatching { Nip46ClientStore.removeClient(context, pubkey) } - .onFailure { if (BuildConfig.DEBUG) Log.e("AppPermissions", "Failed to remove NIP-46 client: ${it::class.simpleName}") } - BunkerConfigStore.update(mobile) { config -> - BunkerConfigInfo(config.enabled, config.authorizedClients.filter { it.lowercase() != pubkey.lowercase() }) - } - BunkerService.forgetPendingAuth(pubkey) - // The engine keeps the client's remembered kinds, which sign without reaching the denylist. - BunkerService.revokeClientInEngine(pubkey) - BunkerService.rotateBunkerCredentials(mobile) + BunkerService.revokeClients(context, mobile, listOf(pubkey)) } @Composable diff --git a/app/src/main/res/values/strings_connections.xml b/app/src/main/res/values/strings_connections.xml index de7651c6..41e42726 100644 --- a/app/src/main/res/values/strings_connections.xml +++ b/app/src/main/res/values/strings_connections.xml @@ -92,9 +92,9 @@ Private/internal relay addresses are not allowed No valid relay URLs found in bunker URL Revoke All Clients - This will disconnect all authorized clients and change the bunker URL, so the old URL can no longer connect. Clients will need to pair again with the new URL. + This will disconnect all authorized clients and change the bunker URL, so the old URL can no longer connect. Revoked clients stay blocked until you approve a new connection from them. Revoke Client - This client loses access, and the bunker URL changes so the old URL can no longer connect. Your other clients will need to pair again with the new URL. + This client loses access and stays blocked until you approve a new connection from it, and the bunker URL changes so the old URL can no longer connect. Your other clients will need to pair again with the new URL. Revoke Revoke All Cancel @@ -174,7 +174,7 @@ Back Disconnect Client? Disconnect App? - This will remove all saved permissions and revoke authorization for %1$s. The bunker URL also changes, so your other clients will need to pair again with the new URL. + This will remove all saved permissions and revoke authorization for %1$s, which stays blocked until you approve a new connection from it. The bunker URL also changes, so your other clients will need to pair again with the new URL. This will remove all saved permissions for %1$s. The app will need to request permission again. Disconnect Cancel diff --git a/app/src/test/kotlin/io/privkey/keep/nip46/RevokedClientSetTest.kt b/app/src/test/kotlin/io/privkey/keep/nip46/RevokedClientSetTest.kt new file mode 100644 index 00000000..20f1c0a0 --- /dev/null +++ b/app/src/test/kotlin/io/privkey/keep/nip46/RevokedClientSetTest.kt @@ -0,0 +1,36 @@ +package io.privkey.keep.nip46 + +import org.junit.Assert.assertEquals +import org.junit.Test + +class RevokedClientSetTest { + + @Test + fun aSingleRevokeCoversOnlyTheNamedClient() { + assertEquals(setOf(A), revokedClientSet(listOf(A), configured = listOf(A, B), cached = listOf(C), pending = listOf(D))) + } + + // A client can be authorized in the cache or mid-save without being in the config yet, + // and Revoke All has to reach it there too. + @Test + fun revokeAllCoversTheConfigTheCacheAndConnectsStillBeingSaved() { + assertEquals(setOf(A, B, C, D), revokedClientSet(null, configured = listOf(A, B), cached = listOf(C), pending = listOf(D))) + } + + @Test + fun pubkeysAreLowercasedAndDeduplicated() { + assertEquals(setOf(A), revokedClientSet(null, configured = listOf(A.uppercase()), cached = listOf(A), pending = emptyList())) + } + + @Test + fun revokeAllWithNoClientsCoversNothing() { + assertEquals(emptySet(), revokedClientSet(null, emptyList(), emptyList(), emptyList())) + } + + private companion object { + val A = "a".repeat(64) + val B = "b".repeat(64) + val C = "c".repeat(64) + val D = "d".repeat(64) + } +} From b5c0ce289cda5be0f4b153df4ba9d3f1e1179081 Mon Sep 17 00:00:00 2001 From: Kyle Santiago Date: Mon, 19 Oct 2026 21:49:12 -0400 Subject: [PATCH 4/4] Rotate the bunker URL even when the engine revoke fails --- .../main/kotlin/io/privkey/keep/nip46/BunkerService.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/io/privkey/keep/nip46/BunkerService.kt b/app/src/main/kotlin/io/privkey/keep/nip46/BunkerService.kt index 5b539a11..3a88a2d0 100644 --- a/app/src/main/kotlin/io/privkey/keep/nip46/BunkerService.kt +++ b/app/src/main/kotlin/io/privkey/keep/nip46/BunkerService.kt @@ -196,8 +196,13 @@ class BunkerService : Service() { runCatching { Nip46ClientStore.removeClient(context, pubkey) } .onFailure { if (BuildConfig.DEBUG) Log.e(TAG, "Failed to remove NIP-46 client: ${it::class.simpleName}") } } - if (pubkeys == null) revokeAllClientsInEngine() else revoked.forEach { revokeClientInEngine(it) } - rotateBunkerCredentials(keepMobile) + try { + if (pubkeys == null) revokeAllClientsInEngine() else revoked.forEach { revokeClientInEngine(it) } + } finally { + // The secret must change even if the engine revoke failed; the next start's + // sweep revokes any grant the engine still holds. + rotateBunkerCredentials(keepMobile) + } return saved }