app

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

commit 65a51ea626bebabf8dbe8202280ed92246cd987c
parent 28e8e76c0da75daea08dab8f11aa9276627b206e
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 20:25:52 +0000

fix(security): clear owned clipboard on disposal

- track the generated secret currently owned by the copy controller
- conditionally clear that value when the application is disposed
- preserve clipboard text replaced by the user before shutdown
- cover both disposal outcomes with deterministic coroutine tests

Diffstat:
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/SecretClipboardController.kt | 6++++++
Mapp/desktop/src/test/kotlin/org/radroots/studio/application/SecretClipboardControllerTest.kt | 18++++++++++++++++++
Mdocs/security/key-management.md | 3++-
3 files changed, 26 insertions(+), 1 deletion(-)

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 @@ -20,19 +20,25 @@ internal class SecretClipboardController( private val clearDelayMillis: Long = 60_000, ) : AutoCloseable { private var clearJob: Job? = null + private var copiedValue: String? = null fun copy(value: String) { clipboard.writeText(value) + copiedValue = value clearJob?.cancel() clearJob = scope.launch { delay(clearDelayMillis) if (clipboard.readText() == value) clipboard.writeText("") + if (copiedValue == value) copiedValue = null } } override fun close() { clearJob?.cancel() clearJob = null + val value = copiedValue + if (value != null && clipboard.readText() == value) clipboard.writeText("") + copiedValue = 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 @@ -52,6 +52,24 @@ class SecretClipboardControllerTest { assertEquals("", clipboard.value) controller.close() } + + @Test + fun disposalClearsOnlyClipboardTextOwnedByController() = runTest { + val clipboard = FakeTextClipboard() + val controller = SecretClipboardController(this, clipboard) + controller.copy("nsec1generated") + + controller.close() + + assertEquals("", clipboard.value) + + val replacedClipboard = FakeTextClipboard() + val replacedController = SecretClipboardController(this, replacedClipboard) + replacedController.copy("nsec1generated") + replacedClipboard.writeText("replacement") + replacedController.close() + assertEquals("replacement", replacedClipboard.value) + } } private class FakeTextClipboard : TextClipboard { diff --git a/docs/security/key-management.md b/docs/security/key-management.md @@ -27,7 +27,8 @@ 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. 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; +content is preserved. Application disposal performs the same conditional clear. +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.