commit 3359c65307c7536f43ef673bd0ec5958bc78f01a parent f2864e88dcaadc307889787df22b01503f3ac594 Author: triesap <tyson@radroots.org> Date: Wed, 12 Aug 2026 19:47:51 +0000 security: redact imported secret draft custody - replace raw import strings with bounded clearable character custody - remove generated data-class diagnostics from secret edit intents - clear displaced, cancelled, submitted, and closed presenter drafts - transfer accepted secrets once through the existing native input boundary Diffstat:
10 files changed, 231 insertions(+), 25 deletions(-)
diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleApplication.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleApplication.kt @@ -108,7 +108,7 @@ internal fun HarvestCircleApplicationWithDependencies( chooseCreateIdentity = { presenter.dispatch(HarvestCircleIntent.ChooseCreateIdentity) }, chooseImportIdentity = { presenter.dispatch(HarvestCircleIntent.ChooseImportIdentity) }, cancelIdentityEntry = { presenter.dispatch(HarvestCircleIntent.CancelIdentityEntry) }, - editImportDraft = { presenter.dispatch(HarvestCircleIntent.EditImportDraft(it)) }, + editImportDraft = { presenter.dispatch(HarvestCircleIntent.EditImportDraft.from(it)) }, generateIdentity = { presenter.dispatch(HarvestCircleIntent.GenerateIdentity) }, importSecretKey = { presenter.dispatch(HarvestCircleIntent.ImportIdentity) }, acknowledgeGeneratedKeyBackup = { presenter.dispatch(HarvestCircleIntent.AcknowledgeGeneratedRecovery) }, diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt @@ -57,12 +57,15 @@ class HarvestCirclePresenter( override fun dispatch(intent: HarvestCircleIntent) { when (intent) { - is HarvestCircleIntent.EditImportDraft -> editImportDraft(intent.value) + is HarvestCircleIntent.EditImportDraft -> editImportDraft(intent) HarvestCircleIntent.ChooseCreateIdentity -> updateState { copy(identityEntryMode = IdentityEntryMode.CREATE, problem = null) } HarvestCircleIntent.ChooseImportIdentity -> updateState { copy(identityEntryMode = IdentityEntryMode.IMPORT, problem = null) } HarvestCircleIntent.CancelIdentityEntry -> - updateState { - copy(identityEntryMode = IdentityEntryMode.CHOICE, importDraft = "", problem = null) + clearImportDraft { + copy( + identityEntryMode = IdentityEntryMode.CHOICE, + problem = null, + ) } HarvestCircleIntent.GenerateIdentity -> prepareIdentity() HarvestCircleIntent.AcknowledgeGeneratedRecovery -> acknowledgeRecovery() @@ -91,6 +94,7 @@ class HarvestCirclePresenter( commandJob?.cancelAndJoin() subscriptionJob?.cancelAndJoin() releaseRecovery() + clearImportDraft() pendingRemoval = null return try { runtime.shutdown().also { receipt -> @@ -113,13 +117,19 @@ class HarvestCirclePresenter( } } - private fun editImportDraft(value: String) { - updateState { - copy( - importDraft = value.take(MAX_IMPORT_SECRET_CHARS), - lastProblem = null, - problem = null, - ) + private fun editImportDraft(intent: HarvestCircleIntent.EditImportDraft) { + val incoming = intent.takeDraft() ?: return + if (closed) { + incoming.clear() + rejectUnavailable("The application runtime is closed.") + return + } + while (true) { + val current = mutableState.value + val updated = current.copy(importDraft = incoming, lastProblem = null, problem = null) + if (!mutableState.compareAndSet(current, updated)) continue + if (current.importDraft !== incoming) current.importDraft.clear() + return } } @@ -171,15 +181,17 @@ class HarvestCirclePresenter( val operationId = operationIds.next() launchOperation( operationId = operationId, - onAccepted = { updateState { copy(importDraft = "") } }, + onAccepted = { detachImportDraft(draft) }, ) { - val input = SecretKeyInput.from(draft) - val command = ApplicationCommand.ImportLocalIdentity(input, requestContext(operationId)) + var input: SecretKeyInput? = null try { + input = SecretKeyInput.from(draft.take()) + val command = ApplicationCommand.ImportLocalIdentity(input, requestContext(operationId)) acceptResult(runtime.execute(command), operationId) updateState { copy(identityEntryMode = IdentityEntryMode.CHOICE) } } finally { - input.clear() + input?.clear() + draft.clear() } } } @@ -503,6 +515,28 @@ class HarvestCirclePresenter( updateState { copy(generatedKeyBackup = null) } } + private fun detachImportDraft(draft: ImportSecretDraft) { + while (true) { + val current = mutableState.value + if (current.importDraft !== draft) return + if (mutableState.compareAndSet(current, current.copy(importDraft = ImportSecretDraft.empty()))) return + } + } + + private fun clearImportDraft(transform: HarvestCirclePresenterState.() -> HarvestCirclePresenterState = { this }) { + while (true) { + val current = mutableState.value + val replacement = ImportSecretDraft.empty() + val updated = current.copy(importDraft = replacement).transform() + if (!mutableState.compareAndSet(current, updated)) { + replacement.clear() + continue + } + current.importDraft.clear() + return + } + } + private fun requestContext(operationId: OperationId): RequestContext = RequestContext(operationId, state.value.snapshot.revision, COMMAND_DEADLINE_MILLIS) diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ImportSecretDraft.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ImportSecretDraft.kt @@ -0,0 +1,28 @@ +package org.harvestcircle.application + +class ImportSecretDraft private constructor( + private var characters: CharArray?, +) { + val length: Int + get() = characters?.size ?: 0 + + fun revealForDisplay(): String = characters?.concatToString().orEmpty() + + fun take(): String { + val current = checkNotNull(characters) { "Import secret draft is no longer available" } + return current.concatToString().also { clear() } + } + + fun clear() { + characters?.fill('\u0000') + characters = null + } + + override fun toString(): String = "ImportSecretDraft([REDACTED])" + + companion object { + fun empty(): ImportSecretDraft = ImportSecretDraft(CharArray(0)) + + fun from(value: String): ImportSecretDraft = ImportSecretDraft(value.take(MAX_IMPORT_SECRET_CHARS).toCharArray()) + } +} diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/PresentationModels.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/PresentationModels.kt @@ -50,7 +50,7 @@ data class IdentityRemovalConfirmation( data class HarvestCirclePresenterState( val snapshot: ApplicationSnapshot, val route: HarvestCircleRoute = snapshot.toHarvestCircleRoute(), - val importDraft: String = "", + val importDraft: ImportSecretDraft = ImportSecretDraft.empty(), val generatedKeyBackup: GeneratedKeyBackup? = null, val removalConfirmation: IdentityRemovalConfirmation? = null, val removalStatus: RemovalStatus = RemovalStatus.NONE, @@ -65,9 +65,17 @@ data class HarvestCirclePresenterState( ) sealed interface HarvestCircleIntent { - data class EditImportDraft( - val value: String, - ) : HarvestCircleIntent + class EditImportDraft private constructor( + private var draft: ImportSecretDraft?, + ) : HarvestCircleIntent { + internal fun takeDraft(): ImportSecretDraft? = draft.also { draft = null } + + override fun toString(): String = "EditImportDraft(draft=[REDACTED])" + + companion object { + fun from(value: String): EditImportDraft = EditImportDraft(ImportSecretDraft.from(value)) + } + } data object ChooseCreateIdentity : HarvestCircleIntent diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/IdentityUiModel.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/IdentityUiModel.kt @@ -7,6 +7,7 @@ import org.harvestcircle.application.HarvestCircleRoute import org.harvestcircle.application.IdentityEntryMode import org.harvestcircle.application.IdentityRemovalConfirmation import org.harvestcircle.application.IdentitySummary +import org.harvestcircle.application.ImportSecretDraft import org.harvestcircle.application.ProfileLoadState import org.harvestcircle.application.RecoveryAction import org.harvestcircle.application.RelayConnectionState @@ -52,7 +53,7 @@ data class HarvestCircleUiModel( val identities: List<IdentityUiModel>, val activeIdentity: ActiveIdentityUiModel?, val configuredRelays: List<String>, - val importDraft: String, + val importDraft: ImportSecretDraft, val generatedKeyBackup: GeneratedKeyBackupUiModel?, val removalConfirmation: IdentityRemovalConfirmation?, val removalStatus: RemovalStatus, diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapIdentityEntry.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapIdentityEntry.kt @@ -82,7 +82,7 @@ private fun ImportIdentityBody( val requester = remember { FocusRequester() } Column(Modifier.testTag("import-identity-entry"), verticalArrangement = Arrangement.spacedBy(16.dp)) { ShellTextField( - value = model.importDraft, + value = model.importDraft.revealForDisplay(), onValueChange = actions.editImportDraft, label = "Nostr secret key", placeholder = "nsec1…", diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import org.harvestcircle.identities.ui.toUiModel import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -224,19 +225,117 @@ class HarvestCirclePresenterTest { presenter.dispatch(HarvestCircleIntent.ChooseImportIdentity) assertEquals(IdentityEntryMode.IMPORT, presenter.state.value.identityEntryMode) - presenter.dispatch(HarvestCircleIntent.EditImportDraft("nsec1" + "x".repeat(200))) + presenter.dispatch(HarvestCircleIntent.EditImportDraft.from("nsec1" + "x".repeat(200))) assertEquals(MAX_IMPORT_SECRET_CHARS, presenter.state.value.importDraft.length) + val adoptedDraft = presenter.state.value.importDraft presenter.dispatch(HarvestCircleIntent.ImportIdentity) - assertEquals("", presenter.state.value.importDraft) + assertEquals( + "", + presenter.state.value.importDraft + .revealForDisplay(), + ) advanceUntilIdle() assertEquals(IdentityEntryMode.CHOICE, presenter.state.value.identityEntryMode) assertEquals(1, runtime.importedSecrets.size) assertEquals(MAX_IMPORT_SECRET_CHARS, runtime.importedSecrets.single().length) + assertFailsWith<IllegalStateException> { adoptedDraft.take() } + presenter.close() + } + + @Test + fun hcSl005ImportDraftIntentStateAndUiDiagnosticsAreRedacted() = + runTest { + val secret = "nsec1distinctive-secret" + val runtime = FakePresenterRuntime() + val presenter = presenter(runtime) + runCurrent() + val intent = HarvestCircleIntent.EditImportDraft.from(secret) + + assertEquals("EditImportDraft(draft=[REDACTED])", intent.toString()) + presenter.dispatch(intent) + + assertEquals( + "ImportSecretDraft([REDACTED])", + presenter.state.value.importDraft + .toString(), + ) + assertTrue(secret !in presenter.state.value.toString()) + assertTrue( + secret !in + presenter.state.value + .toUiModel() + .toString(), + ) + presenter.close() + } + + @Test + fun hcSl005ReplacementAndCancelClearDisplacedDrafts() = + runTest { + val runtime = FakePresenterRuntime() + val presenter = presenter(runtime) + runCurrent() + presenter.dispatch(HarvestCircleIntent.EditImportDraft.from("first-secret")) + val first = presenter.state.value.importDraft + + presenter.dispatch(HarvestCircleIntent.EditImportDraft.from("second-secret")) + val second = presenter.state.value.importDraft + assertFailsWith<IllegalStateException> { first.take() } + assertEquals("second-secret", second.revealForDisplay()) + + presenter.dispatch(HarvestCircleIntent.CancelIdentityEntry) + assertFailsWith<IllegalStateException> { second.take() } + assertEquals( + "", + presenter.state.value.importDraft + .revealForDisplay(), + ) presenter.close() } @Test + fun hcSl005BusyRejectedImportRetainsTheAdoptedDraft() = + runTest { + val gate = CompletableDeferred<Unit>() + val runtime = FakePresenterRuntime(executeGate = gate) + val presenter = presenter(runtime) + runCurrent() + presenter.dispatch(HarvestCircleIntent.EditImportDraft.from("retained-secret")) + val draft = presenter.state.value.importDraft + presenter.dispatch(HarvestCircleIntent.SignOut) + runCurrent() + + presenter.dispatch(HarvestCircleIntent.ImportIdentity) + + assertEquals(CommandStatus.REJECTED_BUSY, presenter.state.value.commandStatus) + assertTrue(presenter.state.value.importDraft === draft) + assertEquals("retained-secret", draft.revealForDisplay()) + gate.complete(Unit) + advanceUntilIdle() + presenter.close() + } + + @Test + fun hcSl005PresenterCloseClearsTheCurrentDraft() = + runTest { + val runtime = FakePresenterRuntime() + val presenter = presenter(runtime) + runCurrent() + presenter.dispatch(HarvestCircleIntent.EditImportDraft.from("close-secret")) + val draft = presenter.state.value.importDraft + + presenter.close() + + assertFailsWith<IllegalStateException> { draft.take() } + assertEquals( + "", + presenter.state.value.importDraft + .revealForDisplay(), + ) + } + + @Test fun removalCancellationReleasesTheRuntimeRequest() = runTest { val runtime = FakePresenterRuntime() diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ImportSecretDraftTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ImportSecretDraftTest.kt @@ -0,0 +1,33 @@ +package org.harvestcircle.application + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class ImportSecretDraftTest { + @Test + fun hcSl005DraftIsBoundedRedactedAndOneUse() { + val secret = "nsec1" + "x".repeat(200) + val draft = ImportSecretDraft.from(secret) + + assertEquals(MAX_IMPORT_SECRET_CHARS, draft.length) + assertEquals("ImportSecretDraft([REDACTED])", draft.toString()) + assertTrue(secret !in draft.toString()) + assertEquals(secret.take(MAX_IMPORT_SECRET_CHARS), draft.take()) + assertFailsWith<IllegalStateException> { draft.take() } + assertEquals("", draft.revealForDisplay()) + } + + @Test + fun hcSl005ClearIsIdempotentAndDestroysAvailability() { + val draft = ImportSecretDraft.from("distinctive-secret") + + draft.clear() + draft.clear() + + assertEquals(0, draft.length) + assertEquals("", draft.revealForDisplay()) + assertFailsWith<IllegalStateException> { draft.take() } + } +} diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/BootstrapIdentityEntryTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/BootstrapIdentityEntryTest.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.v2.runComposeUiTest import org.harvestcircle.application.HarvestCircleRoute import org.harvestcircle.application.IdentityEntryMode +import org.harvestcircle.application.ImportSecretDraft import org.harvestcircle.application.RecoveryAction import org.harvestcircle.application.RemovalStatus import org.harvestcircle.application.SessionLifecycle @@ -64,7 +65,7 @@ private fun model(importDraft: String = "") = identities = emptyList(), activeIdentity = null, configuredRelays = emptyList(), - importDraft = importDraft, + importDraft = ImportSecretDraft.from(importDraft), generatedKeyBackup = null, removalConfirmation = null, removalStatus = RemovalStatus.NONE, diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooserTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooserTest.kt @@ -106,7 +106,9 @@ private fun model( identities = identities, activeIdentity = null, configuredRelays = emptyList(), - importDraft = "", + importDraft = + org.harvestcircle.application.ImportSecretDraft + .empty(), generatedKeyBackup = generatedKeyBackup, removalConfirmation = null, removalStatus = RemovalStatus.NONE,