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:
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,
),
)
},