mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3
@@ -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 ->
|
||||
|
||||
+111
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+287
-3
@@ -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,
|
||||
)
|
||||
|
||||
+70
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user