app

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

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:
Aapp/desktop/src/main/kotlin/org/radroots/studio/application/GeneratedKeyBackup.kt | 19+++++++++++++++++++
Dapp/desktop/src/main/kotlin/org/radroots/studio/application/GeneratedRecoveryController.kt | 42------------------------------------------
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt | 85++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/StudioCoreGateway.kt | 80++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Aapp/desktop/src/test/kotlin/org/radroots/studio/application/GeneratedKeyBackupTest.kt | 18++++++++++++++++++
Dapp/desktop/src/test/kotlin/org/radroots/studio/application/GeneratedRecoveryControllerTest.kt | 34----------------------------------
Mapp/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
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 {