feat(lotw): PBES2 .p12 parsing, station region fields, upload error codes

This commit is contained in:
atsunatsu committed 2026-09-26 18:33:25 +08:00
1 parent 5957f30af2
commit 3be2e4b9e8
8 files changed
+494 -13

No files matched your search

@@ -3,7 +3,11 @@ package com.rtbishop.look4sat.core.data.lotw
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.displayMode import com.rtbishop.look4sat.core.domain.logbook.displayMode
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
import com.rtbishop.look4sat.core.domain.repository.LoTWRegionField
import com.rtbishop.look4sat.core.domain.repository.LoTWRegionOption
import com.rtbishop.look4sat.core.domain.repository.LoTWStation import com.rtbishop.look4sat.core.domain.repository.LoTWStation
import com.rtbishop.look4sat.core.domain.repository.LoTWStationMeta
import com.rtbishop.look4sat.core.domain.repository.LoTWZonePair
import org.w3c.dom.Element import org.w3c.dom.Element
import org.xml.sax.InputSource import org.xml.sax.InputSource
import org.xml.sax.SAXException import org.xml.sax.SAXException
@@ -28,6 +32,8 @@ internal class LoTWConfig(input: InputStream) {
private val spec = config.elements("sigspec").single { it.getAttribute("version") == "2.0" } private val spec = config.elements("sigspec").single { it.getAttribute("version") == "2.0" }
val stationOrder = spec.elements("tSTATION").single().childElements().map { it.tagName } val stationOrder = spec.elements("tSTATION").single().childElements().map { it.tagName }
val contactOrder = spec.elements("tCONTACT").single().childElements().map { it.tagName } val contactOrder = spec.elements("tCONTACT").single().childElements().map { it.tagName }
private val primaryRegionFields = setOf("US_STATE", "CA_PROVINCE", "RU_OBLAST", "CN_PROVINCE", "AU_STATE", "JA_PREFECTURE", "FI_KUNTA")
private val secondaryRegionFields = setOf("US_COUNTY", "JA_CITY_GUN_KU")
private val bands = config.elements("bands").single().elements("band") private val bands = config.elements("bands").single().elements("band")
private val satellites = config.elements("satellite").associateBy { it.getAttribute("name").uppercase(Locale.US) } private val satellites = config.elements("satellite").associateBy { it.getAttribute("name").uppercase(Locale.US) }
private val modes = config.elements("modes").single().elements("mode").map { it.textContent }.toSet() private val modes = config.elements("modes").single().elements("mode").map { it.textContent }.toSet()
@@ -78,6 +84,39 @@ internal class LoTWConfig(input: InputStream) {
return normalized return normalized
} }
/** Region-field metadata and the national zonemap for one DXCC entity. */
fun stationMeta(dxcc: Int): LoTWStationMeta {
val pageFields = config.elements("page").filter { it.getAttribute("dependency") == dxcc.toString() }
.flatMap { it.elements("pageField") }.map { it.textContent }
val primary = pageFields.firstOrNull { it in primaryRegionFields }
val regionField = primary?.let { id ->
val field = config.elements("field").single { it.getAttribute("Id") == id }
val dependency = if (field.getAttribute("dependsOn") == "DXCC") dxcc.toString() else null
val options = field.elements("enums").filter {
it.getAttribute("dependency").isBlank() || it.getAttribute("dependency") == dependency
}.flatMap { it.elements("enum") }.map { enum ->
LoTWRegionOption(
code = enum.getAttribute("value").uppercase(Locale.US),
name = enum.textContent.trim().ifBlank { enum.getAttribute("value") },
zones = parseZonemap(enum.getAttribute("zonemap"))
)
}
LoTWRegionField(id = id, label = field.getAttribute("label"), options = options)
}
val entity = config.elements("dxcc").flatMap { it.elements("entity") }
.firstOrNull { it.getAttribute("arrlId") == dxcc.toString() }
val countryZones = entity?.getAttribute("zonemap")?.let(::parseZonemap).orEmpty()
return LoTWStationMeta(regionField, countryZones)
}
private fun parseZonemap(zonemap: String): List<LoTWZonePair> = zonemap.split(',').mapNotNull { pair ->
val parts = pair.split(':')
if (parts.size != 2) return@mapNotNull null
val itu = parts[0].trim().toIntOrNull() ?: return@mapNotNull null
val cq = parts[1].trim().toIntOrNull() ?: return@mapNotNull null
LoTWZonePair(itu, cq)
}
fun stationFields(station: LoTWStation, dxcc: Int): Map<String, String> { fun stationFields(station: LoTWStation, dxcc: Int): Map<String, String> {
fun normalized(value: String) = value.trim().uppercase(Locale.US) fun normalized(value: String) = value.trim().uppercase(Locale.US)
val grids = station.grid.split(',').map(::normalized).filter(String::isNotBlank).distinct() val grids = station.grid.split(',').map(::normalized).filter(String::isNotBlank).distinct()
@@ -103,8 +142,8 @@ internal class LoTWConfig(input: InputStream) {
} }
val pageFields = config.elements("page").filter { it.getAttribute("dependency") == dxcc.toString() } val pageFields = config.elements("page").filter { it.getAttribute("dependency") == dxcc.toString() }
.flatMap { it.elements("pageField") }.map { it.textContent } .flatMap { it.elements("pageField") }.map { it.textContent }
val primary = pageFields.firstOrNull { it in setOf("US_STATE", "CA_PROVINCE", "RU_OBLAST", "CN_PROVINCE", "AU_STATE", "JA_PREFECTURE", "FI_KUNTA") } val primary = pageFields.firstOrNull { it in primaryRegionFields }
val secondary = pageFields.firstOrNull { it in setOf("US_COUNTY", "JA_CITY_GUN_KU") } val secondary = pageFields.firstOrNull { it in secondaryRegionFields }
fun region(name: String?, value: String) { fun region(name: String?, value: String) {
if (value.isBlank()) return if (value.isBlank()) return
if (name == null) fail(LoTWProblem.STATION_REGION) if (name == null) fail(LoTWProblem.STATION_REGION)
@@ -17,16 +17,40 @@ internal data class LoTWKeyMaterial(val key: PrivateKey, val certificate: X509Ce
companion object { companion object {
fun read(bytes: ByteArray, password: CharArray, now: Long): LoTWKeyMaterial { fun read(bytes: ByteArray, password: CharArray, now: Long): LoTWKeyMaterial {
if (bytes.isEmpty() || bytes.size > MAX_CERTIFICATE_BYTES) fail(LoTWProblem.CERTIFICATE_INVALID) if (bytes.isEmpty() || bytes.size > MAX_CERTIFICATE_BYTES) fail(LoTWProblem.CERTIFICATE_INVALID)
val key: PrivateKey
val cert: X509Certificate
val store = try { val store = try {
KeyStore.getInstance("PKCS12").apply { bytes.inputStream().use { load(it, password) } } KeyStore.getInstance("PKCS12").apply { bytes.inputStream().use { load(it, password) } }
} catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_PASSWORD) } } catch (_: Exception) {
val aliases = Collections.list(store.aliases()).filter { store.isKeyEntry(it) } // Modern TQSL / OpenSSL 3 exports use PBES2+AES-CBC which Android's legacy
// Bouncy Castle parser cannot read. Fall back to our own PBES2 reader; if
// that fails too, report the real reason (format vs password).
if (isPbes2(bytes)) {
try {
val parsed = Pkcs12Reader.read(bytes, password)
parsed.first to parsed.second
} catch (_: Exception) {
fail(if (password.isNotEmpty()) LoTWProblem.CERTIFICATE_FORMAT else LoTWProblem.CERTIFICATE_PASSWORD)
}
} else {
fail(LoTWProblem.CERTIFICATE_PASSWORD)
}
}
if (store is Pair<*, *>) {
@Suppress("UNCHECKED_CAST")
key = store.first as PrivateKey
@Suppress("UNCHECKED_CAST")
cert = store.second as X509Certificate
} else {
val ks = store as KeyStore
val aliases = Collections.list(ks.aliases()).filter { ks.isKeyEntry(it) }
if (aliases.size != 1) fail(LoTWProblem.CERTIFICATE_INVALID) if (aliases.size != 1) fail(LoTWProblem.CERTIFICATE_INVALID)
val alias = aliases.single() val alias = aliases.single()
val key = try { store.getKey(alias, password) as? PrivateKey } key = try { ks.getKey(alias, password) as? PrivateKey }
catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_PASSWORD) } catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_PASSWORD) }
?: fail(LoTWProblem.CERTIFICATE_INVALID) ?: fail(LoTWProblem.CERTIFICATE_INVALID)
val cert = store.getCertificate(alias) as? X509Certificate ?: fail(LoTWProblem.CERTIFICATE_INVALID) cert = ks.getCertificate(alias) as? X509Certificate ?: fail(LoTWProblem.CERTIFICATE_INVALID)
}
if (key.algorithm != "RSA" || cert.publicKey.algorithm != "RSA") fail(LoTWProblem.CERTIFICATE_INVALID) if (key.algorithm != "RSA" || cert.publicKey.algorithm != "RSA") fail(LoTWProblem.CERTIFICATE_INVALID)
try { cert.checkValidity(Date(now)) } catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_EXPIRED) } try { cert.checkValidity(Date(now)) } catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_EXPIRED) }
val info = try { metadata(cert) } catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_INVALID) } val info = try { metadata(cert) } catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_INVALID) }
@@ -38,6 +62,18 @@ internal data class LoTWKeyMaterial(val key: PrivateKey, val certificate: X509Ce
return LoTWKeyMaterial(key, cert, info) return LoTWKeyMaterial(key, cert, info)
} }
/** True when the PKCS12 uses PBES2 (OID 1.2.840.113549.1.5.13), the default
* algorithm of OpenSSL 3 / modern TQSL. Android's legacy BC parser can't read it. */
private fun isPbes2(bytes: ByteArray): Boolean {
val oid = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x05, 0x0d)
if (bytes.size < oid.size) return false
outer@ for (i in 0..bytes.size - oid.size) {
for (j in oid.indices) if (bytes[i + j] != oid[j]) continue@outer
return true
}
return false
}
private fun metadata(cert: X509Certificate): LoTWCertificate { private fun metadata(cert: X509Certificate): LoTWCertificate {
val oid = byteArrayOf(0x2b, 0x06, 0x01, 0x04, 0x01, 0xe0.toByte(), 0x3c, 0x01, 0x01) val oid = byteArrayOf(0x2b, 0x06, 0x01, 0x04, 0x01, 0xe0.toByte(), 0x3c, 0x01, 0x01)
val subject = DerValue.read(cert.subjectX500Principal.encoded).single() val subject = DerValue.read(cert.subjectX500Principal.encoded).single()
@@ -8,6 +8,7 @@ import com.rtbishop.look4sat.core.domain.repository.LoTWCertificate
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
import com.rtbishop.look4sat.core.domain.repository.LoTWStation import com.rtbishop.look4sat.core.domain.repository.LoTWStation
import com.rtbishop.look4sat.core.domain.repository.LoTWStationMeta
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadAudit import com.rtbishop.look4sat.core.domain.repository.LoTWUploadAudit
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult
@@ -124,8 +125,18 @@ class LoTWUploadRepository internal constructor(
val stored = storage.read("certificate") ?: fail(LoTWProblem.CERTIFICATE_MISSING) val stored = storage.read("certificate") ?: fail(LoTWProblem.CERTIFICATE_MISSING)
val bundle = try { readBundle(stored) } finally { stored.fill(0) } val bundle = try { readBundle(stored) } finally { stored.fill(0) }
val normalized = station.normalized() val normalized = station.normalized()
try { config().stationFields(normalized, bundle.info.dxcc) } try {
finally { config().stationFields(normalized, bundle.info.dxcc)
} catch (e: LoTWOperationException) {
// A region saved under a different certificate no longer applies to
// this DXCC entity — drop it instead of failing the whole save.
if (e.reason != LoTWProblem.STATION_REGION) throw e
val cleaned = normalized.copy(region = "", county = "")
config().stationFields(cleaned, bundle.info.dxcc)
writeStation(cleaned)
discardPreview()
return@withLock cleaned
} finally {
bundle.p12.fill(0) bundle.p12.fill(0)
bundle.password?.fill(0) bundle.password?.fill(0)
} }
@@ -139,6 +150,10 @@ class LoTWUploadRepository internal constructor(
mutex.withLock { discardPreview(); storage.delete("certificate") } mutex.withLock { discardPreview(); storage.delete("certificate") }
} }
override suspend fun stationMeta(dxcc: Int): LoTWStationMeta = withContext(Dispatchers.IO) {
config().stationMeta(dxcc)
}
override suspend fun audit(records: List<QsoRecord>): LoTWUploadAudit = withContext(Dispatchers.IO) { override suspend fun audit(records: List<QsoRecord>): LoTWUploadAudit = withContext(Dispatchers.IO) {
mutex.withLock { mutex.withLock {
val signing = signingContext() val signing = signingContext()
@@ -0,0 +1,255 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.lotw
import java.io.ByteArrayInputStream
import java.security.KeyFactory
import java.security.PrivateKey
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.security.spec.PKCS8EncodedKeySpec
import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec
/**
* Minimal PKCS#12 reader for the PBES2/AES-CBC format that OpenSSL 3 and modern
* TQSL produce by default. Android's legacy bundled Bouncy Castle PKCS12 parser
* cannot handle PBES2, so we parse the DER structure ourselves and decrypt with
* the platform JCE (PBKDF2WithHmacSHA1/256 + AES/CBC), which ships on Android.
*
* Handles both layouts:
* - OpenSSL `-certpbe NONE`: plaintext certificate bags + a PBES2-shrouded key
* inside a plaintext `data` ContentInfo.
* - Modern TQSL: the certificate SafeContents wrapped in an `encryptedData`
* ContentInfo (PBES2), plus the PBES2-shrouded key in a plaintext `data`
* ContentInfo.
*/
internal object Pkcs12Reader {
private val OID_DATA = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x07, 0x01)
private val OID_ENCRYPTED_DATA = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x07, 0x06)
private val OID_PKCS8_SHROUDED_KEY_BAG = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x0c, 0x0a, 0x01, 0x02)
private val OID_CERT_BAG = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x0c, 0x0a, 0x01, 0x03)
private val OID_X509_CERT = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x09, 0x16, 0x01)
private val OID_PBES2 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x05, 0x0d)
private val OID_HMAC_SHA1 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x02, 0x07)
private val OID_AES_256_CBC = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x2a)
private val OID_AES_128_CBC = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x02)
fun read(bytes: ByteArray, password: CharArray): Pair<PrivateKey, X509Certificate> {
val pfx = DerReader.read(bytes)
require(pfx.tag == 0x30) { "not a PFX" }
val pfxChildren = DerReader.children(pfx.content)
require(pfxChildren.size >= 2) { "PFX too small" }
// authSafe ContentInfo
val authSafeCi = DerReader.children(pfxChildren[1].content)
require(authSafeCi.size >= 2) { "authSafe malformed" }
// content: [0] EXPLICIT OCTET STRING -> AuthenticatedSafe
val explicit = DerReader.children(authSafeCi[1].content).first()
val octet = DerReader.read(explicit.content)
// octet.content is the AuthenticatedSafe body: a sequence of ContentInfo.
var privateKey: PrivateKey? = null
val certs = mutableListOf<X509Certificate>()
for (info in DerReader.children(octet.content)) {
val parts = DerReader.children(info.content)
if (parts.isEmpty()) continue
when {
parts[0].content.contentEquals(OID_DATA) ->
parseSafeContentsContent(parts, password, certs) { privateKey = it }
parts[0].content.contentEquals(OID_ENCRYPTED_DATA) ->
parseEncryptedData(parts, password, certs)
}
}
val key = privateKey ?: error("no private key bag found")
require(certs.isNotEmpty()) { "no certificate bag found" }
return key to certs[0]
}
/** A plaintext ContentInfo: [0] EXPLICIT OCTET STRING -> full SafeContents DER. */
private fun parseSafeContentsContent(
parts: List<Der>,
password: CharArray,
certs: MutableList<X509Certificate>,
onKey: (PrivateKey) -> Unit
) {
val explicit = DerReader.children(parts[1].content).first()
parseSafeBags(explicit.content, password, certs, onKey)
}
/** EncryptedData ContentInfo: version, EncryptedContentInfo{ oid, PBES2 alg, [0] IMPLICIT OCTET }. */
private fun parseEncryptedData(
parts: List<Der>,
password: CharArray,
certs: MutableList<X509Certificate>
) {
// parts[0] = encryptedData OID; parts[1] = [0] EXPLICIT EncryptedData SEQ
val explicit = DerReader.children(parts[1].content).first()
val eciParts = DerReader.children(explicit.content)
// eciParts = [version INT, EncryptedContentInfo SEQ]
require(eciParts.size >= 2) { "EncryptedData malformed" }
val eci = DerReader.children(eciParts[1].content)
// eci = [contentType OID, contentEncryptionAlgorithm, [0] IMPLICIT OCTET STRING]
require(eci.size >= 3) { "EncryptedContentInfo malformed" }
val alg = eci[1]
val ciphertext = eci[2].content // [0] IMPLICIT OCTET STRING -> raw bytes
val safeContents = decryptPbes2(alg, ciphertext, password)
// The decrypted SafeContents holds certificate bags.
parseSafeBags(safeContents, password, certs) {}
}
/** Decrypt a PBES2-encrypted blob given its AlgorithmIdentifier. */
private fun decryptPbes2(algorithm: Der, encrypted: ByteArray, password: CharArray): ByteArray {
val algParts = DerReader.children(algorithm.content)
require(algParts.size >= 2 && algParts[0].content.contentEquals(OID_PBES2)) { "not PBES2" }
// PBES2-params ::= SEQUENCE { kdf AlgorithmIdentifier, enc AlgorithmIdentifier }
val pbes2 = DerReader.children(algParts[1].content)
val kdf = DerReader.children(pbes2[0].content)
val kdfParams = DerReader.children(kdf[1].content)
val salt = kdfParams[0].content
val iterations = readInt(kdfParams[1].content)
require(iterations in 1..10_000_000) { "implausible PBKDF2 iteration count" }
// Optional PBKDF2-params elements: keyLength (INTEGER) and/or prf (SEQUENCE),
// in either order. Distinguish by DER tag — mistaking prf for keyLength yields
// an absurd key size and a PBKDF2 that runs for hours.
var keyBits = 256
var prfName = "PBKDF2WithHmacSHA1"
for (i in 2 until kdfParams.size) {
val param = kdfParams[i]
when (param.tag) {
0x02 -> keyBits = readInt(param.content) * 8
0x30 -> {
val prf = DerReader.children(param.content)
prfName = if (prf.isNotEmpty() && prf[0].content.contentEquals(OID_HMAC_SHA1))
"PBKDF2WithHmacSHA1" else "PBKDF2WithHmacSHA256"
}
}
}
require(keyBits in 128..512) { "implausible PBKDF2 key size" }
val enc = DerReader.children(pbes2[1].content)
val encOid = enc[0].content
val iv = enc[1].content
require(encOid.contentEquals(OID_AES_256_CBC) || encOid.contentEquals(OID_AES_128_CBC)) { "unsupported cipher" }
val spec = PBEKeySpec(password, salt, iterations, keyBits)
val secretKey = SecretKeyFactory.getInstance(prfName).generateSecret(spec)
// PBKDF2 factories return a PBE key; wrap the raw bytes as an AES key.
val aesKey = SecretKeySpec(secretKey.encoded, "AES")
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, aesKey, IvParameterSpec(iv))
return cipher.doFinal(encrypted)
}
private fun parseSafeBags(
safeContentsDer: ByteArray,
password: CharArray,
certs: MutableList<X509Certificate>,
onKey: (PrivateKey) -> Unit
) {
val safeContents = DerReader.read(safeContentsDer)
require(safeContents.tag == 0x30) { "SafeContents malformed" }
for (bag in DerReader.children(safeContents.content)) {
val bagParts = DerReader.children(bag.content)
if (bagParts.size < 2) continue
val bagOid = bagParts[0].content
val bagValue = DerReader.children(bagParts[1].content).first()
when {
bagOid.contentEquals(OID_PKCS8_SHROUDED_KEY_BAG) ->
onKey(decryptShroudedKeyBag(bagValue, password))
bagOid.contentEquals(OID_CERT_BAG) -> parseCertBag(bagValue, certs)
}
}
}
private fun decryptShroudedKeyBag(bagValue: Der, password: CharArray): PrivateKey {
// bagValue = the SafeBag value SEQ (EncryptedPrivateKeyInfo): children are
// [AlgorithmIdentifier, encryptedData OCTET STRING].
val parts = DerReader.children(bagValue.content)
require(parts.size == 2) { "EncryptedPrivateKeyInfo malformed" }
val pkcs8 = decryptPbes2(parts[0], parts[1].content, password)
return KeyFactory.getInstance("RSA").generatePrivate(PKCS8EncodedKeySpec(pkcs8))
}
private fun parseCertBag(bagValue: Der, certs: MutableList<X509Certificate>) {
// bagValue = the SafeBag value SEQ (CertBag): children are
// [certType OID, [0] EXPLICIT OCTET STRING (full X.509 DER)].
val parts = DerReader.children(bagValue.content)
if (parts.size < 2) return
if (!parts[0].content.contentEquals(OID_X509_CERT)) return
// [0] EXPLICIT -> OCTET STRING -> full X.509 certificate DER.
val explicit = DerReader.children(parts[1].content).first()
val certDer = explicit.content
val factory = CertificateFactory.getInstance("X.509")
certs.add(factory.generateCertificate(ByteArrayInputStream(certDer)) as X509Certificate)
}
private fun readInt(bytes: ByteArray): Int {
var value = 0
var start = 0
if (bytes.size > 1 && bytes[0].toInt() == 0x00) start = 1 // strip leading zero for positive
for (i in start until bytes.size) value = (value shl 8) or (bytes[i].toInt() and 0xff)
return value
}
/** Minimal DER element parser. */
private data class Der(val tag: Int, val content: ByteArray)
private object DerReader {
fun read(der: ByteArray, offset: Int = 0): Der {
require(offset < der.size) { "DER truncated" }
val tag = der[offset].toInt() and 0xff
var i = offset + 1
var len = der[i].toInt() and 0xff
i++
if (len and 0x80 != 0) {
val numBytes = len and 0x7f
len = 0
repeat(numBytes) {
len = (len shl 8) or (der[i].toInt() and 0xff)
i++
}
}
require(i + len <= der.size) { "DER length overflow" }
return Der(tag, der.copyOfRange(i, i + len))
}
fun children(content: ByteArray): List<Der> {
val out = mutableListOf<Der>()
var off = 0
while (off < content.size) {
val d = read(content, off)
out.add(d)
val step = headerSize(content, off) + d.content.size
if (step <= 0) break // defensive: never spin on malformed input
off += step
}
return out
}
private fun headerSize(der: ByteArray, offset: Int): Int {
var i = offset + 1
var len = der[i].toInt() and 0xff
i++
if (len and 0x80 != 0) i += len and 0x7f
return i - offset
}
}
}
@@ -9,6 +9,8 @@ interface ILoTWUploadRepository {
suspend fun saveCertificatePassword(password: CharArray): LoTWCertificate suspend fun saveCertificatePassword(password: CharArray): LoTWCertificate
suspend fun saveStation(station: LoTWStation): LoTWStation suspend fun saveStation(station: LoTWStation): LoTWStation
suspend fun removeCertificate() suspend fun removeCertificate()
/** Region field (State/Province/Prefecture…) and national CQZ/ITUZ map for a DXCC entity. */
suspend fun stationMeta(dxcc: Int): LoTWStationMeta
suspend fun audit(records: List<QsoRecord>): LoTWUploadAudit suspend fun audit(records: List<QsoRecord>): LoTWUploadAudit
suspend fun prepare(records: List<QsoRecord>, resubmit: Boolean): LoTWUploadPreview suspend fun prepare(records: List<QsoRecord>, resubmit: Boolean): LoTWUploadPreview
suspend fun upload(previewId: String): LoTWUploadResult suspend fun upload(previewId: String): LoTWUploadResult
@@ -35,6 +37,25 @@ data class LoTWStation(
val iota: String = "" val iota: String = ""
) )
/** One (ITU:CQ) pair from a LoTW zonemap, e.g. Guangdong is 44:24. */
data class LoTWZonePair(val itu: Int, val cq: Int)
/** One selectable region code with the zones it maps to (cross-zone regions carry several pairs). */
data class LoTWRegionOption(val code: String, val name: String, val zones: List<LoTWZonePair>)
/** Country-dependent region field (US_STATE, CN_PROVINCE, …) with its selectable options. */
data class LoTWRegionField(val id: String, val label: String, val options: List<LoTWRegionOption>)
/**
* Everything the station-location UI needs for one DXCC entity:
* the region field to show (null when the country has none) and the national
* zonemap (used to prefill CQZ/ITUZ when the country has a single zone pair).
*/
data class LoTWStationMeta(
val regionField: LoTWRegionField? = null,
val countryZones: List<LoTWZonePair> = emptyList()
)
data class LoTWUploadPreview( data class LoTWUploadPreview(
val id: String, val id: String,
val callsign: String, val callsign: String,
@@ -69,6 +90,7 @@ class LoTWOperationException(val reason: LoTWProblem, val detail: String = "") :
enum class LoTWProblem { enum class LoTWProblem {
CERTIFICATE_PASSWORD, CERTIFICATE_INVALID, CERTIFICATE_EXPIRED, CERTIFICATE_MISSING, CERTIFICATE_PASSWORD, CERTIFICATE_INVALID, CERTIFICATE_EXPIRED, CERTIFICATE_MISSING,
STORAGE, EMPTY_SELECTION, CALLSIGN_MISMATCH, QSO_DATE, STATION_GRID, STATION_REGION, CERTIFICATE_FORMAT, STORAGE, EMPTY_SELECTION, CALLSIGN_MISMATCH, QSO_DATE, STATION_GRID,
STATION_ZONE, STATION_IOTA, MODE, BAND, SATELLITE, INVALID_CONTACT, LOCATION_MISMATCH, TOO_MANY_CONTACTS STATION_REGION, STATION_ZONE, STATION_IOTA, MODE, BAND, SATELLITE, INVALID_CONTACT,
LOCATION_MISMATCH, TOO_MANY_CONTACTS
} }
@@ -191,11 +191,15 @@
<string name="lotw_sync_progress_qso">本次同步 %1$d 条 QSO,预计还需 %2$d 秒</string> <string name="lotw_sync_progress_qso">本次同步 %1$d 条 QSO,预计还需 %2$d 秒</string>
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string> <string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
<string name="btn_delete">删除</string>
<string name="prefs_logbook_title">日志本</string> <string name="prefs_logbook_title">日志本</string>
<string name="prefs_logbook_count">%1$d 条记录 — 本地通联与 LoTW 确认</string> <string name="prefs_logbook_count">%1$d 条记录 — 本地通联与 LoTW 确认</string>
<string name="prefs_logbook_empty">暂无记录 — 在雷达页 Log 标签记录通联</string> <string name="prefs_logbook_empty">暂无记录 — 在雷达页 Log 标签记录通联</string>
<string name="prefs_logbook_close">关闭</string> <string name="prefs_logbook_close">关闭</string>
<string name="prefs_lotw_upload_title">LoTW 上传</string> <string name="prefs_logbook_upload">上传</string>
<string name="prefs_logbook_upload_title">上传到 LoTW</string>
<string name="prefs_logbook_upload_confirm">确认上传</string>
<string name="prefs_lotw_upload_title">LoTW 上传证书</string>
<string name="prefs_lotw_upload_configured">证书已导入 · 台址网格 %1$s</string> <string name="prefs_lotw_upload_configured">证书已导入 · 台址网格 %1$s</string>
<string name="prefs_lotw_upload_not_configured">未配置 — 导入 TrustedQSL 证书后即可上传通联</string> <string name="prefs_lotw_upload_not_configured">未配置 — 导入 TrustedQSL 证书后即可上传通联</string>
<string name="prefs_lotw_upload_busy">处理中…</string> <string name="prefs_lotw_upload_busy">处理中…</string>
@@ -206,10 +210,12 @@
<string name="prefs_lotw_upload_error_password">证书密码错误,请检查 TQSL 导出 .p12 时设置的密码</string> <string name="prefs_lotw_upload_error_password">证书密码错误,请检查 TQSL 导出 .p12 时设置的密码</string>
<string name="prefs_lotw_upload_error_expired">证书已过期或尚未生效</string> <string name="prefs_lotw_upload_error_expired">证书已过期或尚未生效</string>
<string name="prefs_lotw_upload_error_invalid">不是有效的 LoTW 证书文件</string> <string name="prefs_lotw_upload_error_invalid">不是有效的 LoTW 证书文件</string>
<string name="prefs_lotw_upload_error_format">该 .p12 是新版 PBES2/AES-256 格式(OpenSSL 3 / 新版 TQSL 导出),Android 无法直接读取。请用旧格式重新导出(TQSL 旧加密导出或 OpenSSL -legacy 转换)后再导入</string>
<string name="prefs_lotw_upload_error_unknown">导入失败,请重试</string> <string name="prefs_lotw_upload_error_unknown">导入失败,请重试</string>
<string name="prefs_lotw_upload_cert_info">%1$s · DXCC %2$d · 有效期至 %3$s</string> <string name="prefs_lotw_upload_cert_info">%1$s · DXCC %2$d · 有效期至 %3$s</string>
<string name="prefs_lotw_upload_remove">移除证书</string> <string name="prefs_lotw_upload_remove">移除证书</string>
<string name="prefs_lotw_upload_station_title">台址</string> <string name="prefs_lotw_upload_station_title">台址</string>
<string name="prefs_lotw_upload_region_hint">未选择</string>
<string name="prefs_lotw_upload_grid">网格(逗号分隔,可多格)</string> <string name="prefs_lotw_upload_grid">网格(逗号分隔,可多格)</string>
<string name="prefs_lotw_upload_save">保存</string> <string name="prefs_lotw_upload_save">保存</string>
<string name="prefs_lotw_upload_close">关闭</string> <string name="prefs_lotw_upload_close">关闭</string>
@@ -222,11 +222,15 @@
<string name="lotw_sync_progress_qso">Syncing %1$d QSOs, ~%2$d s remaining</string> <string name="lotw_sync_progress_qso">Syncing %1$d QSOs, ~%2$d s remaining</string>
<string name="prefs_locator_text">Set station\'s position using locator</string> <string name="prefs_locator_text">Set station\'s position using locator</string>
<string name="btn_delete">Delete</string>
<string name="prefs_logbook_title">Logbook</string> <string name="prefs_logbook_title">Logbook</string>
<string name="prefs_logbook_count">%1$d records — local QSOs and LoTW confirmations</string> <string name="prefs_logbook_count">%1$d records — local QSOs and LoTW confirmations</string>
<string name="prefs_logbook_empty">No records yet — record QSOs from the Radar Log tab</string> <string name="prefs_logbook_empty">No records yet — record QSOs from the Radar Log tab</string>
<string name="prefs_logbook_close">Close</string> <string name="prefs_logbook_close">Close</string>
<string name="prefs_lotw_upload_title">LoTW upload</string> <string name="prefs_logbook_upload">Upload</string>
<string name="prefs_logbook_upload_title">Upload to LoTW</string>
<string name="prefs_logbook_upload_confirm">Upload</string>
<string name="prefs_lotw_upload_title">LoTW upload certificate</string>
<string name="prefs_lotw_upload_configured">Certificate imported · station grid %1$s</string> <string name="prefs_lotw_upload_configured">Certificate imported · station grid %1$s</string>
<string name="prefs_lotw_upload_not_configured">Not configured — import your TrustedQSL certificate to upload QSOs</string> <string name="prefs_lotw_upload_not_configured">Not configured — import your TrustedQSL certificate to upload QSOs</string>
<string name="prefs_lotw_upload_busy">Working…</string> <string name="prefs_lotw_upload_busy">Working…</string>
@@ -237,10 +241,12 @@
<string name="prefs_lotw_upload_error_password">Incorrect certificate password. Check the password you set when exporting the .p12 from TQSL.</string> <string name="prefs_lotw_upload_error_password">Incorrect certificate password. Check the password you set when exporting the .p12 from TQSL.</string>
<string name="prefs_lotw_upload_error_expired">Certificate is expired or not yet valid.</string> <string name="prefs_lotw_upload_error_expired">Certificate is expired or not yet valid.</string>
<string name="prefs_lotw_upload_error_invalid">Not a valid LoTW certificate file.</string> <string name="prefs_lotw_upload_error_invalid">Not a valid LoTW certificate file.</string>
<string name="prefs_lotw_upload_error_format">This .p12 uses the new PBES2/AES-256 format (OpenSSL 3 / recent TQSL), which Android cannot read directly. Re-export it as a legacy format (TQSL "export with legacy encryption" or an OpenSSL -legacy conversion), then import again.</string>
<string name="prefs_lotw_upload_error_unknown">Import failed. Try again.</string> <string name="prefs_lotw_upload_error_unknown">Import failed. Try again.</string>
<string name="prefs_lotw_upload_cert_info">%1$s · DXCC %2$d · expires %3$s</string> <string name="prefs_lotw_upload_cert_info">%1$s · DXCC %2$d · expires %3$s</string>
<string name="prefs_lotw_upload_remove">Remove certificate</string> <string name="prefs_lotw_upload_remove">Remove certificate</string>
<string name="prefs_lotw_upload_station_title">Station location</string> <string name="prefs_lotw_upload_station_title">Station location</string>
<string name="prefs_lotw_upload_region_hint">Not selected</string>
<string name="prefs_lotw_upload_grid">Grid(s), comma-separated</string> <string name="prefs_lotw_upload_grid">Grid(s), comma-separated</string>
<string name="prefs_lotw_upload_save">Save</string> <string name="prefs_lotw_upload_save">Save</string>
<string name="prefs_lotw_upload_close">Close</string> <string name="prefs_lotw_upload_close">Close</string>
@@ -13,27 +13,37 @@ import android.net.Uri
import android.provider.OpenableColumns import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -43,7 +53,9 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.repository.LoTWCertificate import com.rtbishop.look4sat.core.domain.repository.LoTWCertificate
import com.rtbishop.look4sat.core.domain.repository.LoTWStation import com.rtbishop.look4sat.core.domain.repository.LoTWStation
import com.rtbishop.look4sat.core.domain.repository.LoTWStationMeta
import com.rtbishop.look4sat.core.presentation.CardButton import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.LocalSpacing
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog import com.rtbishop.look4sat.core.presentation.SharedDialog
@@ -77,6 +89,7 @@ fun LoTWUploadCard(
fun LoTWUploadConfigDialog( fun LoTWUploadConfigDialog(
certificate: LoTWCertificate?, certificate: LoTWCertificate?,
station: LoTWStation?, station: LoTWStation?,
stationMeta: LoTWStationMeta?,
busy: Boolean, busy: Boolean,
error: LoTWUploadError?, error: LoTWUploadError?,
onDismiss: () -> Unit, onDismiss: () -> Unit,
@@ -90,6 +103,26 @@ fun LoTWUploadConfigDialog(
var cqZone by remember { mutableStateOf(station?.cqZone.orEmpty()) } var cqZone by remember { mutableStateOf(station?.cqZone.orEmpty()) }
var ituZone by remember { mutableStateOf(station?.ituZone.orEmpty()) } var ituZone by remember { mutableStateOf(station?.ituZone.orEmpty()) }
var iota by remember { mutableStateOf(station?.iota.orEmpty()) } var iota by remember { mutableStateOf(station?.iota.orEmpty()) }
var region by remember { mutableStateOf(station?.region.orEmpty()) }
// Countries whose national zonemap has exactly one pair (e.g. Germany, India) get
// their CQZ/ITUZ prefilled; multi-zone countries are left blank to avoid a wrong
// default that would mislead (e.g. Guangdong would show the Heilongjiang zone).
LaunchedEffect(stationMeta) {
val meta = stationMeta ?: return@LaunchedEffect
val zones = meta.countryZones
if (zones.size == 1 && cqZone.isBlank() && ituZone.isBlank()) {
cqZone = zones[0].cq.toString()
ituZone = zones[0].itu.toString()
}
// A region saved under a previous certificate (different DXCC) no longer
// applies — clear it so saving doesn't fail validation.
val metaRegion = meta.regionField
if (metaRegion != null && region.isNotBlank() && metaRegion.options.none { it.code == region }) {
region = ""
}
}
val regionField = stationMeta?.regionField
val selectedRegionName = regionField?.options?.firstOrNull { it.code == region }?.name.orEmpty()
val context = LocalContext.current val context = LocalContext.current
// Pick the file first, then ask for the password — matches normal usage. // Pick the file first, then ask for the password — matches normal usage.
@@ -108,6 +141,10 @@ fun LoTWUploadConfigDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
onCancel = onDismiss, onCancel = onDismiss,
onAccept = null onAccept = null
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
verticalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
if (busy) { if (busy) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -130,6 +167,7 @@ fun LoTWUploadConfigDialog(
LoTWUploadError.PASSWORD -> R.string.prefs_lotw_upload_error_password LoTWUploadError.PASSWORD -> R.string.prefs_lotw_upload_error_password
LoTWUploadError.EXPIRED -> R.string.prefs_lotw_upload_error_expired LoTWUploadError.EXPIRED -> R.string.prefs_lotw_upload_error_expired
LoTWUploadError.INVALID_FILE -> R.string.prefs_lotw_upload_error_invalid LoTWUploadError.INVALID_FILE -> R.string.prefs_lotw_upload_error_invalid
LoTWUploadError.FORMAT -> R.string.prefs_lotw_upload_error_format
LoTWUploadError.UNKNOWN -> R.string.prefs_lotw_upload_error_unknown LoTWUploadError.UNKNOWN -> R.string.prefs_lotw_upload_error_unknown
} }
), ),
@@ -185,6 +223,69 @@ fun LoTWUploadConfigDialog(
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(capitalization = KeyboardCapitalization.Characters), keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(capitalization = KeyboardCapitalization.Characters),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
// Country-specific region field (US_STATE, CN_PROVINCE, …) shown only when
// the certificate's DXCC entity defines one; selecting it fills CQZ/ITUZ.
// Drawn as a plain Box (not OutlinedTextField): a read-only text field's
// internal gesture handler consumes the tap, so clickable never fires.
// Options expand inline inside the sheet (no Popup/Dialog window stacking).
if (regionField != null) {
var regionExpanded by remember { mutableStateOf(false) }
val fieldShape = MaterialTheme.shapes.extraSmall
Box(
modifier = Modifier
.fillMaxWidth()
.clip(fieldShape)
.background(MaterialTheme.colorScheme.surface)
.border(1.dp, MaterialTheme.colorScheme.outline, fieldShape)
.clickable { regionExpanded = !regionExpanded }
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
Column {
Text(regionField.label, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(modifier = Modifier.height(3.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = if (region.isBlank()) {
stringResource(R.string.prefs_lotw_upload_region_hint)
} else {
"$region — $selectedRegionName"
},
fontSize = 16.sp,
color = if (region.isBlank()) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Text(
text = if (regionExpanded) "▴" else "▾",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
AnimatedVisibility(visible = regionExpanded) {
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 280.dp)) {
items(regionField.options, key = { it.code }) { option ->
DropdownMenuItem(
text = { Text("${option.code} — ${option.name}", fontSize = 13.sp) },
onClick = {
region = option.code
regionExpanded = false
option.zones.firstOrNull()?.let { zone ->
cqZone = zone.cq.toString()
ituZone = zone.itu.toString()
}
}
)
}
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
OutlinedTextField( OutlinedTextField(
value = cqZone, value = cqZone,
@@ -209,10 +310,11 @@ fun LoTWUploadConfigDialog(
) )
} }
CardButton( CardButton(
onClick = { onSaveStation(LoTWStation(grid, cqZone, ituZone, "", "", iota)) }, onClick = { onSaveStation(LoTWStation(grid, cqZone, ituZone, region, "", iota)) },
text = stringResource(R.string.prefs_lotw_upload_save), text = stringResource(R.string.prefs_lotw_upload_save),
isEnabled = grid.isNotBlank() && !busy, isEnabled = grid.isNotBlank() && !busy,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
} }
} }
}