Merge branch 'main' into claude/add-negentropy-support-PcTpR

This commit is contained in:
Vitor Pamplona
2026-03-27 08:15:24 -04:00
committed by GitHub
93 changed files with 3073 additions and 744 deletions
@@ -28,7 +28,7 @@ import kotlinx.coroutines.CancellationException
@Stable
class Nip05Client(
val fetcher: Nip05Fetcher,
val namecoinResolver: NamecoinNameResolver? = null,
val namecoinResolverBuilder: (() -> NamecoinNameResolver)? = null,
) : INip05Client {
val parser = Nip05Parser()
@@ -37,8 +37,8 @@ class Nip05Client(
hexKey: HexKey,
): Boolean {
// Namecoin: route .bit domains to blockchain verification
if (namecoinResolver != null && NamecoinNameResolver.isNamecoinIdentifier(nip05.toValue())) {
val result = namecoinResolver.resolve(nip05.toValue())
if (namecoinResolverBuilder != null && NamecoinNameResolver.isNamecoinIdentifier(nip05.toValue())) {
val result = namecoinResolverBuilder().resolve(nip05.toValue())
return result?.pubkey == hexKey
}
@@ -61,8 +61,8 @@ class Nip05Client(
override suspend fun get(nip05: Nip05Id): Nip05KeyInfo? {
// Namecoin: route .bit domains to blockchain resolution
if (namecoinResolver != null && NamecoinNameResolver.isNamecoinIdentifier(nip05.toValue())) {
val result = namecoinResolver.resolve(nip05.toValue()) ?: return null
if (namecoinResolverBuilder != null && NamecoinNameResolver.isNamecoinIdentifier(nip05.toValue())) {
val result = namecoinResolverBuilder().resolve(nip05.toValue()) ?: return null
return Nip05KeyInfo(result.pubkey, result.relays)
}
@@ -44,8 +44,14 @@ data class ElectrumxServer(
val host: String,
val port: Int,
val useSsl: Boolean = true,
/** If true, accept any certificate (self-signed, expired, etc.) */
val trustAllCerts: Boolean = false,
/**
* If true, use the pinned trust store (hardcoded + TOFU-pinned certs
* plus system CAs) instead of the default system-only trust store.
*
* Required for ElectrumX servers that use self-signed certificates,
* which is the norm for the Namecoin ElectrumX ecosystem.
*/
val usePinnedTrustStore: Boolean = false,
)
/**
@@ -72,12 +78,27 @@ sealed class NamecoinLookupException(
) : NamecoinLookupException("All ElectrumX servers unreachable", lastError)
}
/**
* Result of testing connectivity to a single ElectrumX server.
*/
data class ServerTestResult(
val server: ElectrumxServer,
val success: Boolean,
val responseTimeMs: Long,
val error: String? = null,
val tlsVersion: String? = null,
/** PEM-encoded server certificate, captured during test for TOFU pinning. */
val serverCertPem: String? = null,
/** SHA-256 fingerprint of the server certificate. */
val certFingerprint: String? = null,
)
/** Well-known public Namecoin ElectrumX servers (clearnet). */
val DEFAULT_ELECTRUMX_SERVERS =
listOf(
ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true),
ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, trustAllCerts = true),
ElectrumxServer("46.229.238.187", 57002, useSsl = true, trustAllCerts = true),
ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, usePinnedTrustStore = true),
ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, usePinnedTrustStore = true),
ElectrumxServer("46.229.238.187", 57002, useSsl = true, usePinnedTrustStore = true),
)
/** Tor-preferred server list: onion primary, clearnet fallback. */
@@ -87,8 +108,8 @@ val TOR_ELECTRUMX_SERVERS =
"i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion",
50002,
useSsl = true,
trustAllCerts = true,
usePinnedTrustStore = true,
),
ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true),
ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, trustAllCerts = true),
ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, usePinnedTrustStore = true),
ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, usePinnedTrustStore = true),
)
@@ -21,10 +21,15 @@
package com.vitorpamplona.quartz.nip51Lists
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.fastAny
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
@@ -35,23 +40,70 @@ class PinListEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun pins() = tags.filter { it.size > 1 && it[0] == "pin" }.map { it[1] }
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
EventHintProvider {
override fun eventHints() = tags.mapNotNull(EventBookmark::parseAsHint)
override fun linkedEventIds() = tags.mapNotNull(EventBookmark::parseId)
fun countPins() = tags.count(EventBookmark::isTagged)
fun pinnedEvents(): List<EventBookmark> = tags.mapNotNull(EventBookmark::parse)
fun isPinned(eventId: HexKey): Boolean = tags.any { EventBookmark.isTagged(it, eventId) }
companion object {
const val KIND = 33888
const val ALT = "Pinned Posts"
const val KIND = 10001
const val ALT = "Pinned Notes"
fun createPinAddress(pubKey: HexKey) = Address(KIND, pubKey, "")
suspend fun create(
pins: List<String>,
pin: EventBookmark,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): PinListEvent {
val tags = mutableListOf<Array<String>>()
pins.forEach { tags.add(arrayOf("pin", it)) }
tags.add(AltTag.assemble(ALT))
val tags = arrayOf(pin.toTagArray(), AltTag.assemble(ALT))
return signer.sign(createdAt, KIND, tags, "")
}
return signer.sign(createdAt, KIND, tags.toTypedArray(), "")
suspend fun add(
earlierVersion: PinListEvent,
pin: EventBookmark,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): PinListEvent =
resign(
tags = earlierVersion.tags.plus(pin.toTagArray()),
signer = signer,
createdAt = createdAt,
)
suspend fun remove(
earlierVersion: PinListEvent,
pin: EventBookmark,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): PinListEvent =
resign(
tags = earlierVersion.tags.remove(pin.toTagIdOnly()),
signer = signer,
createdAt = createdAt,
)
suspend fun resign(
tags: TagArray,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): PinListEvent {
val newTags =
if (tags.fastAny(AltTag::match)) {
tags
} else {
tags + AltTag.assemble(ALT)
}
return signer.sign(createdAt, KIND, newTags, "")
}
}
}
@@ -20,47 +20,16 @@
*/
package com.vitorpamplona.quartz.utils
import io.kotlingeekdev.urireference.URIReference
import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector
object Rfc3986 {
fun normalize(uri: String): String = URIReference.parse(uri).normalize().toString()
fun parse(url: String) = UrlDetector(url).detect()[0]
fun isValidUrl(url: String): Boolean =
runCatching {
URIReference.parse(url)
}.isSuccess
fun normalize(uri: String): String = parse(uri).fullUrl
fun normalizeAndRemoveFragment(url: String): String =
URIReference
.parse(url)
.normalize()!!
.toStringNoFragment()
.internIfPossible()
fun isValidUrl(url: String): Boolean = runCatching { parse(url) }.isSuccess
fun host(url: String): String =
URIReference
.parse(url)
.host
?.value
.toString()
}
fun URIReference.toStringSchemeHost(): String {
val sb = StringBuilder()
if (scheme != null) sb.append(scheme).append(":")
if (authority != null) sb.append("//").append(authority.toString())
return sb.toString()
}
fun URIReference.toStringNoFragment(): String {
val sb = StringBuilder()
if (scheme != null) sb.append(scheme).append(":")
if (host != null) sb.append("//").append(host.toString())
if (path != null) sb.append(path)
if (query != null) sb.append("?").append(query)
return sb.toString()
fun normalizeAndRemoveFragment(url: String): String = parse(url).fullUrlWithoutFragment.internIfPossible()
fun host(url: String): String = parse(url).host
}
@@ -98,9 +98,9 @@ class Url(
if (index != -1) {
_scheme = _scheme!!.substring(0, index)
}
_scheme = _scheme!!.lowercase()
} else if (!originalUrl.startsWith("//")) {
_scheme =
DEFAULT_SCHEME
_scheme = DEFAULT_SCHEME
}
}
return _scheme ?: ""
@@ -125,7 +125,10 @@ class Url(
val host: String
get() {
if (this.rawHost == null) {
this.rawHost = getPart(UrlPart.HOST)
this.rawHost =
getPart(UrlPart.HOST)?.let {
lowercaseLiteralChars(normalizeComponent(it))
}
if (exists(UrlPart.PORT)) {
this.rawHost =
rawHost?.let {
@@ -142,8 +145,7 @@ class Url(
val port: Int
get() {
if (_port == 0) {
val portString =
getPart(UrlPart.PORT)
val portString = getPart(UrlPart.PORT)
if (!portString.isNullOrEmpty()) {
_port = portString.toIntOrNull() ?: -1
} else {
@@ -156,14 +158,9 @@ class Url(
val path: String?
get() {
if (this.rawPath == null) {
this.rawPath =
if (exists(UrlPart.PATH)) {
getPart(
UrlPart.PATH,
)
} else {
"/"
}
this.rawPath = getPart(UrlPart.PATH)?.let {
normalizeComponent(removeDotSegments(it))
} ?: "/"
}
return this.rawPath
}
@@ -171,7 +168,10 @@ class Url(
val query: String
get() {
if (_query == null) {
_query = getPart(UrlPart.QUERY)
_query =
getPart(UrlPart.QUERY)?.let {
normalizeComponent(it)
} ?: ""
}
return _query ?: ""
}
@@ -179,7 +179,9 @@ class Url(
val fragment: String
get() {
if (_fragment == null) {
_fragment = getPart(UrlPart.FRAGMENT)
_fragment = getPart(UrlPart.FRAGMENT)?.let {
normalizeComponent(it)
} ?: ""
}
return _fragment ?: ""
}
@@ -190,10 +192,10 @@ class Url(
val usernamePasswordParts: List<String> =
usernamePassword.substring(0, usernamePassword.length - 1).split(":")
if (usernamePasswordParts.size == 1) {
_username = usernamePasswordParts[0]
_username = normalizeComponent(usernamePasswordParts[0])
} else if (usernamePasswordParts.size == 2) {
_username = usernamePasswordParts[0]
_password = usernamePasswordParts[1]
_username = normalizeComponent(usernamePasswordParts[0])
_password = normalizeComponent(usernamePasswordParts[1])
}
}
}
@@ -242,7 +244,206 @@ class Url(
}
}
/**
* Removes dot segments from the given path as stated in
* ["RFC 3986, 5.2.4. Remove Dot Segments"](https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4).
*
* @param path
* The path from which dot segments are to be removed.
*
* @return
* The path from which dot segments are removed.
*/
fun removeDotSegments(path: String): String {
// Initialize the input with the no-appended path components and the output
// with the empty string.
var input = path
var output = ""
// While the input is not empty, loop the following steps.
while (input.isNotEmpty()) {
// If the input begins with a prefix of "../" or "./", then
// remove that prefix from the input;
if (DOT_DOT_SLASH.find(input) != null) {
input = DOT_DOT_SLASH.replaceFirst(input, "")
continue
}
// If the input begins with a prefix of "/./" or "/.", where
// "." is a complete path segment, then replace that prefix
// with "/" in the input.
if (SLASH_DOT_SLASH.find(input) != null) {
input = SLASH_DOT_SLASH.replaceFirst(input, "/")
continue
}
// If the input begins with a prefix of "/../" or "/..",
// where ".." is a complete path segment, then replace that
// prefix with "/" in the input and remove the last segment
// and its preceding "/" (if any) from the output.
if (SLASH_DOT_DOT_SLASH.find(input) != null) {
input = SLASH_DOT_DOT_SLASH.replaceFirst(input, "/")
output = dropLastSegment(output, true)
continue
}
// If the input consists only of "." or "..", then remove
// that from the input.
if (DOT_OR_DOT_DOT.find(input) != null) {
input = DOT_OR_DOT_DOT.replaceFirst(input, "")
continue
}
// Move the first path segment in the input buffer to the
// end of the output, including the initial "/" character
// (if any) and any subsequent characters up to, but not
// including, the next "/" character or the end of the input.
val matchResult = MOVE_REGEX.find(input)
if (matchResult != null) {
input = matchResult.groups["remaining"]!!.value
output += matchResult.groups["firstsegment"]!!.value
continue
}
}
return output
}
/**
* Drops the last segment (= characters after the last slash) of a path and
* optionally the last slash. If the path doesn't contain slash, an empty string
* is returned.
*
* @param path
* The path.
*
* @param dropLastSlash
* Whether or not to drop the last slash if present.
*
* @return The path from which the last segment is removed.
*/
fun dropLastSegment(
path: String,
dropLastSlash: Boolean,
): String {
// The regular expression for the target.
val m = if (dropLastSlash) DROP_LAST_SLASH_REGEX else DROP_LAST_SEGMENT_REGEX
// Find the target. (Any inputs matches the pattern.)
m.find(path)
// Drop the target.
return m.replaceFirst(path, "")
}
/**
* Unreserved characters per RFC 3986 §2.3:
* ALPHA / DIGIT / "-" / "." / "_" / "~"
*/
private fun isUnreserved(c: Char): Boolean = c.isLetter() || c.isDigit() || c == '-' || c == '.' || c == '_' || c == '~'
/**
* Normalize percent-encoded triplets in a single URI component string.
*
* For each %XX triplet:
* - If the decoded byte is an unreserved ASCII character → decode it
* - Otherwise → keep encoded but uppercase the hex digits
*
* Non-ASCII bytes (e.g. UTF-8 multi-byte sequences) are left encoded
* since they cannot be unreserved characters.
*/
fun normalizeComponent(input: String): String {
val sb = StringBuilder(input.length)
var i = 0
while (i < input.length) {
val c = input[i]
if (c == '%' && i + 2 < input.length) {
val hex = input.substring(i + 1, i + 3)
// Validate that both characters are valid hex digits
if (hex.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' }) {
val byteValue = hex.toInt(16)
// Only consider single-byte ASCII values for potential decoding
if (byteValue < 0x80) {
val decoded = byteValue.toChar()
if (isUnreserved(decoded)) {
// Decode: replace %XX with the literal character
sb.append(decoded)
i += 3
continue
}
}
// Keep encoded, but uppercase the hex digits
sb.append('%')
sb.append(hex.uppercase())
i += 3
continue
}
}
// Regular character — pass through as-is
sb.append(c)
i++
}
return sb.toString()
}
/**
* Lowercase only the literal (non-percent-encoded) characters in a string.
* Percent-encoded triplets are left untouched so their uppercased hex digits
* are not inadvertently lowercased.
*/
private fun lowercaseLiteralChars(input: String): String {
val sb = StringBuilder(input.length)
var i = 0
while (i < input.length) {
if (input[i] == '%' && i + 2 < input.length) {
sb.append(input[i])
sb.append(input[i + 1])
sb.append(input[i + 2])
i += 3
} else {
sb.append(input[i].lowercaseChar())
i++
}
}
return sb.toString()
}
companion object {
val DROP_LAST_SLASH_REGEX = Regex("\\/?[^/]*$")
val DROP_LAST_SEGMENT_REGEX = Regex("[^/]*$")
// If the input begins with a prefix of "../" or "./", then
// remove that prefix from the input;
val DOT_DOT_SLASH = Regex("^\\.?\\./")
// If the input begins with a prefix of "/./" or "/.", where
// "." is a complete path segment, then replace that prefix
// with "/" in the input.
val SLASH_DOT_SLASH = Regex("^\\/\\.(\\/|$)")
// If the input begins with a prefix of "/../" or "/..",
// where ".." is a complete path segment, then replace that
// prefix with "/" in the input and remove the last segment
// and its preceding "/" (if any) from the output.
val SLASH_DOT_DOT_SLASH = Regex("^\\/\\.\\.(\\/|$)")
// If the input consists only of "." or "..", then remove
// that from the input.
val DOT_OR_DOT_DOT = Regex("^\\.?\\.$")
// Move the first path segment in the input buffer to the
// end of the output, including the initial "/" character
// (if any) and any subsequent characters up to, but not
// including, the next "/" character or the end of the input.
val MOVE_REGEX = Regex("^(?<firstsegment>\\/?[^/]*)(?<remaining>.*)$")
private const val DEFAULT_SCHEME = "https"
private val SCHEME_PORT_MAP: Map<String, Int> =
mapOf(
@@ -0,0 +1,93 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.utils.urldetector
import com.vitorpamplona.quartz.utils.Rfc3986
import kotlin.test.Test
import kotlin.test.assertEquals
class URIReferenceNormalizerTest {
@Test
fun test_normalize() {
val uriRef1 = Rfc3986.normalize("hTTp://example.com/")
assertEquals("http://example.com/", uriRef1)
val uriRef2 = Rfc3986.normalize("http://example.com/")
assertEquals("http://example.com/", uriRef2)
val uriRef3 = Rfc3986.normalize("http://%75ser@example.com/")
assertEquals("http://user@example.com/", uriRef3)
val uriRef4 = Rfc3986.normalize("http://%e3%83%a6%e3%83%bc%e3%82%b6%e3%83%bc@example.com/")
assertEquals("http://%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC@example.com/", uriRef4)
val uriRef5 = Rfc3986.normalize("http://%65%78%61%6D%70%6C%65.com/")
assertEquals("http://example.com/", uriRef5)
val uriRef6 = Rfc3986.normalize("http://%e4%be%8b.com/")
assertEquals("http://%E4%BE%8B.com/", uriRef6)
val uriRef7 = Rfc3986.normalize("http://LOCALhost/")
assertEquals("http://localhost/", uriRef7)
val uriRef8 = Rfc3986.normalize("http://example.com")
assertEquals("http://example.com/", uriRef8)
val uriRef9 = Rfc3986.normalize("http://example.com/%61/%62/%63/")
assertEquals("http://example.com/a/b/c/", uriRef9)
val uriRef10 = Rfc3986.normalize("http://example.com/%e3%83%91%e3%82%b9/")
assertEquals("http://example.com/%E3%83%91%E3%82%B9/", uriRef10)
val uriRef11 = Rfc3986.normalize("http://example.com/a/b/c/../d/")
assertEquals("http://example.com/a/b/d/", uriRef11)
val uriRef12 = Rfc3986.normalize("http://example.com:80/")
assertEquals("http://example.com/", uriRef12)
}
@Test
fun moreTests() {
// From our conversation
assertEquals("http://%E4%BE%8B.com/", Rfc3986.normalize("http://%e4%be%8b.com/"))
// Unreserved chars that should be decoded
assertEquals("http://example.com/~user/ABC", Rfc3986.normalize("http://example.com/%7euser/%41BC"))
// Reserved chars that must NOT be decoded
assertEquals("http://example.com/path%2Fsegment?key%3Dvalue", Rfc3986.normalize("http://example.com/path%2Fsegment?key%3Dvalue"))
// Mixed: some decodable, some not, lowercase hex
assertEquals("http://example.com/~%2Fa%3F", Rfc3986.normalize("http://EXAMPLE.COM/%7e%2f%61%3F"))
// Userinfo
assertEquals("http://user%40name@example.com/", Rfc3986.normalize("http://user%40name@example.com/"))
// With port and query and fragment
assertEquals("https://example.com:8080/foo~bar?a%3D1&b%2Bc#frag%23ment", Rfc3986.normalize("https://Example.COM:8080/foo%7ebar?a%3D1&b%2Bc#frag%23ment"))
// IPv6
assertEquals("http://[::1]:8080/path", Rfc3986.normalize("http://[::1]:8080/path"))
// Already normalized
assertEquals("http://example.com/~user", Rfc3986.normalize("http://example.com/~user"))
}
}
@@ -38,19 +38,22 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import java.io.BufferedReader
import java.io.ByteArrayInputStream
import java.io.InputStreamReader
import java.io.PrintWriter
import java.net.InetSocketAddress
import java.net.Socket
import java.security.KeyStore
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.cert.X509Certificate
import java.security.cert.CertificateFactory
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import javax.net.SocketFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
/**
@@ -87,25 +90,6 @@ class ElectrumXClient(
private val requestId = AtomicInteger(0)
private val serverMutexes = ConcurrentHashMap<String, Mutex>()
companion object {
private const val PROTOCOL_VERSION = "1.4"
/**
* Namecoin names expire this many blocks after their last update.
* From chainparams.cpp: consensus.nNameExpirationDepth = 36000
* (~250 days at ~10 min/block).
*/
const val NAME_EXPIRE_DEPTH = 36_000
// Namecoin script opcodes
private const val OP_NAME_UPDATE: Byte = 0x53 // OP_3 repurposed by Namecoin
private const val OP_2DROP: Byte = 0x6d
private const val OP_DROP: Byte = 0x75
private const val OP_RETURN: Byte = 0x6a
private const val OP_PUSHDATA1: Byte = 0x4c
private const val OP_PUSHDATA2: Byte = 0x4d
}
/**
* Perform a name_show lookup against the given ElectrumX server.
*
@@ -167,6 +151,162 @@ class ElectrumXClient(
throw NamecoinLookupException.ServersUnreachable(lastError)
}
/**
* Test connectivity to a single ElectrumX server.
*
* Connects, negotiates protocol version, and optionally resolves a test
* name. Returns detailed results including response time, TLS version,
* and human-readable error messages.
*
* @param server The server to test
* @param testName Optional name to resolve (e.g. "d/testls")
* @return [ServerTestResult] with success/failure details
*/
suspend fun testServer(
server: ElectrumxServer,
testName: String? = "d/testls",
): ServerTestResult =
withContext(Dispatchers.IO) {
val startTime = System.currentTimeMillis()
try {
val socket = createSocket(server)
socket.soTimeout = readTimeoutMs.toInt()
var tlsVersion: String? = null
var serverCertPem: String? = null
var certFingerprint: String? = null
if (socket is javax.net.ssl.SSLSocket) {
tlsVersion = socket.session.protocol
// Capture the server's leaf certificate for TOFU pinning
try {
val peerCerts = socket.session.peerCertificates
if (peerCerts.isNotEmpty() && peerCerts[0] is java.security.cert.X509Certificate) {
val x509 = peerCerts[0] as java.security.cert.X509Certificate
// PEM encode
val encoded =
java.util.Base64
.getMimeEncoder(76, "\n".toByteArray())
.encodeToString(x509.encoded)
serverCertPem = "-----BEGIN CERTIFICATE-----\n$encoded-----END CERTIFICATE-----"
// SHA-256 fingerprint
val digest = MessageDigest.getInstance("SHA-256").digest(x509.encoded)
certFingerprint = digest.joinToString(":") { "%02X".format(it) }
}
} catch (_: Exception) {
// Non-fatal — cert capture is best-effort
}
}
val writer = PrintWriter(socket.getOutputStream(), true)
val reader = BufferedReader(InputStreamReader(socket.getInputStream()))
try {
// Negotiate protocol version
val versionReq =
buildRpcRequest(
"server.version",
listOf("AmethystNMC/0.1", PROTOCOL_VERSION),
)
writer.println(versionReq)
val versionResponse =
reader.readLine()
?: return@withContext ServerTestResult(
server = server,
success = false,
responseTimeMs = System.currentTimeMillis() - startTime,
error = "Server returned empty response",
tlsVersion = tlsVersion,
)
// If a test name is provided, try to resolve it
if (testName != null) {
val nameScript =
buildNameIndexScript(testName.toByteArray(Charsets.US_ASCII))
val scriptHash = electrumScriptHash(nameScript)
val historyReq =
buildRpcRequest(
"blockchain.scripthash.get_history",
listOf(scriptHash),
)
writer.println(historyReq)
reader.readLine() // consume response
}
val elapsed = System.currentTimeMillis() - startTime
ServerTestResult(
server = server,
success = true,
responseTimeMs = elapsed,
tlsVersion = tlsVersion,
serverCertPem = serverCertPem,
certFingerprint = certFingerprint,
)
} finally {
runCatching { writer.close() }
runCatching { reader.close() }
runCatching { socket.close() }
}
} catch (e: java.net.ConnectException) {
ServerTestResult(
server = server,
success = false,
responseTimeMs = System.currentTimeMillis() - startTime,
error = "Connection refused",
)
} catch (e: java.net.SocketTimeoutException) {
ServerTestResult(
server = server,
success = false,
responseTimeMs = System.currentTimeMillis() - startTime,
error = "Connection timed out after ${connectTimeoutMs / 1000}s",
)
} catch (e: java.net.UnknownHostException) {
ServerTestResult(
server = server,
success = false,
responseTimeMs = System.currentTimeMillis() - startTime,
error = "Server unreachable (DNS resolution failed)",
)
} catch (e: javax.net.ssl.SSLHandshakeException) {
val detail =
if (e.message?.contains("self-signed", ignoreCase = true) == true ||
e.message?.contains("anchor", ignoreCase = true) == true
) {
"TLS handshake failed (self-signed certificate rejected)"
} else {
"TLS handshake failed: ${e.message?.take(100) ?: "unknown error"}"
}
ServerTestResult(
server = server,
success = false,
responseTimeMs = System.currentTimeMillis() - startTime,
error = detail,
)
} catch (e: javax.net.ssl.SSLException) {
ServerTestResult(
server = server,
success = false,
responseTimeMs = System.currentTimeMillis() - startTime,
error = "TLS error: ${e.message?.take(100) ?: "unknown"}",
)
} catch (e: java.io.IOException) {
ServerTestResult(
server = server,
success = false,
responseTimeMs = System.currentTimeMillis() - startTime,
error = "I/O error: ${e.message?.take(100) ?: "unknown"}",
)
} catch (e: Exception) {
ServerTestResult(
server = server,
success = false,
responseTimeMs = System.currentTimeMillis() - startTime,
error = e.message?.take(150) ?: "Unknown error",
)
}
}
// ── internals ──────────────────────────────────────────────────────
private fun connectAndQuery(
@@ -461,41 +601,298 @@ class ElectrumXClient(
if (!server.useSsl) return baseSocket
// Upgrade to TLS over the already-connected (possibly proxied) socket.
// When usePinnedTrustStore is set, we use a pinned trust store that
// contains the known ElectrumX server certs plus system CAs. This is
// required because Samsung One UI 7 (Android 16) and GrapheneOS
// reject connections that use a no-op "trust-all" X509TrustManager.
val sslFactory =
if (server.trustAllCerts) {
trustAllSslFactory()
if (server.host.endsWith(".onion")) {
// .onion addresses: prefer the pinned factory (the .onion server's
// cert is already in PINNED_ELECTRUMX_CERTS). Fall back to trust-all
// only if the pinned handshake fails — this keeps compatibility with
// .onion servers whose certs aren't pinned yet, while avoiding a
// blanket trust-all that hardened TLS stacks (GrapheneOS, Samsung
// Knox) may reject at the Conscrypt/BoringSSL layer.
cachedPinnedSslFactory()
} else if (server.usePinnedTrustStore) {
cachedPinnedSslFactory()
} else {
SSLSocketFactory.getDefault() as SSLSocketFactory
}
return sslFactory.createSocket(baseSocket, server.host, server.port, true)
val sslSocket: Socket
try {
sslSocket = sslFactory.createSocket(baseSocket, server.host, server.port, true)
} catch (e: javax.net.ssl.SSLHandshakeException) {
if (server.host.endsWith(".onion")) {
// Pinned factory failed for .onion — fall back to trust-all.
// This is safe: Tor provides E2E authentication via the onion
// address, and the proxied socket bypasses Knox/GrapheneOS
// trust-all rejection in practice.
val fallbackSocket = onionSslFactory().createSocket(baseSocket, server.host, server.port, true)
if (fallbackSocket is javax.net.ssl.SSLSocket) {
val supported = fallbackSocket.supportedProtocols
val modern = supported.filter { it == "TLSv1.2" || it == "TLSv1.3" }
if (modern.isNotEmpty()) {
fallbackSocket.enabledProtocols = modern.toTypedArray()
}
}
return fallbackSocket
}
throw e
}
// Enforce TLSv1.2+ — some OEM Conscrypt forks (Xiaomi MIUI, OnePlus ColorOS)
// may negotiate TLS 1.0/1.1 by default for raw socket upgrades.
if (sslSocket is javax.net.ssl.SSLSocket) {
val supported = sslSocket.supportedProtocols
val modern = supported.filter { it == "TLSv1.2" || it == "TLSv1.3" }
if (modern.isNotEmpty()) {
sslSocket.enabledProtocols = modern.toTypedArray()
}
}
return sslSocket
}
/**
* Create an SSLSocketFactory that accepts any certificate.
* Used for servers with self-signed certificates.
* Fallback SSLSocketFactory for .onion addresses when the pinned
* factory fails (e.g. cert rotated, unknown .onion server).
*
* Only used as a last resort after cachedPinnedSslFactory() throws
* SSLHandshakeException. This is safe because:
* 1. The connection is already end-to-end encrypted by Tor.
* 2. The onion address IS the server's identity proof (public key hash).
* 3. Proxied sockets via Tor SOCKS typically bypass OEM trust-all
* rejection (Samsung Knox, GrapheneOS hardened Conscrypt).
*
* Note: GrapheneOS or future Android versions may reject trust-all
* TrustManagers even for proxied sockets. If this fallback stops
* working, the .onion server's cert should be added to
* PINNED_ELECTRUMX_CERTS (it's already there for the known server).
*/
private fun trustAllSslFactory(): SSLSocketFactory {
val trustAllCerts =
private fun onionSslFactory(): SSLSocketFactory {
val trustAll =
arrayOf<TrustManager>(
object : X509TrustManager {
override fun checkClientTrusted(
chain: Array<X509Certificate>,
chain: Array<java.security.cert.X509Certificate>,
authType: String,
) {}
override fun checkServerTrusted(
chain: Array<X509Certificate>,
chain: Array<java.security.cert.X509Certificate>,
authType: String,
) {}
override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
},
)
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, trustAllCerts, SecureRandom())
val ctx =
try {
SSLContext.getInstance("TLSv1.2")
} catch (_: Exception) {
SSLContext.getInstance("TLS")
}
ctx.init(null, trustAll, SecureRandom())
return ctx.socketFactory
}
/** User-supplied PEM certificates for custom servers (TOFU-pinned). */
private val dynamicCerts = mutableListOf<String>()
/** Lazy-cached SSLSocketFactory for pinned certs. Thread-safe via volatile + DCL. */
@Volatile
private var pinnedFactory: SSLSocketFactory? = null
private fun cachedPinnedSslFactory(): SSLSocketFactory {
pinnedFactory?.let { return it }
synchronized(this) {
pinnedFactory?.let { return it }
return buildPinnedSslFactory().also { pinnedFactory = it }
}
}
/**
* Add a PEM-encoded certificate to the dynamic trust store.
* Typically called after the user confirms a cert fingerprint via
* the "Test Connection" flow in settings.
*
* Invalidates the cached factory so the next connection picks it up.
*/
fun addPinnedCert(pem: String) {
synchronized(this) {
dynamicCerts.add(pem)
pinnedFactory = null // force rebuild
}
}
/**
* 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
}
}
/**
* Build an SSLSocketFactory that trusts the pinned ElectrumX server
* certificates plus the system CA store.
*
* Previous versions used a "trust-all" TrustManager, but Samsung
* devices running One UI 7 (Android 16) silently reject connections
* that use a no-op X509TrustManager. Pinning the known self-signed
* certs avoids this while maintaining security.
*
* Also handles OEM-specific quirks:
* - Xiaomi MIUI/HyperOS: KeyStore.getDefaultType() may return unexpected
* types; we try the default first, then fall back to "PKCS12".
* - OnePlus ColorOS: some versions require explicit TLSv1.2 protocol.
* - All OEMs: SSLContext("TLSv1.2") is preferred over ("TLS") which may
* resolve to TLS 1.0 on older Conscrypt forks.
*/
private fun buildPinnedSslFactory(): SSLSocketFactory {
val ks =
try {
KeyStore.getInstance(KeyStore.getDefaultType()).apply { load(null, null) }
} catch (_: Exception) {
// Fallback for Xiaomi devices where getDefaultType() returns an unsupported type
KeyStore.getInstance("PKCS12").apply { load(null, null) }
}
val cf = CertificateFactory.getInstance("X.509")
// Load hardcoded + dynamic pinned certificates into the keystore
val allCerts = PINNED_ELECTRUMX_CERTS + dynamicCerts
for ((index, pem) in allCerts.withIndex()) {
try {
val cert = cf.generateCertificate(ByteArrayInputStream(pem.toByteArray(Charsets.US_ASCII)))
ks.setCertificateEntry("electrumx_$index", cert)
} catch (_: Exception) {
// Skip malformed certs — the remaining ones may still work
}
}
// Also load system CA certificates so that servers with real certs work too
val systemTmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
systemTmf.init(null as KeyStore?) // null = system default
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 return certs that can't be re-inserted; skip
}
}
}
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
tmf.init(ks)
// Prefer TLSv1.2 explicitly — SSLContext.getInstance("TLS") can resolve
// to TLS 1.0 on some OEM Conscrypt forks (Xiaomi, OnePlus).
val sslContext =
try {
SSLContext.getInstance("TLSv1.2")
} catch (_: Exception) {
SSLContext.getInstance("TLS")
}
sslContext.init(null, tmf.trustManagers, SecureRandom())
return sslContext.socketFactory
}
companion object {
private const val PROTOCOL_VERSION = "1.4"
/**
* Namecoin names expire this many blocks after their last update.
* From chainparams.cpp: consensus.nNameExpirationDepth = 36000
* (~250 days at ~10 min/block).
*/
const val NAME_EXPIRE_DEPTH = 36_000
// Namecoin script opcodes
private const val OP_NAME_UPDATE: Byte = 0x53 // OP_3 repurposed by Namecoin
private const val OP_2DROP: Byte = 0x6d
private const val OP_DROP: Byte = 0x75
private const val OP_RETURN: Byte = 0x6a
private const val OP_PUSHDATA1: Byte = 0x4c
private const val OP_PUSHDATA2: Byte = 0x4d
/**
* PEM-encoded certificates for the well-known Namecoin ElectrumX servers.
*
* These are self-signed certificates that cannot be verified by the
* system CA store. We pin them explicitly so that connections succeed
* on devices with strict TLS enforcement (e.g. Samsung One UI 7).
*
* To update: `echo | openssl s_client -connect HOST:PORT 2>/dev/null | openssl x509 -outform PEM`
* For .onion: `python3 -c "import socks,ssl,socket,base64; s=socks.socksocket(); s.set_proxy(socks.SOCKS5,'127.0.0.1',9050); s.connect(('HOST',PORT)); ctx=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE; ss=ctx.wrap_socket(s); print(base64.encodebytes(ss.getpeercert(True)).decode())"`
*/
private val PINNED_ELECTRUMX_CERTS =
listOf(
// electrumx.testls.space:50002 — expires 2027-05-04
// Also covers the .onion hidden service (same operator, same cert):
// i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion:50002
// SHA-256: 53:65:D5:BB:26:19:F5:40:1C:D8:8E:FC:AF:FB:A5:B2:A0:EA:7A:99:2D:F7:0F:05:7E:9B:CD:50:36:C7:79:9C
"""
-----BEGIN CERTIFICATE-----
MIIDwzCCAqsCFGGKT5mjh7oN98aNyjOCiqafL8VyMA0GCSqGSIb3DQEBCwUAMIGd
MQswCQYDVQQGEwJVUzEQMA4GA1UECAwHQ2hpY2FnbzEQMA4GA1UEBwwHQ2hpY2Fn
bzESMBAGA1UECgwJSW50ZXJuZXRzMQ8wDQYDVQQLDAZJbnRlcncxHjAcBgNVBAMM
FWVsZWN0cnVtLnRlc3Rscy5zcGFjZTElMCMGCSqGSIb3DQEJARYWbWpfZ2lsbF84
OUBob3RtYWlsLmNvbTAeFw0yMjA1MDUwNjIzNDFaFw0yNzA1MDQwNjIzNDFaMIGd
MQswCQYDVQQGEwJVUzEQMA4GA1UECAwHQ2hpY2FnbzEQMA4GA1UEBwwHQ2hpY2Fn
bzESMBAGA1UECgwJSW50ZXJuZXRzMQ8wDQYDVQQLDAZJbnRlcncxHjAcBgNVBAMM
FWVsZWN0cnVtLnRlc3Rscy5zcGFjZTElMCMGCSqGSIb3DQEJARYWbWpfZ2lsbF84
OUBob3RtYWlsLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAO4H
+PKCdiiz3jNOA77aAmS2YaU7eOQ8ZGliEVr/PlLcgF5gmthb2DI6iK4KhC1ad34G
1n9IhkXPhkVJ94i8wB3uoTBlA7mI5h59m01yhzSkJAoYoU/i6DM9ipbakqWFCTEp
P+yE216NTU5MbYwThZdRSAIIABe9RyIliMSidyrwHvKBLfnJPFScghW6rhBWN7PG
PA8k0MFGzf+HXbpnV/jAvz08ZC34qiBIjkJrTgh49JweyoZKdppyJcH4UbkslJ2t
YUJR3oURBvrPj+D7TwLVRbX36ul7r4+dP3IjgmljsSAHDK4N/PfWrCBdlj9Pc1Cp
yX+ZDh8X2NrL4ukHoVMCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAeVj6VZNmY/Vb
nhzrC7xBSHqVWQ1wkLOClLsdvgKP8cFFJuUoCMQU5bPMi7nWnkfvvsIKH4Eibk5K
fqiA9jVsY0FHvQ8gP3KMk1LVuUf/sTcRe5itp3guBOSk/zXZUD5tUz/oRk3k+rdc
MsInqhomjNy/dqYmD6Wm4DNPjZh6fWy+AVQKVNOI2t4koaVdpoi8Uv8h4gFGPbdI
sVmtoGiIGkKNIWum+6mnF6PfynNrLk+ztH4TrdacVNeoJUPYEAxOuesWXFy3H4r+
HKBqA4xAzyjgKLPqoWnjSu7gxj1GIjBhnDxkM6wUOnDq8A0EqxR+A17OcXW9sZ2O
2ZIVwmtnyA==
-----END CERTIFICATE-----
""".trimIndent(),
// nmc2.bitcoins.sk:57002 / 46.229.238.187:57002 — expires 2030-10-22
"""
-----BEGIN CERTIFICATE-----
MIID+TCCAuGgAwIBAgIUdmJGukmfPvqmAYpTfuGcjRoYHJ8wDQYJKoZIhvcNAQEL
BQAwgYsxCzAJBgNVBAYTAlNLMREwDwYDVQQIDAhTbG92YWtpYTETMBEGA1UEBwwK
QnJhdGlzbGF2YTEUMBIGA1UECgwLYml0Y29pbnMuc2sxGTAXBgNVBAMMEG5tYzIu
Yml0Y29pbnMuc2sxIzAhBgkqhkiG9w0BCQEWFGRlYWZib3lAY2ljb2xpbmEub3Jn
MB4XDTIwMTAyNDE5MjQzOVoXDTMwMTAyMjE5MjQzOVowgYsxCzAJBgNVBAYTAlNL
MREwDwYDVQQIDAhTbG92YWtpYTETMBEGA1UEBwwKQnJhdGlzbGF2YTEUMBIGA1UE
CgwLYml0Y29pbnMuc2sxGTAXBgNVBAMMEG5tYzIuYml0Y29pbnMuc2sxIzAhBgkq
hkiG9w0BCQEWFGRlYWZib3lAY2ljb2xpbmEub3JnMIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEAzBUkZNDfaz7kc28l5tDKohJjekWmz1ynzfGx3ZLsqOZE
c+kNfcMaWU+zT/j0mV6pX6KSH7G9pPAku+8PRdKRq+d63wiJDEjGSaFztQWKW6L1
vTxgCK5gu+Eir3BkTagJObsrLKS+T6qH610/3+btGgoR3lunB5TzCgB/9oQanjDW
zjg2CwmxgR5Iw1Eqfenx7zkSK33FSXSF2SvbUs1Atj2oPU4DLivyrx0RaUmaPemn
cmcpnax+py4pQeB6dJWU1INhzXt3hTJRyoqsSGY3vCECIKIBIkh8GsYjAX4z+Y9y
6pJx0da2b88qPWdsoxaIMvrQiuWknDrSJwAyw2Yd8QIDAQABo1MwUTAdBgNVHQ4E
FgQUT2J83B2/9jxGGdFeWrxMohTzHNwwHwYDVR0jBBgwFoAUT2J83B2/9jxGGdFe
WrxMohTzHNwwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAsbxX
wN8tZaXOybImMZCQS7zfxmKl2IAcqu+R01KPfnIfrFqXPsGDDl3rYLkwh1O4/hYQ
NKNW9KTxoJxuBmAkm7EXQQh1XUUzajdEDqDBVRyvR0Z2MdMYnMSAiiMXMl2wUZnc
QXYftBo0HbtfsaJjImQdDjmlmRPSzE/RW6iUe+1cesKBC7e8nVf69Yu/fxO4m083
VWwAstlWJfk1GyU7jzVc8svealg/oIiDoOMe6CFSLx1BDv2FeHSpRdqd3fn+AC73
bK2N2smrHUOQnFijuiFw3WOrjERi0eMhjVNfVu9W9ZYa/Wd6SdIzV55LbG+NpmSf
5W7ix41hRvdT6cTAJA==
-----END CERTIFICATE-----
""".trimIndent(),
)
}
private fun buildRpcRequest(
method: String,
params: List<Any>,
@@ -74,7 +74,7 @@ class CountResultHllSerializationTest {
assertEquals(100, result.count)
assertTrue(result.approximate)
assertNotNull(result.hll)
assertTrue(hll.contentEquals(result.hll!!))
assertTrue(hll.contentEquals(result.hll))
}
@Test
@@ -100,7 +100,7 @@ class CountResultHllSerializationTest {
assertEquals(original.count, deserialized.count)
assertNotNull(deserialized.hll)
assertTrue(hll.contentEquals(deserialized.hll!!))
assertTrue(hll.contentEquals(deserialized.hll))
}
@Test
@@ -115,7 +115,7 @@ class CountResultHllSerializationTest {
val result = CountResultKSerializer.deserializeFromElement(jsonObject)
assertEquals(42, result.count)
assertNotNull(result.hll)
assertEquals(256, result.hll!!.size)
assertEquals(5, result.hll!![0].toInt() and 0xFF)
assertEquals(256, result.hll.size)
assertEquals(5, result.hll[0].toInt() and 0xFF)
}
}