fix(lotw): 空密码 PBES2 证书回落自研解析器,修复 p12 导入误报无效

平台 KeyStore 对空密码 PBES2 文件不抛异常但给不出私钥条目,原逻辑随即报「不是有效的 LoTW 证书文件」。现在平台读取器抛异常、无私钥条目或取不出密钥时,只要文件是 PBES2 就回落自研解析器,两条都失败才报错;非 PBES2 旧式 p12 报错分类不变。新增 5 个单测(空密码回落、平台可用回归、错密码、非 p12、超限)。
This commit is contained in:
atsunatsu committed 2026-10-06 22:13:47 +08:00
1 parent 1e563f9cb0
commit 68e1925784
2 files changed
+171 -43

No files matched your search

@@ -33,53 +33,38 @@ import java.util.TimeZone
internal data class LoTWKeyMaterial(val key: PrivateKey, val certificate: X509Certificate, val info: LoTWCertificate) {
companion object {
fun read(bytes: ByteArray, password: CharArray, now: Long): LoTWKeyMaterial {
/**
* Reads the private key and certificate out of a TrustedQSL .p12 backup.
*
* The platform PKCS12 parser is tried first because it resolves the certificate that
* belongs to the key entry. When it cannot deliver a usable pair — it may refuse the file
* outright (older Android cannot read PBES2 at all) or accept the container yet expose no
* key entry (Conscrypt/OpenSSL reads PBES2 but fails to recover the key of, for example,
* an empty-password backup) — our own PBES2 reader is consulted before reporting a problem,
* so a valid certificate is not rejected as "not a valid LoTW certificate file".
*
* [platformStore] is a seam that lets tests simulate a platform which accepts the file
* without exposing any key entry.
*/
internal fun read(
bytes: ByteArray,
password: CharArray,
now: Long,
platformStore: (ByteArray, CharArray) -> KeyStore? = { data, pass -> loadPlatformStore(data, pass) },
): LoTWKeyMaterial {
if (bytes.isEmpty() || bytes.size > MAX_CERTIFICATE_BYTES) fail(LoTWProblem.CERTIFICATE_INVALID)
val key: PrivateKey
val cert: X509Certificate
val store = try {
KeyStore.getInstance("PKCS12").apply { bytes.inputStream().use { load(it, password) } }
} catch (_: Exception) {
// 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 (e: Exception) {
// Classify by exception type so the user sees the right message:
// - BadPadding (BAD_DECRYPT) -> wrong password
// - IllegalStateException (error()) -> unsupported algorithm,
// message is the algorithm name
// - IllegalArgumentException (require()) -> structurally invalid file
fail(
when {
e is javax.crypto.BadPaddingException -> LoTWProblem.CERTIFICATE_PASSWORD
e is IllegalStateException && password.isNotEmpty() -> LoTWProblem.CERTIFICATE_FORMAT
else -> LoTWProblem.CERTIFICATE_INVALID
},
if (e is IllegalStateException) (e.message ?: "") else ""
)
}
} 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
val outcome = platformStore(bytes, password)?.let { platformKeyAndCertificate(it, password) }
if (outcome is PlatformOutcome.Ok) {
key = outcome.key
cert = outcome.certificate
} else if (isPbes2(bytes)) {
val parsed = readWithPbes2Reader(bytes, password)
key = parsed.first
cert = parsed.second
} else {
val ks = store as KeyStore
val aliases = Collections.list(ks.aliases()).filter { ks.isKeyEntry(it) }
if (aliases.size != 1) fail(LoTWProblem.CERTIFICATE_INVALID)
val alias = aliases.single()
key = try { ks.getKey(alias, password) as? PrivateKey }
catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_PASSWORD) }
?: fail(LoTWProblem.CERTIFICATE_INVALID)
cert = ks.getCertificate(alias) as? X509Certificate ?: fail(LoTWProblem.CERTIFICATE_INVALID)
fail((outcome as? PlatformOutcome.Unusable)?.problem ?: LoTWProblem.CERTIFICATE_PASSWORD)
}
if (key.algorithm != "RSA" || cert.publicKey.algorithm != "RSA") fail(LoTWProblem.CERTIFICATE_INVALID)
try { cert.checkValidity(Date(now)) } catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_EXPIRED) }
@@ -92,6 +77,54 @@ internal data class LoTWKeyMaterial(val key: PrivateKey, val certificate: X509Ce
return LoTWKeyMaterial(key, cert, info)
}
/** What the platform PKCS12 parser managed to deliver for the key entry. */
private sealed interface PlatformOutcome {
data class Ok(val key: PrivateKey, val certificate: X509Certificate) : PlatformOutcome
/** The problem the platform path would have reported on its own. */
data class Unusable(val problem: LoTWProblem) : PlatformOutcome
}
/** The platform PKCS12 keystore, or null when it refuses to read the file at all. */
private fun loadPlatformStore(bytes: ByteArray, password: CharArray): KeyStore? = try {
KeyStore.getInstance("PKCS12").apply { bytes.inputStream().use { load(it, password) } }
} catch (_: Exception) {
null
}
/** Resolves the single key entry the platform offers, keeping its own failure reasons. */
private fun platformKeyAndCertificate(store: KeyStore, password: CharArray): PlatformOutcome {
val aliases = Collections.list(store.aliases()).filter { store.isKeyEntry(it) }
val alias = aliases.singleOrNull() ?: return PlatformOutcome.Unusable(LoTWProblem.CERTIFICATE_INVALID)
val key = try {
store.getKey(alias, password) as? PrivateKey
} catch (_: Exception) {
return PlatformOutcome.Unusable(LoTWProblem.CERTIFICATE_PASSWORD)
} ?: return PlatformOutcome.Unusable(LoTWProblem.CERTIFICATE_INVALID)
val certificate = store.getCertificate(alias) as? X509Certificate
?: return PlatformOutcome.Unusable(LoTWProblem.CERTIFICATE_INVALID)
return PlatformOutcome.Ok(key, certificate)
}
/** Our own PBES2 reader, mapping failures onto the message the operator should see. */
private fun readWithPbes2Reader(bytes: ByteArray, password: CharArray): Pair<PrivateKey, X509Certificate> = try {
Pkcs12Reader.read(bytes, password)
} catch (e: Exception) {
// Classify by exception type so the user sees the right message:
// - BadPadding (BAD_DECRYPT) -> wrong password
// - IllegalStateException (error()) -> unsupported algorithm,
// message is the algorithm name
// - IllegalArgumentException (require()) -> structurally invalid file
fail(
when {
e is javax.crypto.BadPaddingException -> LoTWProblem.CERTIFICATE_PASSWORD
e is IllegalStateException && password.isNotEmpty() -> LoTWProblem.CERTIFICATE_FORMAT
else -> LoTWProblem.CERTIFICATE_INVALID
},
if (e is IllegalStateException) (e.message ?: "") else ""
)
}
/** 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 {
@@ -0,0 +1,95 @@
/*
* 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 com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
import java.io.InputStream
import java.security.KeyStore
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
/**
* Certificate import must not depend on the platform PKCS12 parser. Android's parser accepts a
* modern TQSL PBES2 export but can expose no key entry for it (observed with an empty-password
* backup, which was then rejected as "not a valid LoTW certificate file" although every field of
* the certificate is readable). These tests pin the fallback to our own PBES2 reader.
*/
class LoTWKeyMaterialTest {
private fun fixture(name: String): ByteArray =
javaClass.classLoader!!.getResourceAsStream(name)!!.use { it.readBytes() }
/** Stands in for a platform that parsed the container but offers no private-key entry. */
private fun storeWithoutKeyEntry(): KeyStore = KeyStore.getInstance("PKCS12").apply {
load(null as InputStream?, null as CharArray?)
}
private val now = System.currentTimeMillis()
/** TQSL-style backup (empty password) imported although the platform delivers no key entry. */
@Test
fun importsCertificateWhenPlatformExposesNoKeyEntry() {
val material = LoTWKeyMaterial.read(
bytes = fixture("test_tqsl_empty.p12"),
password = charArrayOf(),
now = now,
platformStore = { _, _ -> storeWithoutKeyEntry() },
)
assertEquals("BA7OPF", material.info.callsign)
assertEquals(318, material.info.dxcc)
assertEquals("2026-01-01", material.info.firstQsoDate)
}
/** The same backup still imports when the platform is the one that resolves the key entry. */
@Test
fun importsCertificateThroughPlatformWhenAvailable() {
val material = LoTWKeyMaterial.read(fixture("test_tqsl_empty.p12"), charArrayOf(), now)
assertEquals("BA7OPF", material.info.callsign)
}
/** A wrong password is still reported as a password problem, not as an invalid file. */
@Test
fun reportsWrongPasswordForPbes2Backup() {
val error = assertThrows(LoTWOperationException::class.java) {
LoTWKeyMaterial.read(fixture("test_tqsl_empty.p12"), "wrong-password".toCharArray(), now)
}
assertEquals(LoTWProblem.CERTIFICATE_PASSWORD, error.reason)
}
/** Something that is not a PKCS12 at all keeps the message the platform path produced. */
@Test
fun refusesNonPkcs12File() {
val error = assertThrows(LoTWOperationException::class.java) {
LoTWKeyMaterial.read("not a p12 at all".toByteArray(), "x".toCharArray(), now) { _, _ -> null }
}
assertEquals(LoTWProblem.CERTIFICATE_PASSWORD, error.reason)
}
/** Oversized input is rejected before any parser runs. */
@Test
fun refusesOversizedInput() {
val bytes = ByteArray(MAX_CERTIFICATE_BYTES + 1)
val error = assertThrows(LoTWOperationException::class.java) {
LoTWKeyMaterial.read(bytes, charArrayOf(), now) { _, _ -> storeWithoutKeyEntry() }
}
assertEquals(LoTWProblem.CERTIFICATE_INVALID, error.reason)
}
}