app

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

commit 8ccd9e1ac8a437b4ebd8989fe7f3422a0cdd8532
parent 219a5ffd234011799408f31541024e3af94d8c14
Author: triesap <tyson@radroots.org>
Date:   Wed, 12 Aug 2026 19:36:51 +0000

security: reject ambiguous hexadecimal references

- classify exact bare hexadecimal references before state and FFI
- clear ambiguous edits with typed safe presentation copy
- preserve trusted Rust parsing and the established FFI contract
- cover private, prefixed, case, whitespace, and length boundary shapes

Diffstat:
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt | 4++++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/ReferenceInputPolicy.kt | 27+++++++++++++++++++++++++++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellOverlays.kt | 5+++++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt | 2+-
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt | 22++++++++++++++++++++++
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/ReferenceInputPolicyTest.kt | 37+++++++++++++++++++++++++++++++++++++
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellOverlaysTest.kt | 13+++++++++++++
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHostTest.kt | 2+-
8 files changed, 110 insertions(+), 2 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 @@ -111,6 +111,10 @@ class HarvestCircleShellPresenter( applyReferenceResult(ReferenceResult.Invalid, clearInput = true) return } + if (admitted == ReferenceInputAdmission.AmbiguousHex) { + applyReferenceResult(ReferenceResult.AmbiguousHex, clearInput = true) + return + } val parsed = referenceParser.parse((admitted as ReferenceInputAdmission.Accepted).value) when (parsed.classification) { NostrReferenceClassification.Invalid -> applyReferenceResult(ReferenceResult.Invalid) diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ReferenceInputPolicy.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ReferenceInputPolicy.kt @@ -10,6 +10,8 @@ sealed interface ReferenceInputAdmission { data object PrivateKeyShaped : ReferenceInputAdmission data object TooLarge : ReferenceInputAdmission + + data object AmbiguousHex : ReferenceInputAdmission } object ReferenceInputPolicy { @@ -21,9 +23,22 @@ object ReferenceInputPolicy { 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 + if (hasAmbiguousHexShape(raw)) return ReferenceInputAdmission.AmbiguousHex return ReferenceInputAdmission.Accepted(raw) } + private fun hasAmbiguousHexShape(raw: String): Boolean { + var start = 0 + var end = raw.length + while (start < end && raw[start].isAsciiWhitespace()) start += 1 + while (end > start && raw[end - 1].isAsciiWhitespace()) end -= 1 + if (end - start >= NOSTR_PREFIX.length && raw.matchesAsciiIgnoreCase(start, NOSTR_PREFIX)) { + start += NOSTR_PREFIX.length + } + if (end - start != HEX_REFERENCE_LENGTH) return false + return (start until end).all { index -> raw[index].isAsciiHexDigit() } + } + private fun hasSensitivePrefix(raw: String): Boolean { val scanEnd = minOf(raw.length, MAX_SENSITIVE_PREFIX_SCAN) var prefixStart = 0 @@ -45,8 +60,20 @@ object ReferenceInputPolicy { } } +private const val HEX_REFERENCE_LENGTH = 64 +private const val NOSTR_PREFIX = "nostr:" private val PRIVATE_KEY_PREFIX = "nsec" + "1" private val PRIVATE_PREFIXES = listOf(PRIVATE_KEY_PREFIX, "nostr:" + PRIVATE_KEY_PREFIX) +private fun String.matchesAsciiIgnoreCase( + start: Int, + expected: String, +): Boolean { + if (start + expected.length > length) return false + return expected.indices.all { offset -> this[start + offset].equals(expected[offset], ignoreCase = true) } +} + +private fun Char.isAsciiHexDigit(): Boolean = this in '0'..'9' || this in 'a'..'f' || this in 'A'..'F' + 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 @@ -152,6 +152,7 @@ object OverlayReducer { 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) + ReferenceInputAdmission.AmbiguousHex -> overlay.copy(input = "", result = ReferenceResult.AmbiguousHex) } return state.updateOverlay(updated) } @@ -218,5 +219,9 @@ enum class ReferenceResult( ) { Invalid("This reference is not valid."), PrivateKeyRejected("Private-key references cannot be opened."), + AmbiguousHex( + "Bare hexadecimal references are not accepted because they can also be private keys.\n\n" + + "Use a note1 or nevent1 reference.", + ), Unsupported("This Nostr reference is not supported by this build."), } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt @@ -175,7 +175,7 @@ private fun ReferenceOverlay( ShellTextField( value = overlay.input, onValueChange = { onIntent(OverlayIntent.EditReference(it)) }, - label = "Nostr link, event ID, or address", + label = "Nostr link, note1, nevent1, or address", placeholder = "nostr:…", modifier = Modifier diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt @@ -244,6 +244,28 @@ class HarvestCircleShellPresenterTest { } @Test + fun hcSl001AmbiguousHexNeverEntersStateOrParserOnEdit() = + runTest { + val hex = "0123456789abcdef".repeat(4) + val identity = FakeIdentityPresentation(presenterState(HarvestCircleRoute.IDENTITIES)) + val parser = RecordingReferenceParser(NostrReferenceClassification.EventId, hex) + val presenter = HarvestCircleShellPresenter(identity, BuildInfo.unknown(), this, parser) + presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.OpenReference())) + + listOf(hex, hex.uppercase(), "NoStR:$hex", " \t$hex\r\n").forEach { raw -> + presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.EditReference(raw))) + assertTrue(parser.inputs.isEmpty()) + assertEquals( + FoundationOverlay.OpenNostrReference("", ReferenceResult.AmbiguousHex), + presenter.state.value.overlays.current, + ) + assertTrue(raw !in presenter.state.value.toString()) + } + + presenter.close() + } + + @Test fun oversizedReferenceNeverEntersStateOrParser() = runTest { val identity = FakeIdentityPresentation(presenterState(HarvestCircleRoute.IDENTITIES)) diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ReferenceInputPolicyTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ReferenceInputPolicyTest.kt @@ -40,6 +40,43 @@ class ReferenceInputPolicyTest { } @Test + fun hcSl001ExactHexadecimalInteractiveReferencesAreAmbiguous() { + val lowercase = "0123456789abcdef".repeat(4) + val uppercase = lowercase.uppercase() + val mixedCase = "0123456789aBcDeF".repeat(4) + val knownPrivateKey = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7" + + listOf( + knownPrivateKey, + lowercase, + uppercase, + mixedCase, + "nostr:$lowercase", + "NoStR:$mixedCase", + " \t$lowercase\r\n", + "\nNOSTR:$uppercase\t", + ).forEach { raw -> + assertSame(ReferenceInputAdmission.AmbiguousHex, ReferenceInputPolicy.admit(raw), raw) + } + } + + @Test + fun hcSl001OnlyTheExactAsciiHexadecimalShapeIsAmbiguous() { + val hex = "ab".repeat(32) + + listOf( + hex.dropLast(1), + hex + "a", + "g" + hex.drop(1), + "nostr: " + hex, + "note1candidate", + "nevent1candidate", + ).forEach { raw -> + assertIs<ReferenceInputAdmission.Accepted>(ReferenceInputPolicy.admit(raw), raw) + } + } + + @Test fun hcSc007AcceptedAdmissionAndEditIntentDoNotStringifyRawInput() { val distinctive = "distinctive-public-reference" val admission = assertIs<ReferenceInputAdmission.Accepted>(ReferenceInputPolicy.admit(distinctive)) 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,19 @@ class ShellOverlaysTest { } @Test + fun hcSl001AmbiguousHexEditClearsInputWithTypedResult() { + val hex = "01".repeat(32) + val open = shellState(FoundationOverlay.OpenNostrReference()) + val edited = OverlayReducer.transition(open, OverlayIntent.EditReference(" nostr:$hex\n")).state + + assertEquals( + FoundationOverlay.OpenNostrReference(input = "", result = ReferenceResult.AmbiguousHex), + edited.overlays.current, + ) + assertTrue(hex !in edited.toString()) + } + + @Test fun hcSc012ReferenceOpeningIsInputFreeAndGenericPrefilledIngressIsRejected() { val initial = shellState() val rejected = diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHostTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHostTest.kt @@ -59,7 +59,7 @@ class FoundationOverlayHostTest { onAllNodesWithTag("foundation-overlay").assertCountEquals(1) onNode(isDialog()).assertExists() onAllNodesWithText("Open a Nostr reference").assertCountEquals(2) - onNodeWithText("Nostr link, event ID, or address").assertExists() + onNodeWithText("Nostr link, note1, nevent1, or address").assertExists() onNodeWithText("nostr:…").assertExists() onAllNodesWithTag("global-status-banner").assertCountEquals(1) onNodeWithTag("nostr-reference-input").assertIsFocused().performTextInput("note1qqqqqq")