commit 28e8e76c0da75daea08dab8f11aa9276627b206e
parent 4544abe63f25ee09fc4c779d4b4023fe86a19f9c
Author: triesap <tyson@radroots.org>
Date: Sun, 2 Aug 2026 20:24:51 +0000
fix(security): expire generated secret clipboard data
- clear copied nsec text after the approved sixty-second window
- preserve clipboard content replaced by the user before expiry
- cancel clipboard timers with the application composition lifecycle
- prevent an unacknowledged backup receipt from being displaced
Diffstat:
5 files changed, 126 insertions(+), 10 deletions(-)
diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt
@@ -163,7 +163,7 @@ private fun InactiveAccountsScreen(
text = "Generate new key",
testTag = "generate-key",
contentDescription = "Generate a new Nostr key",
- enabled = !model.busy,
+ enabled = !model.busy && model.generatedKeyBackup == null,
onClick = actions.generateAccount,
)
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
@@ -4,8 +4,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
-import java.awt.Toolkit
-import java.awt.datatransfer.StringSelection
import kotlinx.coroutines.CoroutineScope
import org.radroots.studio.accounts.ui.StudioScreen
import org.radroots.studio.accounts.ui.StudioUiActions
@@ -30,9 +28,13 @@ fun RadrootsApplication(
StartupFailureScreen(message)
return
}
+ val clipboard = remember { SecretClipboardController(scope) }
- DisposableEffect(store) {
- onDispose(store::close)
+ DisposableEffect(store, clipboard) {
+ onDispose {
+ clipboard.close()
+ store.close()
+ }
}
StudioScreen(
@@ -41,9 +43,7 @@ fun RadrootsApplication(
editImportDraft = store::editImportDraft,
generateAccount = store::generateAccount,
importSecretKey = store::importSecretKey,
- copyText = { value ->
- Toolkit.getDefaultToolkit().systemClipboard.setContents(StringSelection(value), null)
- },
+ copyText = clipboard::copy,
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
@@ -0,0 +1,50 @@
+package org.radroots.studio.application
+
+import java.awt.Toolkit
+import java.awt.datatransfer.DataFlavor
+import java.awt.datatransfer.StringSelection
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+internal interface TextClipboard {
+ fun readText(): String?
+
+ fun writeText(value: String)
+}
+
+internal class SecretClipboardController(
+ private val scope: CoroutineScope,
+ private val clipboard: TextClipboard = SystemTextClipboard,
+ private val clearDelayMillis: Long = 60_000,
+) : AutoCloseable {
+ private var clearJob: Job? = null
+
+ fun copy(value: String) {
+ clipboard.writeText(value)
+ clearJob?.cancel()
+ clearJob = scope.launch {
+ delay(clearDelayMillis)
+ if (clipboard.readText() == value) clipboard.writeText("")
+ }
+ }
+
+ override fun close() {
+ clearJob?.cancel()
+ clearJob = null
+ }
+}
+
+private object SystemTextClipboard : TextClipboard {
+ private val clipboard
+ get() = Toolkit.getDefaultToolkit().systemClipboard
+
+ override fun readText(): String? = runCatching {
+ clipboard.getData(DataFlavor.stringFlavor) as? String
+ }.getOrNull()
+
+ override fun writeText(value: String) {
+ clipboard.setContents(StringSelection(value), null)
+ }
+}
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
@@ -0,0 +1,65 @@
+package org.radroots.studio.application
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class SecretClipboardControllerTest {
+ @Test
+ fun clearsCopiedSecretAfterDelayWhenClipboardIsUnchanged() = runTest {
+ val clipboard = FakeTextClipboard()
+ val controller = SecretClipboardController(this, clipboard, clearDelayMillis = 60_000)
+
+ controller.copy("nsec1generated")
+ advanceTimeBy(60_000)
+ runCurrent()
+
+ assertEquals("", clipboard.value)
+ controller.close()
+ }
+
+ @Test
+ fun preservesClipboardContentReplacedByUserBeforeDelay() = runTest {
+ val clipboard = FakeTextClipboard()
+ val controller = SecretClipboardController(this, clipboard, clearDelayMillis = 60_000)
+
+ controller.copy("nsec1generated")
+ clipboard.writeText("replacement")
+ advanceTimeBy(60_000)
+ runCurrent()
+
+ assertEquals("replacement", clipboard.value)
+ controller.close()
+ }
+
+ @Test
+ fun replacingCopiedSecretCancelsEarlierClearTimer() = runTest {
+ val clipboard = FakeTextClipboard()
+ val controller = SecretClipboardController(this, clipboard, clearDelayMillis = 60_000)
+
+ controller.copy("nsec1first")
+ advanceTimeBy(30_000)
+ controller.copy("nsec1second")
+ advanceTimeBy(30_000)
+ runCurrent()
+ assertEquals("nsec1second", clipboard.value)
+ advanceTimeBy(30_000)
+ runCurrent()
+ assertEquals("", clipboard.value)
+ controller.close()
+ }
+}
+
+private class FakeTextClipboard : TextClipboard {
+ var value: String? = null
+
+ override fun readText(): String? = value
+
+ override fun writeText(value: String) {
+ this.value = value
+ }
+}
diff --git a/docs/security/key-management.md b/docs/security/key-management.md
@@ -25,8 +25,9 @@ non-serializable values.
Generated nsec is returned once in a direct operation receipt, displayed in
non-saveable Kotlin state, and cleared after acknowledgement or disposal.
Explicit copy places the nsec in the operating-system clipboard, which is an
-additional user-authorized exposure that the application cannot reliably
-revoke. Imported key input crosses an unavoidable JVM `String` boundary once;
+additional user-authorized exposure. A lifecycle-owned timer clears it after
+60 seconds only if the clipboard still contains the copied value; user-replaced
+content is preserved. Imported key input crosses an unavoidable JVM `String` boundary once;
the masked Compose draft is cleared immediately when the command is accepted,
before the native coroutine executes. The in-flight JVM argument cannot be
guaranteed zeroized and is never logged or added to public state.