app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 7dde759e7b44f847c58fa8df7686dfcc2d8deeaf
parent 649f3d53aebff0a24f642365fd54b1680af44c0d
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 23:25:30 +0000

application: own exclusive recovery presentation

- add a redacted single-session generated recovery controller
- clear recovery state on acknowledgement and application disposal
- return typed clipboard outcomes for unavailable native access
- make clipboard timers replacement and cleanup failure-safe

Diffstat:
Mapp/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/AccountsUiModel.kt | 8+++++---
Aapp/desktop/src/main/kotlin/org/radroots/studio/application/GeneratedRecoveryController.kt | 38++++++++++++++++++++++++++++++++++++++
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt | 2+-
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/SecretClipboardController.kt | 21+++++++++++++++++----
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt | 10++++------
Aapp/desktop/src/test/kotlin/org/radroots/studio/application/GeneratedRecoveryControllerTest.kt | 34++++++++++++++++++++++++++++++++++
Mapp/desktop/src/test/kotlin/org/radroots/studio/application/SecretClipboardControllerTest.kt | 32+++++++++++++++++++++++++++++++-
Mapp/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt | 2+-
8 files changed, 131 insertions(+), 16 deletions(-)

diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/AccountsUiModel.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/AccountsUiModel.kt @@ -32,10 +32,12 @@ data class ActiveAccountUiModel( val profile: ProfileUiModel, ) -data class GeneratedKeyBackupUiModel( +class GeneratedKeyBackupUiModel( val npub: String, val nsec: String, -) +) { + override fun toString(): String = "GeneratedKeyBackupUiModel(npub=$npub, nsec=[REDACTED])" +} data class StudioUiModel( val accounts: List<AccountUiModel>, @@ -59,7 +61,7 @@ fun StudioStoreState.toUiModel(): StudioUiModel { configuredRelays = snapshot.configuredRelays, importDraft = importDraft, generatedKeyBackup = generatedKeyBackup?.let { - GeneratedKeyBackupUiModel(npub = it.npub, nsec = it.nsec) + GeneratedKeyBackupUiModel(npub = it.npub, nsec = it.revealNsec()) }, pendingRemovalPublicKeyHex = pendingRemovalPublicKeyHex, accountChooserVisible = accountChooserVisible, diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/GeneratedRecoveryController.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/GeneratedRecoveryController.kt @@ -0,0 +1,38 @@ +package org.radroots.studio.application + +class GeneratedKeyBackup internal constructor( + val npub: String, + nsec: String, +) { + private var recoveryText: String? = nsec + + internal fun revealNsec(): String = checkNotNull(recoveryText) { + "Generated recovery material is no longer available" + } + + internal fun clear() { + recoveryText = null + } + + override fun toString(): String = "GeneratedKeyBackup(npub=$npub, nsec=[REDACTED])" +} + +internal class GeneratedRecoveryController : AutoCloseable { + private var active: GeneratedKeyBackup? = null + + fun begin(npub: String, nsec: String): GeneratedKeyBackup { + check(active == null) { "Generated-key recovery is already active" } + return GeneratedKeyBackup(npub, nsec).also { active = it } + } + + fun acknowledge(): Boolean { + val recovery = active ?: return false + recovery.clear() + active = null + return true + } + + override fun close() { + acknowledge() + } +} diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt @@ -43,7 +43,7 @@ fun RadrootsApplication( editImportDraft = store::editImportDraft, generateAccount = store::generateAccount, importSecretKey = store::importSecretKey, - copyText = clipboard::copy, + copyText = { value -> clipboard.copy(value) }, acknowledgeGeneratedKeyBackup = store::acknowledgeGeneratedKeyBackup, selectAccount = store::selectAccount, activateAccount = store::activateAccount, diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/SecretClipboardController.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/SecretClipboardController.kt @@ -14,6 +14,12 @@ internal interface TextClipboard { fun writeText(value: String) } +internal sealed interface SecretClipboardResult { + data object Copied : SecretClipboardResult + + data object Unavailable : SecretClipboardResult +} + internal class SecretClipboardController( private val scope: CoroutineScope, private val clipboard: TextClipboard = SystemTextClipboard, @@ -22,22 +28,29 @@ internal class SecretClipboardController( private var clearJob: Job? = null private var copiedValue: String? = null - fun copy(value: String) { - clipboard.writeText(value) + fun copy(value: String): SecretClipboardResult { + if (runCatching { clipboard.writeText(value) }.isFailure) { + return SecretClipboardResult.Unavailable + } copiedValue = value clearJob?.cancel() clearJob = scope.launch { delay(clearDelayMillis) - if (clipboard.readText() == value) clipboard.writeText("") + runCatching { + if (clipboard.readText() == value) clipboard.writeText("") + } if (copiedValue == value) copiedValue = null } + return SecretClipboardResult.Copied } override fun close() { clearJob?.cancel() clearJob = null val value = copiedValue - if (value != null && clipboard.readText() == value) clipboard.writeText("") + runCatching { + if (value != null && clipboard.readText() == value) clipboard.writeText("") + } copiedValue = null } } diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt @@ -8,11 +8,6 @@ import kotlinx.coroutines.launch import org.radroots.studio.ffi.AppSnapshotDto import org.radroots.studio.ffi.StudioException -data class GeneratedKeyBackup( - val npub: String, - val nsec: String, -) - data class StudioStoreState( val snapshot: AppSnapshotDto, val importDraft: String = "", @@ -29,6 +24,7 @@ class StudioAppStore( private val gateway: StudioCoreGateway, private val scope: CoroutineScope, ) : AutoCloseable { + private val generatedRecovery = GeneratedRecoveryController() private val mutableState = mutableStateOf(StudioStoreState(snapshot = gateway.snapshot())) private var closed = false private var subscription: AutoCloseable? = null @@ -65,13 +61,14 @@ class StudioAppStore( runCommand { val receipt = gateway.generateAccount() mutableState.value = mutableState.value.copy( - generatedKeyBackup = GeneratedKeyBackup(receipt.account.npub, receipt.nsec), + generatedKeyBackup = generatedRecovery.begin(receipt.account.npub, receipt.nsec), ) receipt.snapshot } } fun acknowledgeGeneratedKeyBackup() { + generatedRecovery.acknowledge() mutableState.value = mutableState.value.copy(generatedKeyBackup = null) } @@ -195,6 +192,7 @@ class StudioAppStore( command?.cancel() pendingRemoval?.close() subscription?.close() + generatedRecovery.close() gateway.close() } } diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/application/GeneratedRecoveryControllerTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/GeneratedRecoveryControllerTest.kt @@ -0,0 +1,34 @@ +package org.radroots.studio.application + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class GeneratedRecoveryControllerTest { + @Test + fun recoveryIsExclusiveRedactedAndClearedByAcknowledgement() { + val controller = GeneratedRecoveryController() + val recovery = controller.begin("npub1generated", "nsec1generated") + + assertFalse(recovery.toString().contains("nsec1generated")) + assertFailsWith<IllegalStateException> { + controller.begin("npub1other", "nsec1other") + } + assertEquals("nsec1generated", recovery.revealNsec()) + assertTrue(controller.acknowledge()) + assertFailsWith<IllegalStateException> { recovery.revealNsec() } + assertFalse(controller.acknowledge()) + } + + @Test + fun disposalClearsUnacknowledgedRecovery() { + val controller = GeneratedRecoveryController() + val recovery = controller.begin("npub1generated", "nsec1generated") + + controller.close() + + assertFailsWith<IllegalStateException> { recovery.revealNsec() } + } +} diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/application/SecretClipboardControllerTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/SecretClipboardControllerTest.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertIs @OptIn(ExperimentalCoroutinesApi::class) class SecretClipboardControllerTest { @@ -14,7 +15,7 @@ class SecretClipboardControllerTest { val clipboard = FakeTextClipboard() val controller = SecretClipboardController(this, clipboard, clearDelayMillis = 60_000) - controller.copy("nsec1generated") + assertIs<SecretClipboardResult.Copied>(controller.copy("nsec1generated")) advanceTimeBy(60_000) runCurrent() @@ -70,6 +71,21 @@ class SecretClipboardControllerTest { replacedController.close() assertEquals("replacement", replacedClipboard.value) } + + @Test + fun clipboardFailuresReturnTypedUnavailableAndNeverCrashCleanup() = runTest { + val unavailable = ThrowingTextClipboard(failWrites = true) + val controller = SecretClipboardController(this, unavailable, clearDelayMillis = 1) + assertIs<SecretClipboardResult.Unavailable>(controller.copy("nsec1generated")) + controller.close() + + val failsDuringCleanup = ThrowingTextClipboard(failReads = true) + val cleanupController = SecretClipboardController(this, failsDuringCleanup, clearDelayMillis = 1) + assertIs<SecretClipboardResult.Copied>(cleanupController.copy("nsec1generated")) + advanceTimeBy(1) + runCurrent() + cleanupController.close() + } } private class FakeTextClipboard : TextClipboard { @@ -81,3 +97,17 @@ private class FakeTextClipboard : TextClipboard { this.value = value } } + +private class ThrowingTextClipboard( + private val failReads: Boolean = false, + private val failWrites: Boolean = false, +) : TextClipboard { + override fun readText(): String? { + if (failReads) error("injected clipboard read failure") + return null + } + + override fun writeText(value: String) { + if (failWrites) error("injected clipboard write failure") + } +} diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt @@ -44,7 +44,7 @@ class StudioAppStoreTest { store.generateAccount() advanceUntilIdle() - assertEquals("nsec1secret", store.state.value.generatedKeyBackup?.nsec) + assertEquals("nsec1secret", store.state.value.generatedKeyBackup?.revealNsec()) assertEquals("npub1account", store.state.value.generatedKeyBackup?.npub) store.acknowledgeGeneratedKeyBackup() assertNull(store.state.value.generatedKeyBackup)