feat(desktop): persist TOFU-pinned Namecoin ElectrumX certs

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.
This commit is contained in:
m
2026-05-28 05:41:22 +10:00
parent 80991c6c8b
commit b5b70fe693
5 changed files with 377 additions and 3 deletions
@@ -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()
@@ -45,13 +45,36 @@ import javax.net.SocketFactory
*/
class DesktopNamecoinNameService(
private val preferencesProvider: () -> NamecoinSettings = { NamecoinSettings.DEFAULT },
pinnedCertsProvider: () -> List<String> = { 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(
@@ -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<String> = loadPinnedCertsFromDisk()
/** Wipe all user-pinned certs. Called by [reset]. */
private fun clearPinnedCerts() = savePinnedCerts(emptyList())
private fun savePinnedCerts(certs: List<String>) {
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<String> =
try {
val raw = prefs.get(KEY_PINNED_CERTS, null)
if (raw != null) {
mapper.readValue<List<String>>(raw)
} else {
emptyList()
}
} catch (_: Exception) {
emptyList()
}
// ── Internal ───────────────────────────────────────────────────────
private fun persist(settings: NamecoinSettings) {
@@ -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<List<ServerTestResult>>(emptyList()) }
var pendingCerts by remember { mutableStateOf<List<PendingCertPin>>(emptyList()) }
var confirmingCert by remember { mutableStateOf<PendingCertPin?>(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<ServerTestResult>()
val newCerts = mutableListOf<PendingCertPin>()
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,
@@ -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())
}
}