feat(namecoin): TOFU pin for Namecoin Core RPC TLS path

When the user picks the Namecoin Core RPC backend and points at a
self-hosted node behind a self-signed cert (StartOS / Start9, umbrel,
LAN reverse proxy, …) the previous flow only worked if the cert's CA
was already in the device trust store. There was no in-app way to
inspect or pin the certificate, so users had to install the StartOS
root CA at the OS level — or settle for an unencrypted onion path.

This change brings the Namecoin Core RPC path up to parity with the
existing ElectrumX path:

  - NamecoinCoreRpcClient.probe() now opens a short-lived, no-auth TLS
    socket alongside the JSON-RPC call to capture the server's leaf
    certificate (PEM + SHA-256 fingerprint). Capture is best-effort
    and only runs for https:// URLs. Credentials are never sent over
    the inspection socket.

  - RpcProbeResult exposes serverCertPem, certFingerprint, and
    tlsHandshakeFailed so the Settings UI can react. New fields are
    nullable / default false so existing callers compile unchanged.

  - NamecoinCoreRpcClient maintains its own dynamic-cert keystore and
    a lazy pinned SSLSocketFactory (same shape as ElectrumXClient's,
    minus the hardcoded list — Core RPC has no public defaults). When
    cfg.usePinnedTrustStore is true and the URL is https, callRpc()
    routes through the pinned factory with a permissive hostname
    verifier (LAN/onion certs commonly carry IP-only SANs).

  - NamecoinSettingsSection's Namecoin Core RPC card now shows a
    'Trust Server Certificate?' AlertDialog after Test RPC when the
    probe captured a cert and the user hasn't pinned yet, reusing the
    existing namecoin_pin_cert_* strings. Accept persists the PEM
    AND flips usePinnedTrustStore=true on the config. The result
    card also displays the captured fingerprint and a '(pinned)'
    marker so the user can see the current trust state at a glance.

  - The pinned PEM list is stored in the existing
    KEY_PINNED_CERTS DataStore entry, so a single TOFU confirmation
    covers both backends. AppModules' namecoinCoreRpcClient init now
    bootstraps the pinned list on app start, matching ElectrumX.

  - Tests cover the new probe fields' defaults and the addPinnedCert
    / setDynamicCerts surface.

