From cfb3f1b9fa455d6b15f8b22c9519881cd227b616 Mon Sep 17 00:00:00 2001 From: m Date: Thu, 28 May 2026 03:32:28 +1000 Subject: [PATCH] feat(namecoin): TOFU pin for Namecoin Core RPC TLS path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../com/vitorpamplona/amethyst/AppModules.kt | 24 +- .../settings/NamecoinSettingsScreen.kt | 3 + .../settings/NamecoinSettingsSection.kt | 112 ++++++- .../namecoin/NamecoinCoreRpcClient.kt | 290 +++++++++++++++++- .../namecoin/NamecoinCoreRpcClientTest.kt | 70 +++++ 5 files changed, 491 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 35f74af408..4e04516985 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -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 } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt index 7a1b469579..79adc0dfc1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt @@ -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 -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt index f3fad1f002..2a1acbdb4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt @@ -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(null) } + // Cert that the probe captured but the user hasn't yet accepted/rejected. + var pendingPin by remember { mutableStateOf(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, + ) } } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinCoreRpcClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinCoreRpcClient.kt index 5ea65c9881..897ab9b27d 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinCoreRpcClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinCoreRpcClient.kt @@ -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() + + /** 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) { + synchronized(this) { + dynamicCerts.clear() + dynamicCerts.addAll(pems) + pinnedFactory = null + } + } + /** * Run `name_show ` 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( + object : X509TrustManager { + override fun checkClientTrusted( + chain: Array, + authType: String, + ) {} + + override fun checkServerTrusted( + chain: Array, + authType: String, + ) {} + + override fun getAcceptedIssuers(): Array = 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().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().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().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, ) diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinCoreRpcClientTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinCoreRpcClientTest.kt index 27ad852ea0..59498b33eb 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinCoreRpcClientTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinCoreRpcClientTest.kt @@ -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() + 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) + } }