commit 184afe8c00c3d7f33873e9500e948cfd1333667e parent 3d6a327bd6141372cd00acd791d30dbacb0b17fe Author: triesap <tyson@radroots.org> Date: Tue, 11 Aug 2026 22:42:19 +0000 nostr: validate references through the native parser - Classify canonical NIP-19 references and event identifiers in Rust. - Expose typed parser results through the versioned UniFFI contract. - Inject the native parser into the shared shell presentation boundary. - Reject and clear private-key references without retaining secret state. Diffstat:
19 files changed, 428 insertions(+), 44 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 @@ -75,7 +75,10 @@ internal fun HarvestCircleApplicationWithDependencies( return } checkNotNull(clipboard) - val shellPresenter = remember(presenter, scope) { HarvestCircleShellPresenter(presenter, presenter.buildInfo, scope) } + val shellPresenter = + remember(presenter, scope) { + HarvestCircleShellPresenter(presenter, presenter.buildInfo, scope, NativeNostrReferenceParser) + } DisposableEffect(shellPresenter) { onDispose { shellPresenter.close() } } diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeNostrReferenceParser.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeNostrReferenceParser.kt @@ -0,0 +1,24 @@ +package org.harvestcircle.application + +import org.harvestcircle.ffi.NostrReferenceKindDto +import org.harvestcircle.ffi.classifyNostrReference + +object NativeNostrReferenceParser : NostrReferenceParser { + override fun parse(raw: String): NostrReferenceParseResult { + val parsed = classifyNostrReference(raw) + return NostrReferenceParseResult( + classification = + when (parsed.classification) { + NostrReferenceKindDto.INVALID -> NostrReferenceClassification.Invalid + NostrReferenceKindDto.PRIVATE_KEY_REJECTED -> NostrReferenceClassification.PrivateKeyRejected + NostrReferenceKindDto.EVENT_ID -> NostrReferenceClassification.EventId + NostrReferenceKindDto.PUBLIC_KEY -> NostrReferenceClassification.PublicKey + NostrReferenceKindDto.PROFILE -> NostrReferenceClassification.Profile + NostrReferenceKindDto.NOTE -> NostrReferenceClassification.Note + NostrReferenceKindDto.EVENT -> NostrReferenceClassification.Event + NostrReferenceKindDto.ADDRESS -> NostrReferenceClassification.Address + }, + canonicalReference = parsed.canonicalReference, + ) + } +} diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/application/NativeNostrReferenceParserTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/application/NativeNostrReferenceParserTest.kt @@ -0,0 +1,53 @@ +package org.harvestcircle.application + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class NativeNostrReferenceParserTest { + @Test + fun canonicalPublicReferencesAreClassifiedByTheNativeParser() { + for ((value, expected) in publicReferences) { + val parsed = NativeNostrReferenceParser.parse(value) + assertEquals(expected, parsed.classification) + assertTrue(parsed.canonicalReference?.isNotEmpty() == true) + } + } + + @Test + fun privateAndMalformedReferencesAreSeparatedWithoutReturningSensitiveInput() { + val private = NativeNostrReferenceParser.parse(NSEC) + assertEquals(NostrReferenceClassification.PrivateKeyRejected, private.classification) + assertNull(private.canonicalReference) + + val invalid = NativeNostrReferenceParser.parse("note1qqqqqq") + assertEquals(NostrReferenceClassification.Invalid, invalid.classification) + assertNull(invalid.canonicalReference) + } + + private companion object { + const val NSEC = "nsec1j4c6269y9w0q2er2xjw8sv2ehyrtfxq3jwgdlxj6qfn8z4gjsq5qfvfk99" + val publicReferences = + listOf( + "d94a3f4dd87b9a3b0bed183b32e916fa29c8020107845d1752d72697fe5309a5" to + NostrReferenceClassification.EventId, + "npub14f8usejl26twx0dhuxjh9cas7keav9vr0v8nvtwtrjqx3vycc76qqh9nsy" to + NostrReferenceClassification.PublicKey, + ( + "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gppemhxue69uhhytnc9e3k7mf" + + "0qyt8wumn8ghj7er2vfshxtnnv9jxkc3wvdhk6tclr7lsh" + ) to + NostrReferenceClassification.Profile, + "note1m99r7nwc0wdrkzldrqan96gklg5usqspq7z9696j6unf0ljnpxjspqfw99" to + NostrReferenceClassification.Note, + ( + "nevent1qqsdhet4232flykq3048jzc9msmaa3hnxuesxy3lnc33vd0wt9xwk6szyqewrqnkx4zsaweutf739s0cu7" + + "et29zrntqs5elw70vlm8zudr3y24sqsgy" + ) to + NostrReferenceClassification.Event, + "naddr1qqxnzd3exgersv33xymnsve3qgs8suecw4luyht9ekff89x4uacneapk8r5dyk0gmn6uwwurf6u9rusrqsqqqa282m3gxt" to + NostrReferenceClassification.Address, + ) + } +} diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt @@ -68,6 +68,7 @@ class HarvestCircleShellPresenter( private val identityPresenter: IdentityPresentationPort, buildInfo: BuildInfo, scope: CoroutineScope, + private val referenceParser: NostrReferenceParser = RejectingNostrReferenceParser, ) { private val mutableState = MutableStateFlow(HarvestCircleShellState(identityPresenter.state.value, buildInfo)) val state: StateFlow<HarvestCircleShellState> = mutableState.asStateFlow() @@ -100,6 +101,29 @@ class HarvestCircleShellPresenter( private fun dispatchOverlay(intent: OverlayIntent) { val confirmation = mutableState.value.overlays.current as? FoundationOverlay.ConfirmAction when (intent) { + is OverlayIntent.EditReference -> { + if (referenceParser.parse(intent.value).classification == NostrReferenceClassification.PrivateKeyRejected) { + applyReferenceResult(ReferenceResult.PrivateKeyRejected, clearInput = true) + return + } + } + OverlayIntent.SubmitReference -> { + val overlay = mutableState.value.overlays.current as? FoundationOverlay.OpenNostrReference ?: return + val parsed = referenceParser.parse(overlay.input) + when (parsed.classification) { + NostrReferenceClassification.Invalid -> applyReferenceResult(ReferenceResult.Invalid) + NostrReferenceClassification.PrivateKeyRejected -> + applyReferenceResult(ReferenceResult.PrivateKeyRejected, clearInput = true) + NostrReferenceClassification.EventId, + NostrReferenceClassification.PublicKey, + NostrReferenceClassification.Profile, + NostrReferenceClassification.Note, + NostrReferenceClassification.Event, + NostrReferenceClassification.Address, + -> applyReferenceResult(ReferenceResult.Unsupported) + } + return + } OverlayIntent.Confirm -> { if (mutableState.value.identity.busy) return when (confirmation?.action) { @@ -117,6 +141,13 @@ class HarvestCircleShellPresenter( reduce(ShellEvent.Overlay(intent)) } + private fun applyReferenceResult( + result: ReferenceResult, + clearInput: Boolean = false, + ) { + reduce(ShellEvent.Overlay(OverlayIntent.ApplyReferenceResult(result, clearInput))) + } + private fun reduce(event: ShellEvent) { mutableState.update { current -> ShellReducer.reduce(current, event) } } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/NostrReferenceParser.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/NostrReferenceParser.kt @@ -0,0 +1,25 @@ +package org.harvestcircle.application + +enum class NostrReferenceClassification { + Invalid, + PrivateKeyRejected, + EventId, + PublicKey, + Profile, + Note, + Event, + Address, +} + +data class NostrReferenceParseResult( + val classification: NostrReferenceClassification, + val canonicalReference: String?, +) + +fun interface NostrReferenceParser { + fun parse(raw: String): NostrReferenceParseResult +} + +object RejectingNostrReferenceParser : NostrReferenceParser { + override fun parse(raw: String): NostrReferenceParseResult = NostrReferenceParseResult(NostrReferenceClassification.Invalid, null) +} diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellOverlays.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellOverlays.kt @@ -39,6 +39,7 @@ sealed interface OverlayIntent { data class ApplyReferenceResult( val result: ReferenceResult, + val clearInput: Boolean = false, ) : OverlayIntent data object Confirm : OverlayIntent @@ -57,22 +58,18 @@ object OverlayReducer { is OverlayIntent.Open -> state.copy(current = intent.overlay) is OverlayIntent.EditReference -> state.copy(current = (state.current as? FoundationOverlay.OpenNostrReference)?.copy(input = intent.value)) - OverlayIntent.SubmitReference -> submitReference(state) - is OverlayIntent.ApplyReferenceResult -> applyReferenceResult(state, intent.result) + OverlayIntent.SubmitReference -> state + is OverlayIntent.ApplyReferenceResult -> applyReferenceResult(state, intent.result, intent.clearInput) OverlayIntent.Confirm, OverlayIntent.Close, OverlayIntent.Escape -> state.copy(current = null) } - private fun submitReference(state: OverlayState): OverlayState { - val overlay = state.current as? FoundationOverlay.OpenNostrReference ?: return state - return state.copy(current = overlay.copy(result = validateNostrReference(overlay.input))) - } - private fun applyReferenceResult( state: OverlayState, result: ReferenceResult, + clearInput: Boolean, ): OverlayState { val overlay = state.current as? FoundationOverlay.OpenNostrReference ?: return state - return state.copy(current = overlay.copy(result = result)) + return state.copy(current = overlay.copy(input = if (clearInput) "" else overlay.input, result = result)) } } @@ -80,21 +77,6 @@ enum class ReferenceResult( val message: String, ) { Invalid("This reference is not valid."), + PrivateKeyRejected("Private-key references cannot be opened."), Unsupported("This Nostr reference is not supported by this build."), } - -fun validateNostrReference(raw: String): ReferenceResult { - if (raw.isBlank() || raw.length > MAX_REFERENCE_CHARS || raw.any(Char::isISOControl)) return ReferenceResult.Invalid - val value = raw.trim() - val payload = value.removePrefix("nostr:") - val accepted = - payload.matches(Regex("[0-9a-fA-F]{64}")) || - payload.matches(Regex("(?:npub|nprofile|note|nevent|naddr)1[023456789acdefghjklmnpqrstuvwxyz]{6,}")) - return if (accepted && (value == payload || value.startsWith("nostr:"))) { - ReferenceResult.Unsupported - } else { - ReferenceResult.Invalid - } -} - -private const val MAX_REFERENCE_CHARS = 2048 diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt @@ -118,6 +118,60 @@ class HarvestCircleShellPresenterTest { assertTrue(presenter.state.value.root is ShellRoot.LifecycleCanvas) presenter.close() } + + @Test + fun nativeReferenceClassificationControlsTheSyntaxOnlyResult() = + runTest { + 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.SubmitReference)) + + assertEquals(listOf("note1candidate"), parser.inputs) + assertEquals( + FoundationOverlay.OpenNostrReference("note1candidate", ReferenceResult.Unsupported), + presenter.state.value.overlays.current, + ) + presenter.close() + } + + @Test + fun privateKeyReferencesAreRejectedAndRemovedFromShellState() = + runTest { + val privateReference = "nsec1private" + 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))) + + assertEquals(listOf(privateReference), parser.inputs) + assertEquals( + FoundationOverlay.OpenNostrReference("", ReferenceResult.PrivateKeyRejected), + presenter.state.value.overlays.current, + ) + assertTrue(privateReference !in presenter.state.value.toString()) + presenter.close() + } +} + +private class RecordingReferenceParser( + private val classification: NostrReferenceClassification, + private val canonicalReference: String?, +) : NostrReferenceParser { + val inputs = mutableListOf<String>() + + override fun parse(raw: String): NostrReferenceParseResult { + inputs += raw + return NostrReferenceParseResult(classification, canonicalReference) + } } private class FakeIdentityPresentation( diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellOverlaysTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellOverlaysTest.kt @@ -9,11 +9,22 @@ import kotlin.test.assertTrue class ShellOverlaysTest { @Test - fun acceptedReferencesRemainSyntaxOnlyAndMalformedInputFails() { - assertEquals(ReferenceResult.Unsupported, validateNostrReference("ab".repeat(32))) - assertEquals(ReferenceResult.Unsupported, validateNostrReference("nostr:note1qqqqqq")) - assertEquals(ReferenceResult.Invalid, validateNostrReference("nostr:bad reference")) - assertEquals(ReferenceResult.Invalid, validateNostrReference("note1abc\u0000")) + fun typedReferenceResultsAreAppliedWithoutParsingInTheReducer() { + val open = + OverlayState( + FoundationOverlay.OpenNostrReference(input = "public-reference"), + ) + val unchanged = OverlayReducer.reduce(open, OverlayIntent.SubmitReference) + assertEquals(open, unchanged) + val rejected = + OverlayReducer.reduce( + open, + OverlayIntent.ApplyReferenceResult(ReferenceResult.PrivateKeyRejected, clearInput = true), + ) + assertEquals( + FoundationOverlay.OpenNostrReference(input = "", result = ReferenceResult.PrivateKeyRejected), + rejected.current, + ) } @Test 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 @@ -20,6 +20,7 @@ import org.harvestcircle.application.GlobalStatusBanner import org.harvestcircle.application.OverlayIntent import org.harvestcircle.application.OverlayReducer import org.harvestcircle.application.OverlayState +import org.harvestcircle.application.ReferenceResult import org.harvestcircle.application.ShellStatusModel import org.harvestcircle.application.SignerStatusLabel import org.harvestcircle.application.SyncStatusLabel @@ -40,7 +41,15 @@ class FoundationOverlayHostTest { state, status(banner = GlobalStatusBanner("Limited connection", "Some services are unavailable.", BannerSeverity.Caution)), ) { - state = OverlayReducer.reduce(state, it) + state = + OverlayReducer.reduce( + state, + if (it == OverlayIntent.SubmitReference) { + OverlayIntent.ApplyReferenceResult(ReferenceResult.Invalid) + } else { + it + }, + ) } } onAllNodesWithTag("foundation-overlay").assertCountEquals(1) diff --git a/build-logic/contracts/src/main/kotlin/org/harvestcircle/buildlogic/contracts/FfiCompatibilityBaseline.kt b/build-logic/contracts/src/main/kotlin/org/harvestcircle/buildlogic/contracts/FfiCompatibilityBaseline.kt @@ -47,7 +47,7 @@ public class FfiCompatibilityBaseline private constructor( require(values.getValue("schema") == "harvestcircle.ffi.v4") require(values.getValue("contract.id") == "harvestcircle-desktop-ffi-v4") require(values.getValue("contract.major") == "4") - require(values.getValue("contract.minor") == "2") + require(values.getValue("contract.minor") == "3") require(values.getValue("snapshot.schema") == "1") require(values.getValue("storage.schema.minimum") == "5") require(values.getValue("storage.schema.current") == "10") diff --git a/build-logic/contracts/src/test/kotlin/org/harvestcircle/buildlogic/contracts/BuildContractsTest.kt b/build-logic/contracts/src/test/kotlin/org/harvestcircle/buildlogic/contracts/BuildContractsTest.kt @@ -77,7 +77,7 @@ class BuildContractsTest { assertFails { FfiCompatibilityBaseline.parse(ffiBaseline.replace("package.version=1.0.0", "package.version=invalid")) } assertFails { FfiCompatibilityBaseline.parse(ffiBaseline.replace("schema=harvestcircle.ffi.v4", "schema=harvestcircle.ffi.v3")) } assertFails { FfiCompatibilityBaseline.parse(ffiBaseline.replace("contract.major=4", "contract.major=3")) } - assertFails { FfiCompatibilityBaseline.parse(ffiBaseline.replace("contract.minor=2", "contract.minor=1")) } + assertFails { FfiCompatibilityBaseline.parse(ffiBaseline.replace("contract.minor=3", "contract.minor=2")) } } @Test @@ -189,7 +189,7 @@ class BuildContractsTest { schema=harvestcircle.ffi.v4 contract.id=harvestcircle-desktop-ffi-v4 contract.major=4 - contract.minor=2 + contract.minor=3 contract.hash=${"a".repeat(64)} product.coordinate_digest=${"b".repeat(64)} snapshot.schema=1 diff --git a/build-logic/plugins/src/functionalTest/kotlin/org/harvestcircle/buildlogic/plugins/ConventionPluginSmokeTest.kt b/build-logic/plugins/src/functionalTest/kotlin/org/harvestcircle/buildlogic/plugins/ConventionPluginSmokeTest.kt @@ -535,7 +535,7 @@ class ConventionPluginSmokeTest { schema=harvestcircle.ffi.v4 contract.id=harvestcircle-desktop-ffi-v4 contract.major=4 - contract.minor=2 + contract.minor=3 contract.hash=b32b9a47d12e445e93866ae0ab668b18de503ba6c999e3a053f26dc9509ddaf9 product.coordinate_digest=93bf10e334e989b20ba5fb8ed05e5d55b83f4502efba5f893aef4dc1a66c8223 snapshot.schema=1 diff --git a/core/compatibility/harvestcircle-ffi-v4.properties b/core/compatibility/harvestcircle-ffi-v4.properties @@ -1,8 +1,8 @@ schema=harvestcircle.ffi.v4 contract.id=harvestcircle-desktop-ffi-v4 contract.major=4 -contract.minor=2 -contract.hash=b32b9a47d12e445e93866ae0ab668b18de503ba6c999e3a053f26dc9509ddaf9 +contract.minor=3 +contract.hash=45e62243f3ce91b400fe7a3735ad6ad0e3f92b7a93673555fb4e2c18ba99f635 product.coordinate_digest=93bf10e334e989b20ba5fb8ed05e5d55b83f4502efba5f893aef4dc1a66c8223 snapshot.schema=1 storage.schema.minimum=5 diff --git a/core/crates/harvestcircle_ffi/build.rs b/core/crates/harvestcircle_ffi/build.rs @@ -223,7 +223,7 @@ fn validate_baseline_inputs(baseline: &BTreeMap<String, String>) { "harvestcircle-desktop-ffi-v4" ); assert_eq!(required(baseline, "contract.major"), "4"); - assert_eq!(required(baseline, "contract.minor"), "2"); + assert_eq!(required(baseline, "contract.minor"), "3"); assert_eq!(required(baseline, "snapshot.schema"), "1"); assert_eq!(required(baseline, "storage.schema.minimum"), "5"); assert_eq!(required(baseline, "storage.schema.current"), "10"); diff --git a/core/crates/harvestcircle_ffi/src/contract.rs b/core/crates/harvestcircle_ffi/src/contract.rs @@ -2,7 +2,7 @@ pub const FFI_CONTRACT_ID: &str = env!("HARVESTCIRCLE_FFI_CONTRACT_ID"); pub const PRODUCT_VERSION: &str = env!("HARVESTCIRCLE_PRODUCT_VERSION"); pub const DISTRIBUTION_PACKAGE_VERSION: &str = env!("HARVESTCIRCLE_PACKAGE_VERSION"); pub const FFI_CONTRACT_MAJOR: u16 = 4; -pub const FFI_CONTRACT_MINOR: u16 = 2; +pub const FFI_CONTRACT_MINOR: u16 = 3; pub const PRODUCT_COORDINATE_DIGEST: &str = env!("HARVESTCIRCLE_PRODUCT_COORDINATE_DIGEST"); pub const SNAPSHOT_SCHEMA_VERSION: u32 = 1; pub const MINIMUM_SCHEMA_VERSION: u32 = 5; diff --git a/core/crates/harvestcircle_ffi/src/dto.rs b/core/crates/harvestcircle_ffi/src/dto.rs @@ -6,6 +6,51 @@ use harvestcircle_domain::{ NostrIdentity, ProfileMetadata, RelayDestinationPolicy, RelayEndpoint, SafeError, SafeErrorCode, SignerAvailability, }; +use harvestcircle_nostr::{NostrReferenceKind, NostrReferenceParse}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))] +pub enum NostrReferenceKindDto { + EventId, + PublicKey, + Profile, + Note, + Event, + Address, + PrivateKeyRejected, + Invalid, +} + +impl From<NostrReferenceKind> for NostrReferenceKindDto { + fn from(value: NostrReferenceKind) -> Self { + match value { + NostrReferenceKind::EventId => Self::EventId, + NostrReferenceKind::PublicKey => Self::PublicKey, + NostrReferenceKind::Profile => Self::Profile, + NostrReferenceKind::Note => Self::Note, + NostrReferenceKind::NostrEvent => Self::Event, + NostrReferenceKind::Address => Self::Address, + NostrReferenceKind::PrivateKeyRejected => Self::PrivateKeyRejected, + NostrReferenceKind::Invalid => Self::Invalid, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] +pub struct NostrReferenceParseDto { + pub classification: NostrReferenceKindDto, + pub canonical_reference: Option<String>, +} + +impl From<NostrReferenceParse> for NostrReferenceParseDto { + fn from(value: NostrReferenceParse) -> Self { + Self { + classification: value.classification.into(), + canonical_reference: value.canonical_reference, + } + } +} #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))] diff --git a/core/crates/harvestcircle_ffi/src/lib.rs b/core/crates/harvestcircle_ffi/src/lib.rs @@ -17,10 +17,10 @@ pub use contract::{ SNAPSHOT_SCHEMA_VERSION, SOURCE_FOUNDATION_BASELINE, SOURCE_PROVENANCE_DIGEST, }; pub use dto::{ - ActiveIdentityDto, AppLifecycleDto, AppSnapshotDto, IdentityDto, ProfileDto, - ProfileLoadStateDto, RelayConnectionStateDto, RelayDestinationDto, RelayEndpointDto, - SafeErrorDto, SessionStateDto, SignerAvailabilityDto, SignerBindingKindDto, WireErrorCategory, - WireErrorCode, WireRecoveryAction, + ActiveIdentityDto, AppLifecycleDto, AppSnapshotDto, IdentityDto, NostrReferenceKindDto, + NostrReferenceParseDto, ProfileDto, ProfileLoadStateDto, RelayConnectionStateDto, + RelayDestinationDto, RelayEndpointDto, SafeErrorDto, SessionStateDto, SignerAvailabilityDto, + SignerBindingKindDto, WireErrorCategory, WireErrorCode, WireRecoveryAction, }; pub use observer::{ HarvestCircleChangeObserver, ObserverSubscription, ShutdownReceiptDto, SnapshotChangeDto, @@ -42,6 +42,12 @@ pub fn generate_operation_id_v7() -> String { .to_owned() } +#[cfg_attr(not(coverage_nightly), uniffi::export)] +#[must_use] +pub fn classify_nostr_reference(raw: String) -> NostrReferenceParseDto { + harvestcircle_nostr::classify_reference(&raw).into() +} + #[cfg(test)] #[cfg_attr(coverage_nightly, coverage(off))] mod tests { @@ -52,7 +58,7 @@ mod tests { assert_eq!(env!("CARGO_PKG_VERSION"), "0.1.0-alpha"); assert_eq!(super::FFI_CONTRACT_ID, "harvestcircle-desktop-ffi-v4"); assert_eq!(super::FFI_CONTRACT_MAJOR, 4); - assert_eq!(super::FFI_CONTRACT_MINOR, 2); + assert_eq!(super::FFI_CONTRACT_MINOR, 3); assert_eq!(super::SNAPSHOT_SCHEMA_VERSION, 1); assert_eq!( super::PRODUCT_COORDINATE_DIGEST, diff --git a/core/crates/harvestcircle_nostr/src/lib.rs b/core/crates/harvestcircle_nostr/src/lib.rs @@ -3,7 +3,9 @@ pub mod client; pub mod keys; pub mod profile; +pub mod reference; pub use client::SdkNostrClient; pub use keys::NostrKeyMaterialProvider; pub use profile::parse_verified_kind0; +pub use reference::{NostrReferenceKind, NostrReferenceParse, classify_reference}; diff --git a/core/crates/harvestcircle_nostr/src/reference.rs b/core/crates/harvestcircle_nostr/src/reference.rs @@ -0,0 +1,139 @@ +use nostr::EventId; +use nostr::nips::nip19::{FromBech32, Nip19, ToBech32}; + +const MAX_REFERENCE_BYTES: usize = 2_048; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum NostrReferenceKind { + EventId, + PublicKey, + Profile, + Note, + NostrEvent, + Address, + PrivateKeyRejected, + Invalid, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NostrReferenceParse { + pub classification: NostrReferenceKind, + pub canonical_reference: Option<String>, +} + +#[must_use] +pub fn classify_reference(raw: &str) -> NostrReferenceParse { + if raw.is_empty() + || raw.len() > MAX_REFERENCE_BYTES + || raw.chars().any(char::is_control) + || raw.trim() != raw + { + return invalid(); + } + let value = raw.strip_prefix("nostr:").unwrap_or(raw); + if value.is_empty() || value.starts_with("nostr:") { + return invalid(); + } + if value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return EventId::from_hex(value).map_or_else( + |_| invalid(), + |event_id| valid(NostrReferenceKind::EventId, event_id.to_hex()), + ); + } + match Nip19::from_bech32(value) { + Ok(Nip19::Secret(_)) => NostrReferenceParse { + classification: NostrReferenceKind::PrivateKeyRejected, + canonical_reference: None, + }, + Ok(reference @ Nip19::Pubkey(_)) => canonical(NostrReferenceKind::PublicKey, reference), + Ok(reference @ Nip19::Profile(_)) => canonical(NostrReferenceKind::Profile, reference), + Ok(reference @ Nip19::EventId(_)) => canonical(NostrReferenceKind::Note, reference), + Ok(reference @ Nip19::Event(_)) => canonical(NostrReferenceKind::NostrEvent, reference), + Ok(reference @ Nip19::Coordinate(_)) => canonical(NostrReferenceKind::Address, reference), + Err(_) => invalid(), + } +} + +fn canonical(kind: NostrReferenceKind, reference: Nip19) -> NostrReferenceParse { + reference + .to_bech32() + .map_or_else(|_| invalid(), |value| valid(kind, value)) +} + +fn valid(kind: NostrReferenceKind, canonical_reference: String) -> NostrReferenceParse { + NostrReferenceParse { + classification: kind, + canonical_reference: Some(canonical_reference), + } +} + +fn invalid() -> NostrReferenceParse { + NostrReferenceParse { + classification: NostrReferenceKind::Invalid, + canonical_reference: None, + } +} + +#[cfg(test)] +mod tests { + use super::{NostrReferenceKind, classify_reference}; + + const NPUB: &str = "npub14f8usejl26twx0dhuxjh9cas7keav9vr0v8nvtwtrjqx3vycc76qqh9nsy"; + const NSEC: &str = "nsec1j4c6269y9w0q2er2xjw8sv2ehyrtfxq3jwgdlxj6qfn8z4gjsq5qfvfk99"; + const NOTE: &str = "note1m99r7nwc0wdrkzldrqan96gklg5usqspq7z9696j6unf0ljnpxjspqfw99"; + const NPROFILE: &str = "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gppemhxue69uhhytnc9e3k7mf0qyt8wumn8ghj7er2vfshxtnnv9jxkc3wvdhk6tclr7lsh"; + const NEVENT: &str = "nevent1qqsdhet4232flykq3048jzc9msmaa3hnxuesxy3lnc33vd0wt9xwk6szyqewrqnkx4zsaweutf739s0cu7et29zrntqs5elw70vlm8zudr3y24sqsgy"; + const NADDR: &str = "naddr1qqxnzd3exgersv33xymnsve3qgs8suecw4luyht9ekff89x4uacneapk8r5dyk0gmn6uwwurf6u9rusrqsqqqa282m3gxt"; + + #[test] + fn classifies_canonical_public_references_and_exact_event_hex() { + for (value, expected) in [ + (NPUB, NostrReferenceKind::PublicKey), + (NPROFILE, NostrReferenceKind::Profile), + (NOTE, NostrReferenceKind::Note), + (NEVENT, NostrReferenceKind::NostrEvent), + (NADDR, NostrReferenceKind::Address), + ( + "d94a3f4dd87b9a3b0bed183b32e916fa29c8020107845d1752d72697fe5309a5", + NostrReferenceKind::EventId, + ), + ] { + assert_eq!(classify_reference(value).classification, expected); + assert_eq!( + classify_reference(&format!("nostr:{value}")).classification, + expected + ); + assert!(classify_reference(value).canonical_reference.is_some()); + } + } + + #[test] + fn separates_private_keys_and_rejects_noncanonical_or_unbounded_input() { + assert_eq!( + classify_reference(NSEC).classification, + NostrReferenceKind::PrivateKeyRejected + ); + assert_eq!( + classify_reference(&format!("nostr:{NSEC}")).classification, + NostrReferenceKind::PrivateKeyRejected + ); + assert!(classify_reference(NSEC).canonical_reference.is_none()); + for invalid in [ + "", + " note1m99r7nwc0wdrkzldrqan96gklg5usqspq7z9696j6unf0ljnpxjspqfw99", + "nostr:nostr:bad", + "note1qqqqqq", + "00", + "npub1bad\n", + ] { + assert_eq!( + classify_reference(invalid).classification, + NostrReferenceKind::Invalid + ); + } + assert_eq!( + classify_reference(&"a".repeat(2_049)).classification, + NostrReferenceKind::Invalid + ); + } +}