app

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

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:
Mapp/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt | 2+-
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt | 14+++++++-------
Aapp/desktop/src/main/kotlin/org/radroots/studio/application/SecretClipboardController.kt | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/desktop/src/test/kotlin/org/radroots/studio/application/SecretClipboardControllerTest.kt | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/security/key-management.md | 5+++--
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.