commit 62bb087a0342004de85a0e65de24bf4aabe71dcb
parent db2b97a87c392afbd0f5b8eceacf4a2ec03c240a
Author: triesap <tyson@radroots.org>
Date: Tue, 4 Aug 2026 08:02:23 +0000
accounts: harden generated recovery ownership
- represent pending recovery as one ticket and backup pair
- cancel partial acquisition in a non-cancellable cleanup boundary
- clear one-shot recovery material after acknowledgement or cancellation
- correlate and sanitize failures crossing the native gateway
Diffstat:
7 files changed, 266 insertions(+), 121 deletions(-)
diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/GeneratedKeyBackup.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/GeneratedKeyBackup.kt
@@ -0,0 +1,19 @@
+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])"
+}
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
@@ -1,42 +0,0 @@
-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/StudioAppStore.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt
@@ -5,7 +5,9 @@ import androidx.compose.runtime.mutableStateOf
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
+import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import org.radroots.studio.ffi.AppLifecycleDto
import org.radroots.studio.ffi.AppSnapshotDto
import org.radroots.studio.ffi.StudioException
@@ -83,12 +85,11 @@ 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
private var pendingRemoval: RemovalTicket? = null
- private var pendingGeneratedRecovery: GeneratedRecoveryTicket? = null
+ private var pendingGeneratedRecovery: PendingGeneratedRecovery? = null
private var command: Job? = null
private var retryableCommand: StudioCommand? = null
@@ -142,15 +143,20 @@ class StudioAppStore(
fun generateAccount() {
launchCommand {
val recovery = gateway.beginGeneratedAccount()
- pendingGeneratedRecovery = recovery
- mutableState.value =
- mutableState.value.copy(
- generatedKeyBackup =
- generatedRecovery.begin(
- recovery.account.npub,
- recovery.takeRecoveryNsec(),
- ),
- )
+ var installed = false
+ try {
+ val backup = GeneratedKeyBackup(recovery.account.npub, recovery.takeRecoveryNsec())
+ pendingGeneratedRecovery = PendingGeneratedRecovery(recovery, backup)
+ mutableState.value = mutableState.value.copy(generatedKeyBackup = backup)
+ installed = true
+ } finally {
+ if (!installed) {
+ withContext(NonCancellable) {
+ runCatching { recovery.cancel() }
+ recovery.close()
+ }
+ }
+ }
}
}
@@ -160,15 +166,11 @@ class StudioAppStore(
rejectUnavailableIntent("Generated-key recovery is not available.")
return
}
- pendingGeneratedRecovery = null
runSnapshotCommand {
try {
- recovery.acknowledge().also {
- generatedRecovery.acknowledge()
- mutableState.value = mutableState.value.copy(generatedKeyBackup = null)
- }
+ recovery.ticket.acknowledge()
} finally {
- recovery.close()
+ releaseGeneratedRecovery(recovery)
}
}
}
@@ -179,14 +181,22 @@ class StudioAppStore(
rejectUnavailableIntent("Generated-key recovery is not available.")
return
}
- pendingGeneratedRecovery = null
launchCommand {
try {
- recovery.cancel()
- generatedRecovery.acknowledge()
- mutableState.value = mutableState.value.copy(generatedKeyBackup = null)
+ if (!recovery.ticket.cancel()) {
+ throw StudioGatewayException(
+ StudioCommandFailure(
+ code = WireErrorCode.INVALID_APPLICATION_STATE,
+ category = org.radroots.studio.ffi.WireErrorCategory.LIFECYCLE,
+ retryable = false,
+ recoveryAction = WireRecoveryAction.NONE,
+ correlationId = recovery.ticket.requestId,
+ safeMessage = "The generated-key recovery step was already closed.",
+ ),
+ )
+ }
} finally {
- recovery.close()
+ releaseGeneratedRecovery(recovery)
}
}
}
@@ -426,30 +436,42 @@ class StudioAppStore(
private fun acceptFailure(error: Throwable) {
val native = error as? StudioException.Failure
+ val gatewayFailure = (error as? StudioGatewayException)?.failure
mutableState.value =
mutableState.value.copy(
busy = false,
commandStatus =
- if (native?.retryable == true) {
+ if (native?.retryable == true || gatewayFailure?.retryable == true) {
CommandStatus.FAILED_RETRYABLE
} else {
CommandStatus.FAILED_TERMINAL
},
- lastCommandRequestId = native?.correlationId,
- lastFailureCode = native?.code,
- recoveryAction = native?.recoveryAction ?: WireRecoveryAction.NONE,
- problem = native?.safeMessage ?: "The application command failed.",
+ lastCommandRequestId = gatewayFailure?.correlationId ?: native?.correlationId,
+ lastFailureCode = gatewayFailure?.code ?: native?.code,
+ recoveryAction =
+ gatewayFailure?.recoveryAction
+ ?: native?.recoveryAction
+ ?: WireRecoveryAction.NONE,
+ problem = gatewayFailure?.safeMessage ?: native?.safeMessage ?: "The application command failed.",
)
}
+ private fun releaseGeneratedRecovery(recovery: PendingGeneratedRecovery) {
+ if (pendingGeneratedRecovery === recovery) {
+ pendingGeneratedRecovery = null
+ }
+ recovery.backup.clear()
+ recovery.ticket.close()
+ mutableState.value = mutableState.value.copy(generatedKeyBackup = null)
+ }
+
override fun close() {
if (closed) return
closed = true
command?.cancel()
pendingRemoval?.close()
- pendingGeneratedRecovery?.close()
+ pendingGeneratedRecovery?.let(::releaseGeneratedRecovery)
subscription?.close()
- generatedRecovery.close()
runCatching { gateway.shutdown() }
.onSuccess { receipt ->
mutableState.value =
@@ -465,6 +487,11 @@ class StudioAppStore(
}
}
+private data class PendingGeneratedRecovery(
+ val ticket: GeneratedRecoveryTicket,
+ val backup: GeneratedKeyBackup,
+)
+
internal fun AppSnapshotDto.toStudioRoute(): StudioRoute =
when (lifecycle) {
AppLifecycleDto.OPENING -> StudioRoute.OPENING
diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioCoreGateway.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioCoreGateway.kt
@@ -25,6 +25,7 @@ interface RemovalTicket : AutoCloseable {
}
interface GeneratedRecoveryTicket : AutoCloseable {
+ val requestId: String
val account: AccountDto
fun takeRecoveryNsec(): String
@@ -154,8 +155,27 @@ class NativeStudioCoreGateway(
override suspend fun bootstrap(): AppSnapshotDto = core.bootstrap()
- override suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket =
- NativeGeneratedRecoveryTicket(core, core.beginGeneratedAccountV2())
+ override suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket {
+ val requestId = nextRequestId()
+ return try {
+ val request = core.beginGeneratedAccountV2()
+ try {
+ NativeGeneratedRecoveryTicket(core, request, requestId, request.account())
+ } catch (error: Exception) {
+ request.close()
+ throw error
+ }
+ } catch (error: CancellationException) {
+ throw error
+ } catch (error: Exception) {
+ throw StudioGatewayException(
+ error.toStudioCommandFailure(
+ requestId,
+ "The generated key could not be prepared.",
+ ),
+ )
+ }
+ }
override suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket =
NativeRemovalTicket(core.requestAccountRemoval(publicKeyHex))
@@ -183,13 +203,22 @@ class NativeStudioCoreGateway(
private fun requestContext(): RequestContextDto =
RequestContextDto(
- requestId = "kotlin:${nextRequest.getAndIncrement()}",
+ requestId = nextRequestId(),
expectedRevision = core.snapshot().revision,
deadlineMillis = 30_000UL,
)
+
+ private fun nextRequestId(): String = "kotlin:${nextRequest.getAndIncrement()}"
}
-internal fun Throwable.toStudioCommandFailure(fallbackCorrelationId: String): StudioCommandFailure {
+internal class StudioGatewayException(
+ val failure: StudioCommandFailure,
+) : Exception(failure.safeMessage)
+
+internal fun Throwable.toStudioCommandFailure(
+ fallbackCorrelationId: String,
+ fallbackSafeMessage: String = "The application command failed.",
+): StudioCommandFailure {
val native = this as? StudioException.Failure
return StudioCommandFailure(
code = native?.code ?: WireErrorCode.INTERNAL,
@@ -197,7 +226,7 @@ internal fun Throwable.toStudioCommandFailure(fallbackCorrelationId: String): St
retryable = native?.retryable ?: false,
recoveryAction = native?.recoveryAction ?: WireRecoveryAction.NONE,
correlationId = native?.correlationId ?: fallbackCorrelationId,
- safeMessage = native?.safeMessage ?: "The application command failed.",
+ safeMessage = native?.safeMessage ?: fallbackSafeMessage,
)
}
@@ -226,17 +255,46 @@ private class NativeRemovalTicket(
private class NativeGeneratedRecoveryTicket(
private val core: StudioAppCore,
private val request: GeneratedRecoveryRequest,
+ override val requestId: String,
+ override val account: AccountDto,
) : GeneratedRecoveryTicket {
- override val account: AccountDto = request.account()
-
- override fun takeRecoveryNsec(): String = request.takeRecoveryNsec()
+ override fun takeRecoveryNsec(): String =
+ try {
+ request.takeRecoveryNsec()
+ } catch (error: Exception) {
+ throw StudioGatewayException(
+ error.toStudioCommandFailure(
+ requestId,
+ "The generated recovery key could not be read.",
+ ),
+ )
+ }
- override suspend fun acknowledge(): AppSnapshotDto = core.acknowledgeGeneratedAccountV2(request)
+ override suspend fun acknowledge(): AppSnapshotDto =
+ call("The generated account could not be saved.") {
+ core.acknowledgeGeneratedAccountV2(request)
+ }
- override suspend fun cancel(): Boolean = core.cancelGeneratedAccountV2(request)
+ override suspend fun cancel(): Boolean =
+ call("The generated key could not be cancelled safely.") {
+ core.cancelGeneratedAccountV2(request)
+ }
override fun close() {
- runBlocking { runCatching { cancel() } }
request.close()
}
+
+ private suspend fun <T> call(
+ fallbackSafeMessage: String,
+ operation: suspend () -> T,
+ ): T =
+ try {
+ operation()
+ } catch (error: CancellationException) {
+ throw error
+ } catch (error: Exception) {
+ throw StudioGatewayException(
+ error.toStudioCommandFailure(requestId, fallbackSafeMessage),
+ )
+ }
}
diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/application/GeneratedKeyBackupTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/GeneratedKeyBackupTest.kt
@@ -0,0 +1,18 @@
+package org.radroots.studio.application
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+
+class GeneratedKeyBackupTest {
+ @Test
+ fun recoveryIsRedactedAndCanBeCleared() {
+ val recovery = GeneratedKeyBackup("npub1generated", "nsec1generated")
+
+ assertFalse(recovery.toString().contains("nsec1generated"))
+ assertEquals("nsec1generated", recovery.revealNsec())
+ recovery.clear()
+ assertFailsWith<IllegalStateException> { recovery.revealNsec() }
+ }
+}
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
@@ -1,34 +0,0 @@
-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/StudioAppStoreTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt
@@ -85,6 +85,69 @@ class StudioAppStoreTest {
}
@Test
+ fun `partial generated recovery acquisition cancels and closes its native ticket`() =
+ runTest {
+ val gateway =
+ FakeStudioCoreGateway(snapshot(0UL)).apply {
+ failGeneratedRecoveryRead = true
+ }
+ val store = StudioAppStore(gateway, this)
+ advanceUntilIdle()
+
+ store.generateAccount()
+ advanceUntilIdle()
+
+ assertNull(store.state.value.generatedKeyBackup)
+ assertEquals(1, gateway.lastGeneratedRecoveryTicket?.cancelCalls)
+ assertTrue(gateway.lastGeneratedRecoveryTicket?.closed == true)
+ store.close()
+ }
+
+ @Test
+ fun `failed generated acknowledgement releases one-shot recovery ownership`() =
+ runTest {
+ val gateway =
+ FakeStudioCoreGateway(snapshot(0UL)).apply {
+ failGeneratedAcknowledgement = true
+ }
+ val store = StudioAppStore(gateway, this)
+ advanceUntilIdle()
+ store.generateAccount()
+ advanceUntilIdle()
+
+ store.acknowledgeGeneratedKeyBackup()
+ advanceUntilIdle()
+
+ assertNull(store.state.value.generatedKeyBackup)
+ assertTrue(gateway.lastGeneratedRecoveryTicket?.closed == true)
+ assertEquals("fake-generated-request", store.state.value.lastCommandRequestId)
+ assertEquals("The generated account could not be saved.", store.state.value.problem)
+ store.close()
+ }
+
+ @Test
+ fun `already resolved cancellation clears recovery and reports the state mismatch`() =
+ runTest {
+ val gateway =
+ FakeStudioCoreGateway(snapshot(0UL)).apply {
+ generatedCancellationResult = false
+ }
+ val store = StudioAppStore(gateway, this)
+ advanceUntilIdle()
+ store.generateAccount()
+ advanceUntilIdle()
+
+ store.cancelGeneratedKeyBackup()
+ advanceUntilIdle()
+
+ assertNull(store.state.value.generatedKeyBackup)
+ assertEquals(WireErrorCode.INVALID_APPLICATION_STATE, store.state.value.lastFailureCode)
+ assertEquals("fake-generated-request", store.state.value.lastCommandRequestId)
+ assertTrue(gateway.lastGeneratedRecoveryTicket?.closed == true)
+ store.close()
+ }
+
+ @Test
fun `ignores observer delivery after close`() =
runTest {
val gateway = FakeStudioCoreGateway(snapshot(0UL))
@@ -259,6 +322,10 @@ private class FakeStudioCoreGateway(
var failRemovalConfirmation = false
var lastRemovalTicket: FakeRemovalTicket? = null
var nextCommandResult: StudioCommandResult? = null
+ var failGeneratedRecoveryRead = false
+ var failGeneratedAcknowledgement = false
+ var generatedCancellationResult = true
+ var lastGeneratedRecoveryTicket: FakeGeneratedRecoveryTicket? = null
override fun snapshot(): AppSnapshotDto = current
@@ -294,11 +361,16 @@ private class FakeStudioCoreGateway(
override suspend fun bootstrap(): AppSnapshotDto = bootstrapSnapshot.also(::emit)
override suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket =
- FakeGeneratedRecoveryTicket(account()) { committed ->
+ FakeGeneratedRecoveryTicket(
+ account = account(),
+ failRecoveryRead = failGeneratedRecoveryRead,
+ failAcknowledgement = failGeneratedAcknowledgement,
+ cancellationResult = generatedCancellationResult,
+ ) { committed ->
current = snapshot(current.revision + 1UL)
emit(current)
committed(current)
- }
+ }.also { lastGeneratedRecoveryTicket = it }
override suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket = FakeRemovalTicket().also { lastRemovalTicket = it }
@@ -320,22 +392,49 @@ private class FakeStudioCoreGateway(
private class FakeGeneratedRecoveryTicket(
override val account: AccountDto,
+ private val failRecoveryRead: Boolean,
+ private val failAcknowledgement: Boolean,
+ private val cancellationResult: Boolean,
private val commit: (((AppSnapshotDto) -> Unit) -> Unit),
) : GeneratedRecoveryTicket {
+ override val requestId: String = "fake-generated-request"
private var available = true
+ var cancelCalls = 0
+ var closed = false
- override fun takeRecoveryNsec(): String = "nsec1secret"
+ override fun takeRecoveryNsec(): String {
+ if (failRecoveryRead) error("injected recovery read failure")
+ return "nsec1secret"
+ }
override suspend fun acknowledge(): AppSnapshotDto {
+ if (failAcknowledgement) {
+ available = false
+ throw StudioGatewayException(
+ StudioCommandFailure(
+ WireErrorCode.KEYRING_UNAVAILABLE,
+ WireErrorCategory.CREDENTIAL,
+ retryable = true,
+ WireRecoveryAction.RETRY,
+ requestId,
+ "The generated account could not be saved.",
+ ),
+ )
+ }
lateinit var snapshot: AppSnapshotDto
commit { snapshot = it }
available = false
return snapshot
}
- override suspend fun cancel(): Boolean = available.also { available = false }
+ override suspend fun cancel(): Boolean {
+ cancelCalls += 1
+ return (available && cancellationResult).also { available = false }
+ }
- override fun close() = Unit
+ override fun close() {
+ closed = true
+ }
}
private class FakeRemovalTicket : RemovalTicket {