From b5b70fe6932b8d5fe02f43652dc5cbeeca0eadac Mon Sep 17 00:00:00 2001 From: m Date: Thu, 28 May 2026 05:41:22 +1000 Subject: [PATCH] feat(desktop): persist TOFU-pinned Namecoin ElectrumX certs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors Android's NamecoinSharedPreferences pinned-cert API on Desktop so user-accepted TLS pins survive process restart. Same JSON-list shape, same distinct-append semantics, same wipe-on-reset behaviour. What's new - DesktopNamecoinPreferences gains addPinnedCert / loadPinnedCerts / clearPinnedCerts (sync rather than suspend, since java.util.prefs is synchronous). reset() now clears pinned certs too, matching Android. - DesktopNamecoinNameService accepts a pinnedCertsProvider and pushes the loaded list into ElectrumXClient.setDynamicCerts at init, mirroring Android's AppModules.kt wiring. Exposes the underlying client so the Settings UI can call testServer() and re-apply pins live. - Desktop NamecoinSettingsSection grows an optional Test Connection + TOFU pin sub-section: runs ElectrumXClient.testServer per active server, collects PEM + SHA-256 fingerprint from successful TLS handshakes, and prompts the user to pin each new cert via AlertDialog. UI hidden when no service is wired (so existing call sites stay valid). - Main.kt wires both halves together and updates the freshly-pinned cert list into the live client without waiting for restart. Persistence is plain java.util.prefs (same backing store as the rest of DesktopNamecoinPreferences) — explicitly NOT EncryptedSharedPreferences. Pinned cert PEMs are public material; no secrets stored. Tests - DesktopNamecoinPreferencesTest: +6 cases covering empty default, persistence + reload, dedup, blank input ignored, reset wipes, and independence from settings copies. Verification - ./gradlew :desktopApp:compileKotlin — BUILD SUCCESSFUL - ./gradlew :desktopApp:test — BUILD SUCCESSFUL (16 tests, 0 failures) - ./gradlew :amethyst:compilePlayDebugKotlin — BUILD SUCCESSFUL - ./gradlew :amethyst:spotlessCheck :commons:spotlessCheck :desktopApp:spotlessCheck — BUILD SUCCESSFUL Stack note Stacked behind #3072 (merged 2026-05-27). Next: PR-C for the full Namecoin Core RPC backend + composite fallback persistence + UI. --- .../vitorpamplona/amethyst/desktop/Main.kt | 26 ++- .../namecoin/DesktopNamecoinNameService.kt | 25 +- .../namecoin/DesktopNamecoinPreferences.kt | 47 ++++ .../ui/settings/NamecoinSettingsSection.kt | 218 +++++++++++++++++- .../DesktopNamecoinPreferencesTest.kt | 64 +++++ 5 files changed, 377 insertions(+), 3 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index a314a3449a..9787f46b65 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -973,7 +973,10 @@ fun App( val namecoinPreferences = remember { DesktopNamecoinPreferences() } val namecoinService = remember { - DesktopNamecoinNameService(preferencesProvider = { namecoinPreferences.current }) + DesktopNamecoinNameService( + preferencesProvider = { namecoinPreferences.current }, + pinnedCertsProvider = { namecoinPreferences.loadPinnedCerts() }, + ) } // NWC loaded during startup in loadSavedAccount flow @@ -1652,6 +1655,7 @@ fun RelaySettingsScreen( // Namecoin Settings (ElectrumX servers for .bit / d/ / id/ resolution) val namecoinPrefsHere = namecoinPreferences ?: LocalNamecoinPreferences.current + val namecoinServiceHere = LocalNamecoinService.current if (namecoinPrefsHere != null) { val namecoinScope = rememberCoroutineScope() val namecoinSettings by namecoinPrefsHere.settings.collectAsState() @@ -1669,6 +1673,26 @@ fun RelaySettingsScreen( onReset = { namecoinScope.launch { namecoinPrefsHere.reset() } }, + onTestServer = + namecoinServiceHere?.let { svc -> + { server -> svc.client.testServer(server) } + }, + onPinCert = + namecoinServiceHere?.let { svc -> + { pem -> + namecoinPrefsHere.addPinnedCert(pem) + // Apply immediately so the next lookup uses the new pin. + namecoinScope.launch { + try { + svc.client.setDynamicCerts( + namecoinPrefsHere.loadPinnedCerts(), + ) + } catch (_: Exception) { + // Best-effort — persisted, will apply on next restart. + } + } + } + }, ) Spacer(Modifier.height(24.dp)) HorizontalDivider() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinNameService.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinNameService.kt index aa5891b97b..0b9f899ed4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinNameService.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinNameService.kt @@ -45,13 +45,36 @@ import javax.net.SocketFactory */ class DesktopNamecoinNameService( private val preferencesProvider: () -> NamecoinSettings = { NamecoinSettings.DEFAULT }, + pinnedCertsProvider: () -> List = { emptyList() }, ) { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val electrumxClient = ElectrumXClient( socketFactory = { SocketFactory.getDefault() }, - ) + ).also { client -> + // Push any persisted TOFU-pinned certs into the client at startup so + // user-accepted pins survive process restart. Mirrors Android's + // AppModules.kt path. + scope.launch { + try { + val pinned = pinnedCertsProvider() + if (pinned.isNotEmpty()) { + client.setDynamicCerts(pinned) + } + } catch (_: Exception) { + // Non-fatal — defaults still work, user can re-pin via Settings. + } + } + } + + /** + * Expose the underlying ElectrumX client so the Settings UI can push a + * newly-accepted cert in via [ElectrumXClient.setDynamicCerts] without + * waiting for an app restart, and so a Test Connection button can call + * [ElectrumXClient.testServer] directly. + */ + val client: ElectrumXClient get() = electrumxClient private val resolver = NamecoinNameResolver( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferences.kt index 42f2b33027..2237b86934 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferences.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferences.kt @@ -49,6 +49,7 @@ class DesktopNamecoinPreferences( companion object { private const val KEY_ENABLED = "namecoin.enabled" private const val KEY_CUSTOM_SERVERS = "namecoin.customServers" + private const val KEY_PINNED_CERTS = "namecoin.pinnedCerts" } private val _settings = MutableStateFlow(loadFromDisk()) @@ -84,8 +85,54 @@ class DesktopNamecoinPreferences( suspend fun reset() { persist(NamecoinSettings.DEFAULT) + clearPinnedCerts() } + // ── Pinned certs (TOFU) ──────────────────────────────────────────── + + /** + * Store a PEM-encoded certificate that the user accepted via Test + * Connection. The cert is appended to the existing list (deduplicated) + * and persisted; callers are expected to push the updated list into + * [com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient] + * via `setDynamicCerts(...)` so it takes effect immediately. + * + * Mirrors the Android API in `NamecoinSharedPreferences`. + */ + fun addPinnedCert(pem: String) { + if (pem.isBlank()) return + val existing = loadPinnedCertsFromDisk() + val updated = (existing + pem).distinct() + savePinnedCerts(updated) + } + + /** Load all user-pinned certs from disk (for startup sync). */ + fun loadPinnedCerts(): List = loadPinnedCertsFromDisk() + + /** Wipe all user-pinned certs. Called by [reset]. */ + private fun clearPinnedCerts() = savePinnedCerts(emptyList()) + + private fun savePinnedCerts(certs: List) { + try { + prefs.put(KEY_PINNED_CERTS, mapper.writeValueAsString(certs)) + prefs.flush() + } catch (e: Exception) { + System.err.println("NamecoinPrefs: Error writing pinned certs: ${e.message}") + } + } + + private fun loadPinnedCertsFromDisk(): List = + try { + val raw = prefs.get(KEY_PINNED_CERTS, null) + if (raw != null) { + mapper.readValue>(raw) + } else { + emptyList() + } + } catch (_: Exception) { + emptyList() + } + // ── Internal ─────────────────────────────────────────────────────── private fun persist(settings: NamecoinSettings) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NamecoinSettingsSection.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NamecoinSettingsSection.kt index ec3033a65c..fbe1aeabd2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NamecoinSettingsSection.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NamecoinSettingsSection.kt @@ -34,6 +34,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -45,6 +48,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -64,6 +68,9 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinSettings import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ServerTestResult +import kotlinx.coroutines.launch /** * Complete settings section for Namecoin ElectrumX server configuration. @@ -77,6 +84,13 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_S * @param onAddServer Called with `host:port[:tcp]` when user adds a server * @param onRemoveServer Called with the server string to remove * @param onReset Called when user resets to defaults + * @param onTestServer Suspend function to test a single server. When + * `null`, the Test Connection UI is hidden — useful + * for previews or when no [DesktopNamecoinNameService] + * is available. + * @param onPinCert Called with a PEM-encoded cert when the user + * accepts a TOFU pin prompt. Mirrors the Android + * callback contract. */ @Composable fun NamecoinSettingsSection( @@ -86,6 +100,8 @@ fun NamecoinSettingsSection( onRemoveServer: (String) -> Unit, onReset: () -> Unit, modifier: Modifier = Modifier, + onTestServer: (suspend (ElectrumxServer) -> ServerTestResult)? = null, + onPinCert: ((String) -> Unit)? = null, ) { Column(modifier = modifier.padding(16.dp)) { // ── Section header ───────────────────────────────────────── @@ -126,9 +142,23 @@ fun NamecoinSettingsSection( onRemove = onRemoveServer, ) - // ── Add server input ─────────────────────────────── + // ── Add server input ─────────────────────────────────────────── NamecoinAddServerInput(onAdd = onAddServer) + // ── Test connection + TOFU pin ──────────────────────────────── + if (onTestServer != null) { + Spacer(Modifier.height(12.dp)) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + Spacer(Modifier.height(12.dp)) + NamecoinTestConnectionSection( + settings = settings, + onTestServer = onTestServer, + onPinCert = onPinCert ?: {}, + ) + } + Spacer(Modifier.height(8.dp)) // ── Reset button ─────────────────────────────────── @@ -372,6 +402,192 @@ private fun NamecoinAddServerInput(onAdd: (String) -> Unit) { } } +// ── Test connection sub-section ──────────────────────────────────────────────── + +private data class PendingCertPin( + val serverHost: String, + val fingerprint: String, + val pem: String, +) + +@Composable +private fun NamecoinTestConnectionSection( + settings: NamecoinSettings, + onTestServer: suspend (ElectrumxServer) -> ServerTestResult, + onPinCert: (String) -> Unit, +) { + val scope = rememberCoroutineScope() + var isTesting by remember { mutableStateOf(false) } + var testResults by remember { mutableStateOf>(emptyList()) } + var pendingCerts by remember { mutableStateOf>(emptyList()) } + var confirmingCert by remember { mutableStateOf(null) } + + val servers = settings.toElectrumxServers() ?: DEFAULT_ELECTRUMX_SERVERS + + // ── Cert confirmation dialog ────────────────────────────────────────── + confirmingCert?.let { pending -> + AlertDialog( + onDismissRequest = { + pendingCerts = pendingCerts.drop(1) + confirmingCert = pendingCerts.firstOrNull() + }, + title = { Text("Pin server certificate?") }, + text = { + Column { + Text( + "Trust this certificate for ${pending.serverHost}? " + + "Subsequent lookups against this host will require the same cert.", + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(12.dp)) + Text( + "SHA-256:", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = pending.fingerprint, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { + Button(onClick = { + onPinCert(pending.pem) + pendingCerts = pendingCerts.drop(1) + confirmingCert = pendingCerts.firstOrNull() + }) { + Text("Pin") + } + }, + dismissButton = { + TextButton(onClick = { + pendingCerts = pendingCerts.drop(1) + confirmingCert = pendingCerts.firstOrNull() + }) { + Text("Skip") + } + }, + ) + } + + Column { + Button( + onClick = { + if (!isTesting) { + isTesting = true + testResults = emptyList() + pendingCerts = emptyList() + scope.launch { + val results = mutableListOf() + val newCerts = mutableListOf() + for (server in servers) { + val result = onTestServer(server) + results.add(result) + testResults = results.toList() + val pem = result.serverCertPem + val fp = result.certFingerprint + if (result.success && pem != null && fp != null) { + newCerts.add( + PendingCertPin( + serverHost = "${server.host}:${server.port}", + fingerprint = fp, + pem = pem, + ), + ) + } + } + isTesting = false + if (newCerts.isNotEmpty()) { + pendingCerts = newCerts + confirmingCert = newCerts.first() + } + } + } + }, + enabled = !isTesting, + modifier = Modifier.fillMaxWidth(), + ) { + if (isTesting) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(8.dp)) + Text("Testing…") + } else { + Text("Test connection & pin certs") + } + } + + if (testResults.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + Text( + "Test results", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + ) + Spacer(Modifier.height(6.dp)) + testResults.forEach { result -> + NamecoinServerTestResultRow(result) + } + } + } +} + +@Composable +private fun NamecoinServerTestResultRow(result: ServerTestResult) { + val serverLabel = "${result.server.host}:${result.server.port}" + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (result.success) "✓" else "✗", + color = + if (result.success) { + Color(0xFF2E8B57) + } else { + MaterialTheme.colorScheme.error + }, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(end = 8.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = serverLabel, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val detail: String = + when { + result.success && result.tlsVersion != null -> + "${result.tlsVersion} · ${result.responseTimeMs} ms" + result.success -> "${result.responseTimeMs} ms" + !result.error.isNullOrBlank() -> result.error!! + else -> "Failed" + } + Text( + text = detail, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + @Composable private fun NamecoinServerRow( displayText: String, diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferencesTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferencesTest.kt index bd27f3c803..94a57dee30 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferencesTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferencesTest.kt @@ -162,4 +162,68 @@ class DesktopNamecoinPreferencesTest { val reloaded = DesktopNamecoinPreferences(prefs = testPrefs) assertEquals(settings, reloaded.current) } + + // ── Pinned certs ───────────────────────────────────────────────────── + + private val samplePem1 = + "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----" + private val samplePem2 = + "-----BEGIN CERTIFICATE-----\nBBBB\n-----END CERTIFICATE-----" + + @Test + fun `loadPinnedCerts returns empty by default`() { + assertTrue(namecoinPrefs.loadPinnedCerts().isEmpty()) + } + + @Test + fun `addPinnedCert persists and survives reload`() { + namecoinPrefs.addPinnedCert(samplePem1) + assertEquals(listOf(samplePem1), namecoinPrefs.loadPinnedCerts()) + + val reloaded = DesktopNamecoinPreferences(prefs = testPrefs) + assertEquals(listOf(samplePem1), reloaded.loadPinnedCerts()) + } + + @Test + fun `addPinnedCert appends without duplicating`() { + namecoinPrefs.addPinnedCert(samplePem1) + namecoinPrefs.addPinnedCert(samplePem2) + namecoinPrefs.addPinnedCert(samplePem1) // duplicate + + assertEquals(listOf(samplePem1, samplePem2), namecoinPrefs.loadPinnedCerts()) + } + + @Test + fun `addPinnedCert ignores blank input`() { + namecoinPrefs.addPinnedCert("") + namecoinPrefs.addPinnedCert(" ") + assertTrue(namecoinPrefs.loadPinnedCerts().isEmpty()) + } + + @Test + fun `reset clears pinned certs`() = + runBlocking { + namecoinPrefs.addPinnedCert(samplePem1) + namecoinPrefs.addPinnedCert(samplePem2) + assertEquals(2, namecoinPrefs.loadPinnedCerts().size) + + namecoinPrefs.reset() + assertTrue(namecoinPrefs.loadPinnedCerts().isEmpty()) + + // Verify persistence — cleared certs stay cleared after reload. + val reloaded = DesktopNamecoinPreferences(prefs = testPrefs) + assertTrue(reloaded.loadPinnedCerts().isEmpty()) + } + + @Test + fun `pinned certs are independent of settings copy`() = + runBlocking { + // Add some pinned certs. + namecoinPrefs.addPinnedCert(samplePem1) + // Mutating other settings must not clobber the pinned-cert list. + namecoinPrefs.addServer("example.com:50006") + namecoinPrefs.setEnabled(false) + + assertEquals(listOf(samplePem1), namecoinPrefs.loadPinnedCerts()) + } }