Merge branch 'main' into claude/add-negentropy-support-PcTpR
This commit is contained in:
+5
-5
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+29
-8
@@ -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(
|
||||
|
||||
+93
@@ -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"))
|
||||
}
|
||||
}
|
||||
+429
-32
@@ -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>,
|
||||
|
||||
+4
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user