mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
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:
@@ -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()
|
||||
|
||||
+24
-1
@@ -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(
|
||||
|
||||
+47
@@ -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) {
|
||||
|
||||
+217
-1
@@ -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,
|
||||
|
||||
+64
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user