diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pRelayEvaluation.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pRelayEvaluation.kt new file mode 100644 index 000000000..39ce3460c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pRelayEvaluation.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.i2p + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isI2p +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost + +class I2pRelayEvaluation( + val i2pSettings: I2pRelaySettings, + val trustedRelayList: Set, + val dmRelayList: Set, +) { + fun useI2p(relay: NormalizedRelayUrl): Boolean = + if (i2pSettings.i2pType == I2pType.OFF) { + false + } else { + if (relay.isLocalHost()) { + false + } else if (relay.isI2p()) { + i2pSettings.i2pRelaysViaI2p + } else if (relay in dmRelayList) { + i2pSettings.dmRelaysViaI2p + } else if (relay in trustedRelayList) { + i2pSettings.trustedRelaysViaI2p + } else { + i2pSettings.newRelaysViaI2p + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pRelaySettings.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pRelaySettings.kt new file mode 100644 index 000000000..ee5496599 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pRelaySettings.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.i2p + +data class I2pRelaySettings( + val i2pType: I2pType = I2pType.OFF, + val i2pRelaysViaI2p: Boolean = true, + val dmRelaysViaI2p: Boolean = false, + val newRelaysViaI2p: Boolean = false, + val trustedRelaysViaI2p: Boolean = false, +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pServiceStatus.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pServiceStatus.kt new file mode 100644 index 000000000..8422d77a0 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pServiceStatus.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.i2p + +sealed class I2pServiceStatus { + data class Active( + val port: Int, + ) : I2pServiceStatus() + + data object Off : I2pServiceStatus() + + data object Connecting : I2pServiceStatus() + + data class Error( + val message: String, + ) : I2pServiceStatus() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pSettings.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pSettings.kt new file mode 100644 index 000000000..cb3524385 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pSettings.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.i2p + +// Mirror of TorSettings. Per-relay-class booleans answer "should I2P handle this +// kind of relay" — kept independent of TorSettings since the same questions can +// be answered differently for each transport. Per-feature picks (images, +// videos, etc.) live on PrivacySettings.features, not here. +data class I2pSettings( + val i2pType: I2pType = I2pType.OFF, + val externalSocksPort: Int = 4447, + val i2pRelaysViaI2p: Boolean = true, + val dmRelaysViaI2p: Boolean = false, + val newRelaysViaI2p: Boolean = false, + val trustedRelaysViaI2p: Boolean = false, +) + +enum class I2pType( + val screenCode: Int, +) { + OFF(0), + INTERNAL(1), + EXTERNAL(2), +} + +fun parseI2pType(code: Int?): I2pType = + when (code) { + I2pType.INTERNAL.screenCode -> I2pType.INTERNAL + I2pType.EXTERNAL.screenCode -> I2pType.EXTERNAL + else -> I2pType.OFF + } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/II2pManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/II2pManager.kt new file mode 100644 index 000000000..c976c0a87 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/II2pManager.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.i2p + +import kotlinx.coroutines.flow.StateFlow + +// Platform-agnostic interface for the I2P daemon lifecycle. +// Android internal mode bundles net.i2p:router; external mode just opens a SOCKS +// proxy to a user-run daemon. activePortOrNull surfaces whichever local SOCKS +// port the HTTP manager should route I2P traffic through. +interface II2pManager { + val status: StateFlow + val activePortOrNull: StateFlow + + suspend fun dormant() + + suspend fun active() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/II2pSettingsPersistence.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/II2pSettingsPersistence.kt new file mode 100644 index 000000000..db9c11aff --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/II2pSettingsPersistence.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.i2p + +interface II2pSettingsPersistence { + fun load(): I2pSettings + + fun save(settings: I2pSettings) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/FeatureRole.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/FeatureRole.kt new file mode 100644 index 000000000..a182e264e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/FeatureRole.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.privacy + +// What the network call is for. Used by PrivacyRouter to pick a transport for +// clearnet traffic (hidden-service hostnames hard-pin and ignore the role). +enum class FeatureRole { + IMAGE, + VIDEO, + URL_PREVIEW, + PROFILE_PIC, + NIP05, + MONEY, + UPLOAD, +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/FeatureTransportChoices.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/FeatureTransportChoices.kt new file mode 100644 index 000000000..17e5236b0 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/FeatureTransportChoices.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.privacy + +data class FeatureTransportChoices( + val image: TransportChoice = TransportChoice.DIRECT, + val video: TransportChoice = TransportChoice.DIRECT, + val urlPreview: TransportChoice = TransportChoice.DIRECT, + val profilePic: TransportChoice = TransportChoice.DIRECT, + val nip05: TransportChoice = TransportChoice.DIRECT, + val money: TransportChoice = TransportChoice.DIRECT, + val upload: TransportChoice = TransportChoice.DIRECT, +) { + fun choiceFor(role: FeatureRole): TransportChoice = + when (role) { + FeatureRole.IMAGE -> image + FeatureRole.VIDEO -> video + FeatureRole.URL_PREVIEW -> urlPreview + FeatureRole.PROFILE_PIC -> profilePic + FeatureRole.NIP05 -> nip05 + FeatureRole.MONEY -> money + FeatureRole.UPLOAD -> upload + } + + fun with( + role: FeatureRole, + choice: TransportChoice, + ): FeatureTransportChoices = + when (role) { + FeatureRole.IMAGE -> copy(image = choice) + FeatureRole.VIDEO -> copy(video = choice) + FeatureRole.URL_PREVIEW -> copy(urlPreview = choice) + FeatureRole.PROFILE_PIC -> copy(profilePic = choice) + FeatureRole.NIP05 -> copy(nip05 = choice) + FeatureRole.MONEY -> copy(money = choice) + FeatureRole.UPLOAD -> copy(upload = choice) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRelayEvaluation.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRelayEvaluation.kt new file mode 100644 index 000000000..6a942b8e7 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRelayEvaluation.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.privacy + +import com.vitorpamplona.amethyst.commons.i2p.I2pRelayEvaluation +import com.vitorpamplona.amethyst.commons.tor.TorRelayEvaluation +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isI2p +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion + +// Per-relay transport pick. Onion always wins for Tor, I2P always wins for I2P +// (matching PrivacyRouter's hostname pin). For non-hidden hosts, prefer I2P if +// both transports want the relay — arbitrary tiebreak documented here so the +// behavior is testable. +class PrivacyRelayEvaluation( + private val tor: TorRelayEvaluation, + private val i2p: I2pRelayEvaluation, +) { + fun transport(relay: NormalizedRelayUrl): PrivacyTransport { + if (relay.isOnion()) return if (tor.useTor(relay)) PrivacyTransport.TOR else PrivacyTransport.DIRECT + if (relay.isI2p()) return if (i2p.useI2p(relay)) PrivacyTransport.I2P else PrivacyTransport.DIRECT + return when { + i2p.useI2p(relay) -> PrivacyTransport.I2P + tor.useTor(relay) -> PrivacyTransport.TOR + else -> PrivacyTransport.DIRECT + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouter.kt new file mode 100644 index 000000000..4014d451c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouter.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.privacy + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.HiddenServiceKind +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +// Decides which PrivacyTransport carries a request. Hidden-service hostnames +// hard-pin (onion → Tor, i2p → I2P) regardless of the per-feature picker, since +// no other transport can reach them. If the pinned or chosen transport is OFF, +// downgrade to DIRECT — the caller decides whether to make the request anyway. +object PrivacyRouter { + fun route( + url: String, + role: FeatureRole, + settings: PrivacySettings, + ): PrivacyTransport = + when (RelayUrlNormalizer.classifyHidden(url)) { + HiddenServiceKind.LOCALHOST -> PrivacyTransport.DIRECT + HiddenServiceKind.ONION -> if (settings.torAvailable) PrivacyTransport.TOR else PrivacyTransport.DIRECT + HiddenServiceKind.I2P -> if (settings.i2pAvailable) PrivacyTransport.I2P else PrivacyTransport.DIRECT + HiddenServiceKind.CLEARNET -> resolve(settings.features.choiceFor(role), settings) + } + + private fun resolve( + choice: TransportChoice, + settings: PrivacySettings, + ): PrivacyTransport = + when (choice) { + TransportChoice.DIRECT -> PrivacyTransport.DIRECT + TransportChoice.TOR -> if (settings.torAvailable) PrivacyTransport.TOR else PrivacyTransport.DIRECT + TransportChoice.I2P -> if (settings.i2pAvailable) PrivacyTransport.I2P else PrivacyTransport.DIRECT + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacySettings.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacySettings.kt new file mode 100644 index 000000000..862251584 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacySettings.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.privacy + +import com.vitorpamplona.amethyst.commons.i2p.I2pSettings +import com.vitorpamplona.amethyst.commons.i2p.I2pType +import com.vitorpamplona.amethyst.commons.tor.TorSettings +import com.vitorpamplona.amethyst.commons.tor.TorType + +// Aggregate of all privacy configuration. TorSettings and I2pSettings stay +// independent (each owns its daemon-type + per-relay-class booleans, persisted +// under its own pref-key namespace). FeatureTransportChoices is the single +// source of truth for per-feature clearnet routing — UI presents one 3-way +// picker per role rather than two parallel toggles. +data class PrivacySettings( + val tor: TorSettings = TorSettings(), + val i2p: I2pSettings = I2pSettings(), + val features: FeatureTransportChoices = FeatureTransportChoices(), +) { + val torAvailable: Boolean get() = tor.torType != TorType.OFF + val i2pAvailable: Boolean get() = i2p.i2pType != I2pType.OFF +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyTransport.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyTransport.kt new file mode 100644 index 000000000..c141029b3 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyTransport.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.privacy + +enum class PrivacyTransport { + DIRECT, + TOR, + I2P, +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/TransportChoice.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/TransportChoice.kt new file mode 100644 index 000000000..a418d0d4a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/TransportChoice.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.privacy + +// UI-facing per-feature choice. Kept separate from PrivacyTransport so a future +// AUTO value can be added without churning the transport plumbing. +enum class TransportChoice( + val screenCode: Int, +) { + DIRECT(0), + TOR(1), + I2P(2), +} + +fun parseTransportChoice(code: Int?): TransportChoice = + when (code) { + TransportChoice.TOR.screenCode -> TransportChoice.TOR + TransportChoice.I2P.screenCode -> TransportChoice.I2P + else -> TransportChoice.DIRECT + } + +fun TransportChoice.toTransport(): PrivacyTransport = + when (this) { + TransportChoice.DIRECT -> PrivacyTransport.DIRECT + TransportChoice.TOR -> PrivacyTransport.TOR + TransportChoice.I2P -> PrivacyTransport.I2P + } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouterTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouterTest.kt new file mode 100644 index 000000000..8316e0353 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouterTest.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.privacy + +import com.vitorpamplona.amethyst.commons.i2p.I2pSettings +import com.vitorpamplona.amethyst.commons.i2p.I2pType +import com.vitorpamplona.amethyst.commons.tor.TorSettings +import com.vitorpamplona.amethyst.commons.tor.TorType +import kotlin.test.Test +import kotlin.test.assertEquals + +class PrivacyRouterTest { + private val clearnet = "wss://relay.damus.io/" + private val onion = "wss://abc123.onion/" + private val i2p = "wss://abc.i2p/" + private val localhost = "ws://127.0.0.1:8080/" + + private fun settings( + torOn: Boolean = false, + i2pOn: Boolean = false, + features: FeatureTransportChoices = FeatureTransportChoices(), + ) = PrivacySettings( + tor = TorSettings(torType = if (torOn) TorType.INTERNAL else TorType.OFF), + i2p = I2pSettings(i2pType = if (i2pOn) I2pType.INTERNAL else I2pType.OFF), + features = features, + ) + + @Test + fun localhost_alwaysDirect() { + assertEquals(PrivacyTransport.DIRECT, PrivacyRouter.route(localhost, FeatureRole.IMAGE, settings(torOn = true, i2pOn = true))) + } + + @Test + fun onion_pinsToTor_whenAvailable() { + assertEquals(PrivacyTransport.TOR, PrivacyRouter.route(onion, FeatureRole.IMAGE, settings(torOn = true))) + } + + @Test + fun onion_downgradesToDirect_whenTorOff() { + assertEquals(PrivacyTransport.DIRECT, PrivacyRouter.route(onion, FeatureRole.IMAGE, settings(torOn = false, i2pOn = true))) + } + + @Test + fun i2p_pinsToI2p_whenAvailable() { + assertEquals(PrivacyTransport.I2P, PrivacyRouter.route(i2p, FeatureRole.IMAGE, settings(i2pOn = true))) + } + + @Test + fun i2p_downgradesToDirect_whenI2pOff() { + assertEquals(PrivacyTransport.DIRECT, PrivacyRouter.route(i2p, FeatureRole.IMAGE, settings(torOn = true, i2pOn = false))) + } + + @Test + fun i2p_ignoresFeatureChoice() { + // Per-feature picker is irrelevant for hidden-service hosts. + val features = FeatureTransportChoices(image = TransportChoice.TOR) + assertEquals(PrivacyTransport.I2P, PrivacyRouter.route(i2p, FeatureRole.IMAGE, settings(torOn = true, i2pOn = true, features = features))) + } + + @Test + fun clearnet_followsFeatureChoice_tor() { + val features = FeatureTransportChoices(image = TransportChoice.TOR) + assertEquals(PrivacyTransport.TOR, PrivacyRouter.route(clearnet, FeatureRole.IMAGE, settings(torOn = true, features = features))) + } + + @Test + fun clearnet_followsFeatureChoice_i2p() { + val features = FeatureTransportChoices(video = TransportChoice.I2P) + assertEquals(PrivacyTransport.I2P, PrivacyRouter.route(clearnet, FeatureRole.VIDEO, settings(i2pOn = true, features = features))) + } + + @Test + fun clearnet_choiceWithoutBackingTransport_downgradesToDirect() { + val features = FeatureTransportChoices(image = TransportChoice.TOR) + assertEquals(PrivacyTransport.DIRECT, PrivacyRouter.route(clearnet, FeatureRole.IMAGE, settings(torOn = false, features = features))) + } + + @Test + fun clearnet_defaultChoice_isDirect() { + assertEquals(PrivacyTransport.DIRECT, PrivacyRouter.route(clearnet, FeatureRole.NIP05, settings(torOn = true, i2pOn = true))) + } + + @Test + fun perFeatureRouting_routesIndependently() { + val features = + FeatureTransportChoices( + image = TransportChoice.TOR, + video = TransportChoice.I2P, + urlPreview = TransportChoice.DIRECT, + ) + val s = settings(torOn = true, i2pOn = true, features = features) + assertEquals(PrivacyTransport.TOR, PrivacyRouter.route(clearnet, FeatureRole.IMAGE, s)) + assertEquals(PrivacyTransport.I2P, PrivacyRouter.route(clearnet, FeatureRole.VIDEO, s)) + assertEquals(PrivacyTransport.DIRECT, PrivacyRouter.route(clearnet, FeatureRole.URL_PREVIEW, s)) + } + + @Test + fun b32_i2p_address_pinsToI2p() { + val b32 = "wss://abcdefghijklmnopqrstuvwxyz234567.b32.i2p/" + assertEquals(PrivacyTransport.I2P, PrivacyRouter.route(b32, FeatureRole.IMAGE, settings(i2pOn = true))) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/HiddenServiceKind.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/HiddenServiceKind.kt new file mode 100644 index 000000000..aec407c61 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/HiddenServiceKind.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.normalizer + +enum class HiddenServiceKind { + CLEARNET, + LOCALHOST, + ONION, + I2P, +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt index ac3f69a1f..d35c87b2b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt @@ -46,4 +46,8 @@ fun NormalizedRelayUrl.toHttp() = fun NormalizedRelayUrl.isOnion() = url.contains(".onion/") +fun NormalizedRelayUrl.isI2p() = RelayUrlNormalizer.isI2p(this.url) + fun NormalizedRelayUrl.isLocalHost() = RelayUrlNormalizer.isLocalHost(this.url) + +fun NormalizedRelayUrl.classifyHidden(): HiddenServiceKind = RelayUrlNormalizer.classifyHidden(this.url) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt index ffd1da4d6..d7a11df76 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt @@ -48,6 +48,16 @@ class RelayUrlNormalizer { fun isOnion(url: String) = url.endsWith(".onion") || url.contains(".onion/") + fun isI2p(url: String) = url.endsWith(".i2p") || url.contains(".i2p/") + + fun classifyHidden(url: String): HiddenServiceKind = + when { + isLocalHost(url) -> HiddenServiceKind.LOCALHOST + isOnion(url) -> HiddenServiceKind.ONION + isI2p(url) -> HiddenServiceKind.I2P + else -> HiddenServiceKind.CLEARNET + } + fun isRelaySchemePrefix(url: String) = url.length > 6 && url[0] == 'w' && url[1] == 's' fun isRelaySchemePrefixSecure(url: String) = url[2] == 's' && url[3] == ':' && url[4] == '/' && url[5] == '/' && url[6] != '/' @@ -149,7 +159,7 @@ class RelayUrlNormalizer { return null } - return if (isOnion(trimmed) || isLocalHost(trimmed)) { + return if (isOnion(trimmed) || isI2p(trimmed) || isLocalHost(trimmed)) { "ws://$trimmed" } else { "wss://$trimmed"