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