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:
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)