From 68e19257845a817905788a2576b30f822ef2c262 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Tue, 6 Oct 2026 22:13:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(lotw):=20=E7=A9=BA=E5=AF=86=E7=A0=81=20PBES?= =?UTF-8?q?2=20=E8=AF=81=E4=B9=A6=E5=9B=9E=E8=90=BD=E8=87=AA=E7=A0=94?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E5=99=A8=EF=BC=8C=E4=BF=AE=E5=A4=8D=20p12=20?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E8=AF=AF=E6=8A=A5=E6=97=A0=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 平台 KeyStore 对空密码 PBES2 文件不抛异常但给不出私钥条目,原逻辑随即报「不是有效的 LoTW 证书文件」。现在平台读取器抛异常、无私钥条目或取不出密钥时,只要文件是 PBES2 就回落自研解析器,两条都失败才报错;非 PBES2 旧式 p12 报错分类不变。新增 5 个单测(空密码回落、平台可用回归、错密码、非 p12、超限)。 --- .../core/data/lotw/LoTWKeyMaterial.kt | 119 +++++++++++------- .../core/data/lotw/LoTWKeyMaterialTest.kt | 95 ++++++++++++++ 2 files changed, 171 insertions(+), 43 deletions(-) create mode 100644 core/data/src/test/java/com/rtbishop/look4sat/core/data/lotw/LoTWKeyMaterialTest.kt diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWKeyMaterial.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWKeyMaterial.kt index 4e057e54..2c1080bc 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWKeyMaterial.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWKeyMaterial.kt @@ -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 = 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 { diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/lotw/LoTWKeyMaterialTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/lotw/LoTWKeyMaterialTest.kt new file mode 100644 index 00000000..291bc1de --- /dev/null +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/lotw/LoTWKeyMaterialTest.kt @@ -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 . + */ + +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) + } +}