refactor(privacy): single preferred clearnet transport, fail-closed hidden services

Drop the per-feature 3-way picker — no splitting clearnet traffic between Tor
and I2P at the same time. Both daemons can still run side-by-side so .onion
and .i2p hidden services stay reachable independently, but only one transport
carries clearnet traffic at a time.

Routing model:
- .onion → Tor required; Blocked when Tor is OFF (no clearnet fallback)
- .i2p   → I2P required; Blocked when I2P is OFF (no clearnet fallback)
- clearnet → preferredClearnetTransport (NONE/TOR/I2P) picks the active
  transport; that transport's own per-feature toggle decides this request;
  otherwise Direct

Commons:
- Add PrivacyRoute sealed type { Direct, Tor, I2p, Blocked(BlockReason) } so
  fail-closed has somewhere to land — callers must surface Blocked instead of
  silently leaking over clearnet
- PrivacySettings drops `features`, adds preferredClearnetTransport
- I2pSettings gains imagesViaI2p / videosViaI2p / urlPreviewsViaI2p /
  profilePicsViaI2p / nip05VerificationsViaI2p / moneyOperationsViaI2p /
  mediaUploadsViaI2p — mirrors TorSettings; only effective when I2P is the
  preferred clearnet transport
- PrivacyRouter rewritten to the new model
- Delete FeatureTransportChoices and TransportChoice
- Keep FeatureRole as the per-request hint that selects which toggle to read

