app

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

commit a9e89746e47ec3d028f3bd6afd19f1c7299c0ac1
parent 4ded537be6025767aa9b5a363006e2ec41a9860a
Author: triesap <tyson@radroots.org>
Date:   Wed, 12 Aug 2026 17:03:24 +0000

security: guard Nostr reference input before parsing

- Bound reference characters, UTF-8 bytes, and sensitive-prefix scanning.
- Reject private-shaped and oversized edits before state or native parsing.
- Replace prefilled overlay ingress with an input-free reference action.
- Redact edit diagnostics and cover public, private, and huge inputs.

Diffstat:
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt | 15+++++++++------
Aapp/shared/src/commonMain/kotlin/org/harvestcircle/application/ReferenceInputPolicy.kt | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellOverlays.kt | 35++++++++++++++++++++++++++++-------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt | 6+++---
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt | 56++++++++++++++++++++++++++++++++++++++++++++++----------
Aapp/shared/src/commonTest/kotlin/org/harvestcircle/application/ReferenceInputPolicyTest.kt | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellOverlaysTest.kt | 15+++++++++++++++
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellReducerTest.kt | 4++--
8 files changed, 206 insertions(+), 28 deletions(-)

diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt @@ -100,15 +100,18 @@ class HarvestCircleShellPresenter( private fun dispatchOverlay(intent: OverlayIntent) { when (intent) { - is OverlayIntent.EditReference -> { - if (referenceParser.parse(intent.value).classification == NostrReferenceClassification.PrivateKeyRejected) { + OverlayIntent.SubmitReference -> { + val overlay = mutableState.value.overlays.current as? FoundationOverlay.OpenNostrReference ?: return + val admitted = ReferenceInputPolicy.admit(overlay.input) + if (admitted == ReferenceInputAdmission.PrivateKeyShaped) { applyReferenceResult(ReferenceResult.PrivateKeyRejected, clearInput = true) return } - } - OverlayIntent.SubmitReference -> { - val overlay = mutableState.value.overlays.current as? FoundationOverlay.OpenNostrReference ?: return - val parsed = referenceParser.parse(overlay.input) + if (admitted == ReferenceInputAdmission.TooLarge) { + applyReferenceResult(ReferenceResult.Invalid, clearInput = true) + return + } + val parsed = referenceParser.parse((admitted as ReferenceInputAdmission.Accepted).value) when (parsed.classification) { NostrReferenceClassification.Invalid -> applyReferenceResult(ReferenceResult.Invalid) NostrReferenceClassification.PrivateKeyRejected -> diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ReferenceInputPolicy.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ReferenceInputPolicy.kt @@ -0,0 +1,52 @@ +package org.harvestcircle.application + +sealed interface ReferenceInputAdmission { + class Accepted( + val value: String, + ) : ReferenceInputAdmission { + override fun toString(): String = "Accepted(value=[REDACTED])" + } + + data object PrivateKeyShaped : ReferenceInputAdmission + + data object TooLarge : ReferenceInputAdmission +} + +object ReferenceInputPolicy { + const val MAX_REFERENCE_CHARS = 2048 + const val MAX_REFERENCE_UTF8_BYTES = 2048 + const val MAX_SENSITIVE_PREFIX_SCAN = 32 + + fun admit(raw: String): ReferenceInputAdmission { + if (raw.length > MAX_REFERENCE_CHARS) return ReferenceInputAdmission.TooLarge + if (hasSensitivePrefix(raw)) return ReferenceInputAdmission.PrivateKeyShaped + if (raw.encodeToByteArray().size > MAX_REFERENCE_UTF8_BYTES) return ReferenceInputAdmission.TooLarge + return ReferenceInputAdmission.Accepted(raw) + } + + private fun hasSensitivePrefix(raw: String): Boolean { + val scanEnd = minOf(raw.length, MAX_SENSITIVE_PREFIX_SCAN) + var prefixStart = 0 + while (prefixStart < scanEnd && raw[prefixStart].isAsciiWhitespace()) prefixStart += 1 + if (prefixStart == scanEnd) return raw.length > scanEnd + return PRIVATE_PREFIXES.any { prefix -> raw.matchesSensitivePrefix(prefixStart, scanEnd, prefix) } + } + + private fun String.matchesSensitivePrefix( + start: Int, + scanEnd: Int, + prefix: String, + ): Boolean { + val available = minOf(prefix.length, scanEnd - start) + for (offset in 0 until available) { + if (!this[start + offset].equals(prefix[offset], ignoreCase = true)) return false + } + return available == prefix.length || (start + available == scanEnd && length > scanEnd) + } +} + +private val PRIVATE_KEY_PREFIX = "nsec" + "1" +private val PRIVATE_PREFIXES = listOf(PRIVATE_KEY_PREFIX, "nostr:" + PRIVATE_KEY_PREFIX) + +private fun Char.isAsciiWhitespace(): Boolean = + this == ' ' || this == '\t' || this == '\n' || this == '\r' || this == '\u000B' || this == '\u000C' diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellOverlays.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellOverlays.kt @@ -42,9 +42,13 @@ sealed interface OverlayIntent { val overlay: FoundationOverlay, ) : OverlayIntent - data class EditReference( + data object OpenReference : OverlayIntent + + class EditReference( val value: String, - ) : OverlayIntent + ) : OverlayIntent { + override fun toString(): String = "EditReference(value=[REDACTED])" + } data object SubmitReference : OverlayIntent @@ -85,11 +89,14 @@ object OverlayReducer { intent: OverlayIntent, ): OverlayTransition = when (intent) { - is OverlayIntent.Open -> state.withOverlay(intent.overlay) - is OverlayIntent.EditReference -> - state.withOverlay( - (state.overlays.current as? FoundationOverlay.OpenNostrReference)?.copy(input = intent.value), - ) + is OverlayIntent.Open -> + if (intent.overlay is FoundationOverlay.OpenNostrReference) { + OverlayTransition(state) + } else { + state.withOverlay(intent.overlay) + } + OverlayIntent.OpenReference -> state.withOverlay(FoundationOverlay.OpenNostrReference()) + is OverlayIntent.EditReference -> applyReferenceEdit(state, intent.value) OverlayIntent.SubmitReference -> OverlayTransition(state) is OverlayIntent.ApplyReferenceResult -> applyReferenceResult(state, intent.result, intent.clearInput) is OverlayIntent.Confirm -> admitConfirmation(state, intent.action, submitting = true) @@ -111,6 +118,20 @@ object OverlayReducer { } } + private fun applyReferenceEdit( + state: HarvestCircleShellState, + raw: String, + ): OverlayTransition { + val overlay = state.overlays.current as? FoundationOverlay.OpenNostrReference ?: return OverlayTransition(state) + val updated = + when (val admission = ReferenceInputPolicy.admit(raw)) { + is ReferenceInputAdmission.Accepted -> overlay.copy(input = admission.value, result = null) + ReferenceInputAdmission.PrivateKeyShaped -> overlay.copy(input = "", result = ReferenceResult.PrivateKeyRejected) + ReferenceInputAdmission.TooLarge -> overlay.copy(input = "", result = ReferenceResult.Invalid) + } + return state.withOverlay(updated) + } + private fun admitConfirmation( state: HarvestCircleShellState, expected: ConfirmationAction, diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt @@ -171,7 +171,7 @@ private fun DashboardRoot( openNostrReference = { dispatch( HarvestCircleShellIntent.Overlay( - OverlayIntent.Open(FoundationOverlay.OpenNostrReference()), + OverlayIntent.OpenReference, ), ) }, @@ -218,7 +218,7 @@ private fun dispatchShortcut( ShellShortcut.Back -> HarvestCircleShellIntent.Navigation(NavigationIntent.Back) ShellShortcut.Forward -> HarvestCircleShellIntent.Navigation(NavigationIntent.Forward) ShellShortcut.OpenNostrReference -> - HarvestCircleShellIntent.Overlay(OverlayIntent.Open(FoundationOverlay.OpenNostrReference())) + HarvestCircleShellIntent.Overlay(OverlayIntent.OpenReference) ShellShortcut.Today -> HarvestCircleShellIntent.Navigate(ScreenKey.PersonalToday) ShellShortcut.Settings -> HarvestCircleShellIntent.Navigate(ScreenKey.Settings) ShellShortcut.CloseOverlay -> HarvestCircleShellIntent.Overlay(OverlayIntent.Escape()) @@ -243,7 +243,7 @@ private fun dispatchTopBar( GlobalTopBarIntent.Back -> HarvestCircleShellIntent.Navigation(NavigationIntent.Back) GlobalTopBarIntent.Forward -> HarvestCircleShellIntent.Navigation(NavigationIntent.Forward) GlobalTopBarIntent.OpenNostrReference -> - HarvestCircleShellIntent.Overlay(OverlayIntent.Open(FoundationOverlay.OpenNostrReference())) + HarvestCircleShellIntent.Overlay(OverlayIntent.OpenReference) GlobalTopBarIntent.ShowSyncStatus -> HarvestCircleShellIntent.Overlay(OverlayIntent.Open(FoundationOverlay.Status(StatusOverlayKey.Sync))) GlobalTopBarIntent.ShowSignerStatus -> diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt @@ -169,11 +169,9 @@ class HarvestCircleShellPresenterTest { val identity = FakeIdentityPresentation(presenterState(HarvestCircleRoute.IDENTITIES)) val parser = RecordingReferenceParser(NostrReferenceClassification.Note, "note1canonical") val presenter = HarvestCircleShellPresenter(identity, BuildInfo.unknown(), this, parser) - presenter.dispatch( - HarvestCircleShellIntent.Overlay( - OverlayIntent.Open(FoundationOverlay.OpenNostrReference("note1candidate")), - ), - ) + presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.OpenReference)) + presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.EditReference("note1candidate"))) + assertTrue(parser.inputs.isEmpty()) presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.SubmitReference)) assertEquals(listOf("note1candidate"), parser.inputs) @@ -191,12 +189,12 @@ class HarvestCircleShellPresenterTest { val identity = FakeIdentityPresentation(presenterState(HarvestCircleRoute.IDENTITIES)) val parser = RecordingReferenceParser(NostrReferenceClassification.PrivateKeyRejected, null) val presenter = HarvestCircleShellPresenter(identity, BuildInfo.unknown(), this, parser) - presenter.dispatch( - HarvestCircleShellIntent.Overlay(OverlayIntent.Open(FoundationOverlay.OpenNostrReference())), - ) - presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.EditReference(privateReference))) + presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.OpenReference)) + val edit = OverlayIntent.EditReference(privateReference) + presenter.dispatch(HarvestCircleShellIntent.Overlay(edit)) - assertEquals(listOf(privateReference), parser.inputs) + assertTrue(parser.inputs.isEmpty()) + assertEquals("EditReference(value=[REDACTED])", edit.toString()) assertEquals( FoundationOverlay.OpenNostrReference("", ReferenceResult.PrivateKeyRejected), presenter.state.value.overlays.current, @@ -204,6 +202,44 @@ class HarvestCircleShellPresenterTest { assertTrue(privateReference !in presenter.state.value.toString()) presenter.close() } + + @Test + fun oversizedReferenceNeverEntersStateOrParser() = + runTest { + val identity = FakeIdentityPresentation(presenterState(HarvestCircleRoute.IDENTITIES)) + val parser = RecordingReferenceParser(NostrReferenceClassification.Invalid, null) + val presenter = HarvestCircleShellPresenter(identity, BuildInfo.unknown(), this, parser) + presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.OpenReference)) + val oversized = "x".repeat(2 * 1024 * 1024) + presenter.dispatch( + HarvestCircleShellIntent.Overlay( + OverlayIntent.EditReference(oversized), + ), + ) + + assertTrue(parser.inputs.isEmpty()) + assertTrue(oversized !in presenter.state.value.toString()) + assertEquals( + FoundationOverlay.OpenNostrReference("", ReferenceResult.Invalid), + presenter.state.value.overlays.current, + ) + presenter.close() + } + + @Test + fun genericPrefilledReferenceOpenIsRejected() = + runTest { + val identity = FakeIdentityPresentation(presenterState(HarvestCircleRoute.IDENTITIES)) + val presenter = HarvestCircleShellPresenter(identity, BuildInfo.unknown(), this) + presenter.dispatch( + HarvestCircleShellIntent.Overlay( + OverlayIntent.Open(FoundationOverlay.OpenNostrReference("nsec1prefilled")), + ), + ) + + assertNull(presenter.state.value.overlays.current) + presenter.close() + } } private class RecordingReferenceParser( diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ReferenceInputPolicyTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ReferenceInputPolicyTest.kt @@ -0,0 +1,51 @@ +package org.harvestcircle.application + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertSame + +class ReferenceInputPolicyTest { + @Test + fun privateKeyShapesAreRejectedCaseInsensitivelyAfterAsciiWhitespace() { + listOf( + "nsec1", + "nsec1partial", + "NSEC1PARTIAL", + "nostr:nsec1partial", + " nsec1partial", + "\t\r\nNoStR:NsEc1partial", + ).forEach { raw -> + assertSame(ReferenceInputAdmission.PrivateKeyShaped, ReferenceInputPolicy.admit(raw)) + } + } + + @Test + fun ambiguousWhitespacePrefixFailsClosedWithinTheScanBudget() { + val raw = " ".repeat(ReferenceInputPolicy.MAX_SENSITIVE_PREFIX_SCAN) + "public" + assertSame(ReferenceInputAdmission.PrivateKeyShaped, ReferenceInputPolicy.admit(raw)) + } + + @Test + fun characterAndUtf8ByteLimitsAreEnforcedBeforeAdmission() { + val asciiLimit = "a".repeat(ReferenceInputPolicy.MAX_REFERENCE_CHARS) + val multibyteOverflow = "é".repeat((ReferenceInputPolicy.MAX_REFERENCE_UTF8_BYTES / 2) + 1) + + assertEquals(asciiLimit, assertIs<ReferenceInputAdmission.Accepted>(ReferenceInputPolicy.admit(asciiLimit)).value) + assertSame( + ReferenceInputAdmission.TooLarge, + ReferenceInputPolicy.admit(asciiLimit + "a"), + ) + assertSame(ReferenceInputAdmission.TooLarge, ReferenceInputPolicy.admit(multibyteOverflow)) + } + + @Test + fun acceptedAdmissionAndEditIntentDoNotStringifyRawInput() { + val distinctive = "distinctive-public-reference" + val admission = assertIs<ReferenceInputAdmission.Accepted>(ReferenceInputPolicy.admit(distinctive)) + val intent = OverlayIntent.EditReference(distinctive) + + assertEquals("Accepted(value=[REDACTED])", admission.toString()) + assertEquals("EditReference(value=[REDACTED])", intent.toString()) + } +} diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellOverlaysTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellOverlaysTest.kt @@ -26,6 +26,21 @@ class ShellOverlaysTest { } @Test + fun referenceOpeningIsInputFreeAndGenericPrefilledIngressIsRejected() { + val initial = shellState() + val rejected = + OverlayReducer + .transition( + initial, + OverlayIntent.Open(FoundationOverlay.OpenNostrReference("nsec1prefilled")), + ).state + assertEquals(initial, rejected) + + val opened = OverlayReducer.transition(initial, OverlayIntent.OpenReference).state + assertEquals(FoundationOverlay.OpenNostrReference(), opened.overlays.current) + } + + @Test fun oneTopOverlayReplacesPriorAndEscapeDoesNotTouchNavigation() { val first = OverlayReducer diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellReducerTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellReducerTest.kt @@ -27,7 +27,7 @@ class ShellReducerTest { ShellEvent.SetTheme(ThemePreference.Dark), ShellEvent.SetTextSize(TextSizePreference.VeryLarge), ShellEvent.SetMotion(MotionPreference.Reduced), - ShellEvent.Overlay(OverlayIntent.Open(FoundationOverlay.OpenNostrReference())), + ShellEvent.Overlay(OverlayIntent.OpenReference), ) val forward = events.fold(initial, ShellReducer::reduce) @@ -67,7 +67,7 @@ class ShellReducerTest { async { presenter.dispatch( HarvestCircleShellIntent.Overlay( - OverlayIntent.Open(FoundationOverlay.OpenNostrReference()), + OverlayIntent.OpenReference, ), ) },