Local verification: builds clean (assembleFdroidDebug), :quartz:jvmTest
NamecoinCoreRpcClientTest all green, :amethyst:testFdroidDebugUnitTest
namecoin suites all green.
This commit is contained in:
m
2026-05-28 03:32:28 +10:00
parent 1cd57bd497
commit cfb3f1b9fa
5 changed files with 491 additions and 8 deletions
@@ -281,11 +281,27 @@ class AppModules(
*/
val namecoinCoreRpcClient by lazy {
Log.d("AppModules", "NamecoinCoreRpcClient Init")
NamecoinCoreRpcClient(
httpClientForUrl = roleBasedHttpClientBuilder::okHttpClientForNip05,
).also {
it.setConfig(namecoinPrefs.current.namecoinCoreRpc)
val client =
NamecoinCoreRpcClient(
httpClientForUrl = roleBasedHttpClientBuilder::okHttpClientForNip05,
).also {
it.setConfig(namecoinPrefs.current.namecoinCoreRpc)
}
// Bootstrap the pinned trust store from the same shared
// SharedPreferences entry the ElectrumX client uses. Mirrors
// the ElectrumXClient init path above so user-pinned certs
// are available on both backends after process restart.
applicationIOScope.launch {
try {
val pinnedCerts = namecoinPrefs.loadPinnedCerts()
if (pinnedCerts.isNotEmpty()) {
client.setDynamicCerts(pinnedCerts)
}
} catch (_: Exception) {
// Non-fatal — user can re-pin via Settings.
}
}
client
}
/**
@@ -95,7 +95,10 @@ fun NamecoinSettingsScreen(
onPinCert = { pem ->
scope.launch {
namecoinPrefs.addPinnedCert(pem)
// Share the pinned PEM across both backends so the
// user only has to confirm a self-signed cert once.
electrumXClient().addPinnedCert(pem)
namecoinCoreRpcClient().addPinnedCert(pem)
}
},
onSetBackend = { backend ->
@@ -155,6 +155,7 @@ fun NamecoinSettingsSection(
config = settings.namecoinCoreRpc,
onConfigChange = onSetCoreRpcConfig,
onTestCoreRpc = onTestCoreRpc,
onPinCert = onPinCert,
)
Spacer(Modifier.height(16.dp))
HorizontalDivider(
@@ -884,6 +885,7 @@ private fun NamecoinCoreRpcSection(
config: NamecoinCoreRpcConfig,
onConfigChange: (NamecoinCoreRpcConfig) -> Unit,
onTestCoreRpc: suspend (NamecoinCoreRpcConfig) -> RpcProbeResult,
onPinCert: (String) -> Unit = {},
) {
val scope = rememberCoroutineScope()
var url by rememberSaveable(config.url) { mutableStateOf(config.url) }
@@ -892,6 +894,61 @@ private fun NamecoinCoreRpcSection(
var passVisible by remember { mutableStateOf(false) }
var testing by remember { mutableStateOf(false) }
var lastProbe by remember { mutableStateOf<RpcProbeResult?>(null) }
// Cert that the probe captured but the user hasn't yet accepted/rejected.
var pendingPin by remember { mutableStateOf<PendingCertPin?>(null) }
// ── Cert confirmation dialog (TOFU) ─────────────────────────────────────
pendingPin?.let { pin ->
AlertDialog(
onDismissRequest = { pendingPin = null },
title = { Text(stringResource(R.string.namecoin_pin_cert_title)) },
text = {
Column {
Text(
stringResource(R.string.namecoin_pin_cert_body, pin.serverHost),
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 = pin.fingerprint,
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
Button(onClick = {
// Persist the PEM to the shared trust store AND flip
// usePinnedTrustStore on the config so future calls
// route through the pinned factory.
onPinCert(pin.pem)
onConfigChange(
config.copy(
url = url.trim(),
username = user.trim(),
password = pass,
usePinnedTrustStore = true,
),
)
pendingPin = null
}) {
Text(stringResource(R.string.namecoin_pin_cert_accept))
}
},
dismissButton = {
TextButton(onClick = { pendingPin = null }) {
Text(stringResource(R.string.namecoin_pin_cert_reject))
}
},
)
}
fun commit() {
onConfigChange(
@@ -1019,7 +1076,37 @@ private fun NamecoinCoreRpcSection(
)
scope.launch {
try {
lastProbe = onTestCoreRpc(candidate)
val probe = onTestCoreRpc(candidate)
lastProbe = probe
// Offer TOFU when the probe captured a leaf cert and
// either: (a) the handshake failed (so the user has
// to pin to get further), or (b) the user isn't
// pinning yet but the cert isn't already in their
// trust store — we surface the option pre-emptively
// so they can lock the identity in. Skip when
// already pinned to this exact PEM.
val pem = probe.serverCertPem
val fp = probe.certFingerprint
if (
pem != null && fp != null && (
probe.tlsHandshakeFailed ||
!candidate.usePinnedTrustStore
)
) {
val host =
try {
java.net.URI(candidate.url).host
?: candidate.url
} catch (_: Exception) {
candidate.url
}
pendingPin =
PendingCertPin(
serverHost = host,
fingerprint = fp,
pem = pem,
)
}
} finally {
testing = false
}
@@ -1084,6 +1171,29 @@ private fun NamecoinCoreRpcSection(
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
)
if (probe.tlsHandshakeFailed && probe.certFingerprint != null) {
Spacer(Modifier.height(6.dp))
Text(
"TLS rejected by the system trust store. Tap Test RPC " +
"again, then choose Trust to pin this server's certificate.",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
val fp = probe.certFingerprint
if (fp != null) {
Spacer(Modifier.height(6.dp))
Text(
text =
"cert SHA-256: ${fp.take(23)}\u2026" +
if (config.usePinnedTrustStore) " (pinned)" else "",
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -35,7 +35,22 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.coroutines.executeAsync
import java.io.ByteArrayInputStream
import java.net.Socket
import java.net.URI
import java.security.KeyStore
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.util.Base64
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLHandshakeException
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
/**
* Namecoin Core JSON-RPC client.
@@ -86,12 +101,48 @@ class NamecoinCoreRpcClient(
@Volatile
private var config: NamecoinCoreRpcConfig = NamecoinCoreRpcConfig()
/**
* User-supplied PEM certificates the user has explicitly trusted
* via the Settings "Test RPC" TOFU prompt. Shared with
* [ElectrumXClient] at the SharedPreferences layer but kept in a
* private list here so each client owns its own [SSLSocketFactory].
*/
private val dynamicCerts = mutableListOf<String>()
/** Lazy-cached SSLSocketFactory for pinned certs. Thread-safe via volatile + DCL. */
@Volatile
private var pinnedFactory: SSLSocketFactory? = null
fun setConfig(cfg: NamecoinCoreRpcConfig) {
config = cfg
}
fun currentConfig(): NamecoinCoreRpcConfig = config
/**
* Append a PEM-encoded certificate to the dynamic trust store.
* Typically called after the user confirms a cert fingerprint via
* the Settings "Test RPC" flow. Invalidates the cached factory so
* the next connection picks it up.
*/
fun addPinnedCert(pem: String) {
synchronized(this) {
if (pem !in dynamicCerts) dynamicCerts.add(pem)
pinnedFactory = null
}
}
/**
* Replace all dynamic certs (e.g. loaded from preferences on startup).
*/
fun setDynamicCerts(pems: List<String>) {
synchronized(this) {
dynamicCerts.clear()
dynamicCerts.addAll(pems)
pinnedFactory = null
}
}
/**
* Run `name_show <identifier>` against the configured Namecoin Core node.
*
@@ -110,6 +161,14 @@ class NamecoinCoreRpcClient(
*/
suspend fun probe(cfg: NamecoinCoreRpcConfig): RpcProbeResult {
val started = System.currentTimeMillis()
// Capture the leaf cert + fingerprint as a best-effort side channel
// before issuing the RPC. This lets the Settings UI show the
// server's certificate even when the subsequent RPC call fails
// (e.g. wrong username) and — crucially — even when the RPC
// call would fail TLS verification (so the user can choose to
// pin it via TOFU). Only meaningful for https URLs.
val captured: CapturedLeafCert? =
withContext(Dispatchers.IO) { captureLeafCert(cfg) }
return try {
// Use a cheap, side-effect-free call: getblockchaininfo. It works on every
// bitcoin-derived node, doesn't require -namehistoric, and answers
@@ -136,18 +195,30 @@ class NamecoinCoreRpcClient(
blocks = blocks,
verificationProgress = verification,
initialBlockDownload = initialDownload,
serverCertPem = captured?.pem,
certFingerprint = captured?.fingerprint,
)
} catch (e: NamecoinLookupException.ServersUnreachable) {
val cause = e.cause
val tlsFailure =
cause is SSLHandshakeException ||
(cause is javax.net.ssl.SSLException && cause.message?.contains("trust", ignoreCase = true) == true)
RpcProbeResult(
success = false,
elapsedMs = System.currentTimeMillis() - started,
error = e.cause?.message ?: e.message ?: "Unreachable",
error = cause?.message ?: e.message ?: "Unreachable",
serverCertPem = captured?.pem,
certFingerprint = captured?.fingerprint,
tlsHandshakeFailed = tlsFailure,
)
} catch (e: Exception) {
RpcProbeResult(
success = false,
elapsedMs = System.currentTimeMillis() - started,
error = e.message ?: e::class.simpleName ?: "Error",
serverCertPem = captured?.pem,
certFingerprint = captured?.fingerprint,
tlsHandshakeFailed = e is SSLHandshakeException,
)
}
}
@@ -233,14 +304,24 @@ class NamecoinCoreRpcClient(
)
}
val client =
val builder =
httpClientForUrl(cfg.url)
.newBuilder()
.followRedirects(false)
.connectTimeout(cfg.timeoutMs, TimeUnit.MILLISECONDS)
.readTimeout(cfg.timeoutMs, TimeUnit.MILLISECONDS)
.callTimeout(cfg.timeoutMs, TimeUnit.MILLISECONDS)
.build()
if (cfg.usePinnedTrustStore && cfg.url.startsWith("https://")) {
// Route this call through the pinned trust store + a permissive
// hostname verifier. We've already vouched for the cert by
// pinning it, and Namecoin Core LAN/onion deployments
// commonly present certs whose SAN doesn't match the URL
// the user typed (e.g. IP-in-SAN vs hostname).
val tm = pinnedTrustManager()
builder.sslSocketFactory(cachedPinnedSslFactory(), tm)
builder.hostnameVerifier { _, _ -> true }
}
val client = builder.build()
val request = requestBuilder.build()
@@ -299,6 +380,190 @@ class NamecoinCoreRpcClient(
lastError = IllegalStateException("RPC response missing result"),
)
}
// ── TLS pinning helpers ────────────────────────────────────────────────
private data class CapturedLeafCert(
val pem: String,
val fingerprint: String,
)
/**
* Open a short-lived TLS connection to [cfg]'s host:port and return
* the server's leaf certificate as PEM + SHA-256 fingerprint.
*
* Used by [probe] so the Settings UI can show the certificate the
* server presented — even if the subsequent RPC call rejects it.
* Done via a separate socket (no HTTP, no Authorization header) so:
* 1. We never send credentials over an untrusted connection.
* 2. The capture works regardless of whether the OkHttp request
* below would have failed TLS verification.
*
* Returns null for non-https URLs, or when the TCP/TLS connection
* itself fails (server down, port closed, etc.).
*/
private fun captureLeafCert(cfg: NamecoinCoreRpcConfig): CapturedLeafCert? {
if (!cfg.url.startsWith("https://")) return null
val uri =
try {
URI(cfg.url)
} catch (_: Exception) {
return null
}
val host = uri.host ?: return null
val port = if (uri.port > 0) uri.port else 443
// Use a trust-all SSLContext just for the capture handshake.
// We're not making any trust decision here — we're inspecting.
// The actual call below applies the user's pinning policy.
val trustAll =
arrayOf<javax.net.ssl.TrustManager>(
object : X509TrustManager {
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
) {}
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
) {}
override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
},
)
val ctx =
try {
SSLContext.getInstance("TLSv1.2")
} catch (_: Exception) {
SSLContext.getInstance("TLS")
}
ctx.init(null, trustAll, SecureRandom())
val factory = ctx.socketFactory
return try {
// Bound the TCP connect with the configured timeout. The
// capture is best-effort; never let it stall the probe.
val raw = Socket()
raw.connect(
java.net.InetSocketAddress(host, port),
cfg.timeoutMs.coerceAtMost(10_000L).toInt(),
)
raw.soTimeout = cfg.timeoutMs.coerceAtMost(10_000L).toInt()
val ssl = factory.createSocket(raw, host, port, true) as SSLSocket
ssl.useClientMode = true
try {
ssl.startHandshake()
val peerCerts = ssl.session.peerCertificates
if (peerCerts.isEmpty() || peerCerts[0] !is X509Certificate) return null
val x509 = peerCerts[0] as X509Certificate
val encoded =
Base64.getMimeEncoder(76, "\n".toByteArray()).encodeToString(x509.encoded)
val pem = "-----BEGIN CERTIFICATE-----\n$encoded\n-----END CERTIFICATE-----\n"
val digest = MessageDigest.getInstance("SHA-256").digest(x509.encoded)
val fp = digest.joinToString(":") { "%02X".format(it) }
CapturedLeafCert(pem = pem, fingerprint = fp)
} finally {
runCatching { ssl.close() }
runCatching { raw.close() }
}
} catch (_: Exception) {
null
}
}
private fun cachedPinnedSslFactory(): SSLSocketFactory {
pinnedFactory?.let { return it }
synchronized(this) {
pinnedFactory?.let { return it }
return buildPinnedSslFactory().also { pinnedFactory = it }
}
}
/**
* Build an [SSLSocketFactory] that trusts user-pinned certificates
* plus the system CA store. Same shape as the ElectrumX pinned
* factory, minus the hardcoded list (Namecoin Core RPC endpoints
* are 100% user-configured — there are no public defaults to ship).
*/
private fun buildPinnedSslFactory(): SSLSocketFactory {
val ks =
try {
KeyStore.getInstance(KeyStore.getDefaultType()).apply { load(null, null) }
} catch (_: Exception) {
KeyStore.getInstance("PKCS12").apply { load(null, null) }
}
val cf = CertificateFactory.getInstance("X.509")
val pems = synchronized(this) { dynamicCerts.toList() }
for ((index, pem) in pems.withIndex()) {
try {
val cert = cf.generateCertificate(ByteArrayInputStream(pem.toByteArray(Charsets.US_ASCII)))
ks.setCertificateEntry("namecoin_rpc_$index", cert)
} catch (_: Exception) {
// Skip malformed certs — the rest may still work.
}
}
val systemTmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
systemTmf.init(null as KeyStore?)
val systemTm = systemTmf.trustManagers.filterIsInstance<X509TrustManager>().firstOrNull()
if (systemTm != null) {
for ((index, issuer) in systemTm.acceptedIssuers.withIndex()) {
try {
ks.setCertificateEntry("system_$index", issuer)
} catch (_: Exception) {
// Some OEMs reject re-inserts; skip.
}
}
}
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
tmf.init(ks)
val sslContext =
try {
SSLContext.getInstance("TLSv1.2")
} catch (_: Exception) {
SSLContext.getInstance("TLS")
}
sslContext.init(null, tmf.trustManagers, SecureRandom())
return sslContext.socketFactory
}
/** Return an [X509TrustManager] backed by the same pinned keystore. */
private fun pinnedTrustManager(): X509TrustManager {
val ks =
try {
KeyStore.getInstance(KeyStore.getDefaultType()).apply { load(null, null) }
} catch (_: Exception) {
KeyStore.getInstance("PKCS12").apply { load(null, null) }
}
val cf = CertificateFactory.getInstance("X.509")
val pems = synchronized(this) { dynamicCerts.toList() }
for ((index, pem) in pems.withIndex()) {
try {
val cert = cf.generateCertificate(ByteArrayInputStream(pem.toByteArray(Charsets.US_ASCII)))
ks.setCertificateEntry("namecoin_rpc_$index", cert)
} catch (_: Exception) {
// skip
}
}
val systemTmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
systemTmf.init(null as KeyStore?)
val systemTm = systemTmf.trustManagers.filterIsInstance<X509TrustManager>().firstOrNull()
if (systemTm != null) {
for ((index, issuer) in systemTm.acceptedIssuers.withIndex()) {
try {
ks.setCertificateEntry("system_$index", issuer)
} catch (_: Exception) {
// skip
}
}
}
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
tmf.init(ks)
return tmf.trustManagers.filterIsInstance<X509TrustManager>().first()
}
}
/** Outcome of a `Test RPC` probe in Settings. */
@@ -310,4 +575,23 @@ data class RpcProbeResult(
val verificationProgress: Double? = null,
val initialBlockDownload: Boolean? = null,
val error: String? = null,
/**
* PEM-encoded server leaf certificate captured during the probe.
* Only populated when the URL scheme is https and the TLS handshake
* succeeded (even if the subsequent HTTP request failed). Used by
* the Settings UI to prompt for a TOFU pin.
*/
val serverCertPem: String? = null,
/**
* SHA-256 fingerprint of the captured leaf certificate, formatted
* as colon-separated uppercase hex bytes (matches
* [ServerTestResult.certFingerprint] for ElectrumX).
*/
val certFingerprint: String? = null,
/**
* True when the probe failed specifically because the TLS handshake
* was rejected (self-signed cert, untrusted CA, hostname mismatch).
* Used by the Settings UI to suggest a TOFU pin as the fix.
*/
val tlsHandshakeFailed: Boolean = false,
)
@@ -175,4 +175,74 @@ class NamecoinCoreRpcClientTest {
assertFalse(probe.success)
assertNotNull(probe.error)
}
// ── TOFU / pinning ────────────────────────────────────────────────────────────
@Test
fun `probe leaves cert fields null on http url`() =
runBlocking {
// No TLS in this fake transport, so the probe shouldn't fabricate
// a fingerprint. The interceptor short-circuits before any real
// socket is opened, but the probe layer also runs an out-of-band
// TLS capture — it must return null for http://.
val http =
fakeClient(
body =
"""{"result":{"chain":"main","blocks":1,"verificationprogress":1.0,"initialblockdownload":false},"error":null,"id":"amethyst"}""",
)
val client = NamecoinCoreRpcClient(httpClientForUrl = { http })
val probe = client.probe(NamecoinCoreRpcConfig(url = "http://node.example/", username = "u", password = "p"))
assertTrue(probe.success)
assertEquals(null, probe.serverCertPem)
assertEquals(null, probe.certFingerprint)
assertFalse(probe.tlsHandshakeFailed)
}
@Test
fun `addPinnedCert is idempotent and survives setDynamicCerts`() {
val client = NamecoinCoreRpcClient(httpClientForUrl = { OkHttpClient() })
// addPinnedCert twice with same PEM should keep one copy (we can't
// observe the internal list directly, but we can confirm that the
// next call doesn't throw, and that setDynamicCerts() then replaces
// the contents wholesale).
val pem =
"-----BEGIN CERTIFICATE-----\n" +
"MIIBhTCCASugAwIBAgIQEAAAAAAAnRtRrwK0e\n" +
"-----END CERTIFICATE-----\n"
client.addPinnedCert(pem)
client.addPinnedCert(pem)
client.setDynamicCerts(emptyList())
// Replacing with a malformed PEM must not blow up later use; this
// is the same robustness contract ElectrumXClient honours.
client.setDynamicCerts(listOf("not a cert"))
// Smoke test that subsequent calls still work without pinning
// (we're using http:// so pinning isn't engaged).
val captured = mutableListOf<Request>()
val http =
fakeClient(
body = """{"result":{"chain":"main","blocks":1},"error":null,"id":"amethyst"}""",
captured = captured,
)
val c2 = NamecoinCoreRpcClient(httpClientForUrl = { http })
c2.setConfig(NamecoinCoreRpcConfig(url = "http://n/", usePinnedTrustStore = true))
runBlocking {
val r = c2.probe(c2.currentConfig())
assertTrue(r.success)
}
// usePinnedTrustStore is a no-op for http:// URLs — verify the
// request still went through the fake client.
assertTrue(captured.isNotEmpty())
}
@Test
fun `RpcProbeResult carries new fields with sensible defaults`() {
// Public contract: existing call sites that don't set the new
// fields keep compiling and read null/false defaults.
val r =
com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin
.RpcProbeResult(success = true, elapsedMs = 0)
assertEquals(null, r.serverCertPem)
assertEquals(null, r.certFingerprint)
assertFalse(r.tlsHandshakeFailed)
}
}