Tests: PrivacyRouterTest rewritten to cover the new outcomes, including
both-daemons-running clearnet preference, fail-closed for .onion / .i2p, and
that the non-preferred transport's toggles have no effect on clearnet.
This commit is contained in:
Claude
2026-05-16 20:06:35 +00:00
parent fb2f05d9cb
commit a011421ff5
7 changed files with 224 additions and 161 deletions
@@ -20,10 +20,11 @@
*/
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.
// Shape mirrors TorSettings. Both daemons can be enabled side-by-side (needed so
// .onion and .i2p hidden services stay reachable independently), but only one
// transport carries clearnet at a time — see PrivacySettings.preferredClearnetTransport.
// The per-feature booleans here only take effect when I2P is the preferred clearnet
// transport; otherwise the matching TorSettings flag wins.
data class I2pSettings(
val i2pType: I2pType = I2pType.OFF,
val externalSocksPort: Int = 4447,
@@ -31,6 +32,13 @@ data class I2pSettings(
val dmRelaysViaI2p: Boolean = false,
val newRelaysViaI2p: Boolean = false,
val trustedRelaysViaI2p: Boolean = false,
val urlPreviewsViaI2p: Boolean = false,
val profilePicsViaI2p: Boolean = false,
val imagesViaI2p: Boolean = false,
val videosViaI2p: Boolean = false,
val moneyOperationsViaI2p: Boolean = false,
val nip05VerificationsViaI2p: Boolean = false,
val mediaUploadsViaI2p: Boolean = false,
)
enum class I2pType(
@@ -20,8 +20,10 @@
*/
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).
// What the network call is for. PrivacyRouter uses this to look up the matching
// per-feature toggle on whichever transport is preferred for clearnet
// (TorSettings.imagesViaTor vs I2pSettings.imagesViaI2p, etc.). Hidden-service
// hostnames ignore the role and hard-pin to their matching network.
enum class FeatureRole {
IMAGE,
VIDEO,
@@ -1,56 +0,0 @@
/*
* 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)
}
}
@@ -20,26 +20,24 @@
*/
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),
// Outcome of PrivacyRouter.route. Either an actionable transport, or Blocked
// when a hidden-service hostname cannot be reached (matching daemon is OFF)
// and we fail closed rather than leak the request over the clearnet.
sealed interface PrivacyRoute {
data object Direct : PrivacyRoute
data object Tor : PrivacyRoute
data object I2p : PrivacyRoute
// Hidden-service request whose matching daemon is disabled. Callers must
// surface this as a visible failure — don't fall back to DIRECT.
data class Blocked(
val reason: BlockReason,
) : PrivacyRoute
}
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
}
enum class BlockReason {
ONION_REQUIRES_TOR,
I2P_REQUIRES_I2P,
}
@@ -20,33 +20,74 @@
*/
package com.vitorpamplona.amethyst.commons.privacy
import com.vitorpamplona.amethyst.commons.i2p.I2pSettings
import com.vitorpamplona.amethyst.commons.tor.TorSettings
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.
// Decides how a request is routed.
//
// Hidden-service hostnames hard-pin to their matching network — .onion needs
// Tor, .i2p needs I2P — and fail closed (Blocked) when that daemon is OFF
// rather than leak the request over the clearnet.
//
// For clearnet hostnames there is exactly one active privacy transport at a
// time (PrivacySettings.preferredClearnetTransport). Per-feature toggles on
// that transport's own settings (TorSettings.*ViaTor or I2pSettings.*ViaI2p)
// decide whether this particular request is routed through it; otherwise the
// request goes Direct.
object PrivacyRouter {
fun route(
url: String,
role: FeatureRole,
settings: PrivacySettings,
): PrivacyTransport =
): PrivacyRoute =
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)
HiddenServiceKind.LOCALHOST -> PrivacyRoute.Direct
HiddenServiceKind.ONION ->
if (settings.torAvailable) PrivacyRoute.Tor else PrivacyRoute.Blocked(BlockReason.ONION_REQUIRES_TOR)
HiddenServiceKind.I2P ->
if (settings.i2pAvailable) PrivacyRoute.I2p else PrivacyRoute.Blocked(BlockReason.I2P_REQUIRES_I2P)
HiddenServiceKind.CLEARNET -> clearnet(role, settings)
}
private fun resolve(
choice: TransportChoice,
private fun clearnet(
role: FeatureRole,
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
): PrivacyRoute =
when (settings.preferredClearnetTransport) {
PrivacyTransport.TOR ->
if (settings.torAvailable && torEnables(role, settings.tor)) PrivacyRoute.Tor else PrivacyRoute.Direct
PrivacyTransport.I2P ->
if (settings.i2pAvailable && i2pEnables(role, settings.i2p)) PrivacyRoute.I2p else PrivacyRoute.Direct
PrivacyTransport.DIRECT -> PrivacyRoute.Direct
}
private fun torEnables(
role: FeatureRole,
tor: TorSettings,
): Boolean =
when (role) {
FeatureRole.IMAGE -> tor.imagesViaTor
FeatureRole.VIDEO -> tor.videosViaTor
FeatureRole.URL_PREVIEW -> tor.urlPreviewsViaTor
FeatureRole.PROFILE_PIC -> tor.profilePicsViaTor
FeatureRole.NIP05 -> tor.nip05VerificationsViaTor
FeatureRole.MONEY -> tor.moneyOperationsViaTor
FeatureRole.UPLOAD -> tor.mediaUploadsViaTor
}
private fun i2pEnables(
role: FeatureRole,
i2p: I2pSettings,
): Boolean =
when (role) {
FeatureRole.IMAGE -> i2p.imagesViaI2p
FeatureRole.VIDEO -> i2p.videosViaI2p
FeatureRole.URL_PREVIEW -> i2p.urlPreviewsViaI2p
FeatureRole.PROFILE_PIC -> i2p.profilePicsViaI2p
FeatureRole.NIP05 -> i2p.nip05VerificationsViaI2p
FeatureRole.MONEY -> i2p.moneyOperationsViaI2p
FeatureRole.UPLOAD -> i2p.mediaUploadsViaI2p
}
}
@@ -25,15 +25,15 @@ 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.
// Aggregate of all privacy configuration. Both Tor and I2P daemons can run
// side-by-side so hidden services on each network stay reachable, but only one
// transport carries clearnet at a time: preferredClearnetTransport picks it.
// The per-feature booleans on each TorSettings / I2pSettings only take effect
// for the preferred transport.
data class PrivacySettings(
val tor: TorSettings = TorSettings(),
val i2p: I2pSettings = I2pSettings(),
val features: FeatureTransportChoices = FeatureTransportChoices(),
val preferredClearnetTransport: PrivacyTransport = PrivacyTransport.DIRECT,
) {
val torAvailable: Boolean get() = tor.torType != TorType.OFF
val i2pAvailable: Boolean get() = i2p.i2pType != I2pType.OFF
@@ -36,85 +36,155 @@ class PrivacyRouterTest {
private fun settings(
torOn: Boolean = false,
i2pOn: Boolean = false,
features: FeatureTransportChoices = FeatureTransportChoices(),
preferred: PrivacyTransport = PrivacyTransport.DIRECT,
tor: TorSettings = TorSettings(torType = if (torOn) TorType.INTERNAL else TorType.OFF),
i2p: I2pSettings = I2pSettings(i2pType = if (i2pOn) I2pType.INTERNAL else I2pType.OFF),
) = PrivacySettings(
tor = TorSettings(torType = if (torOn) TorType.INTERNAL else TorType.OFF),
i2p = I2pSettings(i2pType = if (i2pOn) I2pType.INTERNAL else I2pType.OFF),
features = features,
tor = tor,
i2p = i2p,
preferredClearnetTransport = preferred,
)
@Test
fun localhost_alwaysDirect() {
assertEquals(PrivacyTransport.DIRECT, PrivacyRouter.route(localhost, FeatureRole.IMAGE, settings(torOn = true, i2pOn = true)))
assertEquals(
PrivacyRoute.Direct,
PrivacyRouter.route(localhost, FeatureRole.IMAGE, settings(torOn = true, i2pOn = true, preferred = PrivacyTransport.TOR)),
)
}
@Test
fun onion_pinsToTor_whenAvailable() {
assertEquals(PrivacyTransport.TOR, PrivacyRouter.route(onion, FeatureRole.IMAGE, settings(torOn = true)))
assertEquals(PrivacyRoute.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)))
fun onion_blocksWhenTorOff() {
assertEquals(
PrivacyRoute.Blocked(BlockReason.ONION_REQUIRES_TOR),
PrivacyRouter.route(onion, FeatureRole.IMAGE, settings(torOn = false, i2pOn = true, preferred = PrivacyTransport.I2P)),
)
}
@Test
fun i2p_pinsToI2p_whenAvailable() {
assertEquals(PrivacyTransport.I2P, PrivacyRouter.route(i2p, FeatureRole.IMAGE, settings(i2pOn = true)))
assertEquals(PrivacyRoute.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))
fun i2p_blocksWhenI2pOff() {
assertEquals(
PrivacyRoute.Blocked(BlockReason.I2P_REQUIRES_I2P),
PrivacyRouter.route(i2p, FeatureRole.IMAGE, settings(torOn = true, i2pOn = false, preferred = PrivacyTransport.TOR)),
)
}
@Test
fun b32_i2p_address_pinsToI2p() {
val b32 = "wss://abcdefghijklmnopqrstuvwxyz234567.b32.i2p/"
assertEquals(PrivacyTransport.I2P, PrivacyRouter.route(b32, FeatureRole.IMAGE, settings(i2pOn = true)))
assertEquals(PrivacyRoute.I2p, PrivacyRouter.route(b32, FeatureRole.IMAGE, settings(i2pOn = true)))
}
@Test
fun clearnet_directWhenNoPreferred() {
assertEquals(
PrivacyRoute.Direct,
PrivacyRouter.route(
clearnet,
FeatureRole.IMAGE,
settings(torOn = true, i2pOn = true, preferred = PrivacyTransport.DIRECT, tor = TorSettings(torType = TorType.INTERNAL, imagesViaTor = true)),
),
)
}
@Test
fun clearnet_torRoutes_whenPreferredAndFeatureOn() {
val s =
settings(
torOn = true,
preferred = PrivacyTransport.TOR,
tor = TorSettings(torType = TorType.INTERNAL, imagesViaTor = true),
)
assertEquals(PrivacyRoute.Tor, PrivacyRouter.route(clearnet, FeatureRole.IMAGE, s))
}
@Test
fun clearnet_torDirect_whenPreferredButFeatureOff() {
val s =
settings(
torOn = true,
preferred = PrivacyTransport.TOR,
tor = TorSettings(torType = TorType.INTERNAL, imagesViaTor = false),
)
assertEquals(PrivacyRoute.Direct, PrivacyRouter.route(clearnet, FeatureRole.IMAGE, s))
}
@Test
fun clearnet_i2pRoutes_whenPreferredAndFeatureOn() {
val s =
settings(
i2pOn = true,
preferred = PrivacyTransport.I2P,
i2p = I2pSettings(i2pType = I2pType.INTERNAL, videosViaI2p = true),
)
assertEquals(PrivacyRoute.I2p, PrivacyRouter.route(clearnet, FeatureRole.VIDEO, s))
}
@Test
fun clearnet_i2pDirect_whenPreferredButFeatureOff() {
val s =
settings(
i2pOn = true,
preferred = PrivacyTransport.I2P,
i2p = I2pSettings(i2pType = I2pType.INTERNAL, videosViaI2p = false),
)
assertEquals(PrivacyRoute.Direct, PrivacyRouter.route(clearnet, FeatureRole.VIDEO, s))
}
@Test
fun clearnet_preferredButDaemonOff_isDirect() {
// User picked TOR but the daemon is off — go Direct instead of erroring.
val s =
settings(
torOn = false,
preferred = PrivacyTransport.TOR,
tor = TorSettings(torType = TorType.OFF, imagesViaTor = true),
)
assertEquals(PrivacyRoute.Direct, PrivacyRouter.route(clearnet, FeatureRole.IMAGE, s))
}
@Test
fun clearnet_i2pTogglesIgnored_whenTorIsPreferred() {
// I2P is the only daemon running but TOR is the preferred clearnet transport,
// so I2P toggles must not take effect — fall through to Direct.
val s =
settings(
torOn = false,
i2pOn = true,
preferred = PrivacyTransport.TOR,
i2p = I2pSettings(i2pType = I2pType.INTERNAL, imagesViaI2p = true),
)
assertEquals(PrivacyRoute.Direct, PrivacyRouter.route(clearnet, FeatureRole.IMAGE, s))
}
@Test
fun bothDaemonsRunning_clearnetUsesOnlyPreferred() {
// Both daemons up, both have images toggled on, preferred is I2P → I2P wins.
val s =
settings(
torOn = true,
i2pOn = true,
preferred = PrivacyTransport.I2P,
tor = TorSettings(torType = TorType.INTERNAL, imagesViaTor = true),
i2p = I2pSettings(i2pType = I2pType.INTERNAL, imagesViaI2p = true),
)
assertEquals(PrivacyRoute.I2p, PrivacyRouter.route(clearnet, FeatureRole.IMAGE, s))
}
@Test
fun hiddenServiceIgnoresClearnetPreference() {
// .onion still pins to Tor even when I2P is the preferred clearnet transport.
val s = settings(torOn = true, i2pOn = true, preferred = PrivacyTransport.I2P)
assertEquals(PrivacyRoute.Tor, PrivacyRouter.route(onion, FeatureRole.IMAGE, s))
}
}