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 48e1183e..3c51ddcb 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 @@ -29,8 +29,20 @@ internal data class LoTWKeyMaterial(val key: PrivateKey, val certificate: X509Ce 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) + } 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) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/Pkcs12Reader.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/Pkcs12Reader.kt index 87a6d9ff..be372575 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/Pkcs12Reader.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/Pkcs12Reader.kt @@ -51,9 +51,15 @@ internal object Pkcs12Reader { 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_PBKDF2 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x05, 0x0c) private val OID_HMAC_SHA1 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x02, 0x07) + private val OID_HMAC_SHA256 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x02, 0x09) + private val OID_HMAC_SHA384 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x02, 0x0a) + private val OID_HMAC_SHA512 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x02, 0x0b) private val OID_AES_256_CBC = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x2a) + private val OID_AES_192_CBC = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x16) private val OID_AES_128_CBC = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x02) + private val OID_DES_EDE3_CBC = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x03, 0x07) fun read(bytes: ByteArray, password: CharArray): Pair { val pfx = DerReader.read(bytes) @@ -123,6 +129,11 @@ internal object Pkcs12Reader { // PBES2-params ::= SEQUENCE { kdf AlgorithmIdentifier, enc AlgorithmIdentifier } val pbes2 = DerReader.children(algParts[1].content) val kdf = DerReader.children(pbes2[0].content) + // KDF must be PBKDF2 (OpenSSL 3 also supports scrypt — not available on the + // platform JCE, so report it by name instead of a generic failure). + if (kdf.isEmpty() || !kdf[0].content.contentEquals(OID_PBKDF2)) { + error(oidName(kdf.firstOrNull()?.content)) + } val kdfParams = DerReader.children(kdf[1].content) val salt = kdfParams[0].content val iterations = readInt(kdfParams[1].content) @@ -130,7 +141,7 @@ internal object Pkcs12Reader { // 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 keyBits = -1 // -1 = not written; defaulted below from the cipher var prfName = "PBKDF2WithHmacSHA1" for (i in 2 until kdfParams.size) { val param = kdfParams[i] @@ -138,26 +149,61 @@ internal object Pkcs12Reader { 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" + prfName = when { + prf.isEmpty() || prf[0].content.contentEquals(OID_HMAC_SHA1) -> "PBKDF2WithHmacSHA1" + prf[0].content.contentEquals(OID_HMAC_SHA256) -> "PBKDF2WithHmacSHA256" + prf[0].content.contentEquals(OID_HMAC_SHA384) -> "PBKDF2WithHmacSHA384" + prf[0].content.contentEquals(OID_HMAC_SHA512) -> "PBKDF2WithHmacSHA512" + else -> error(oidName(prf[0].content)) + } } } } - 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 (cipherName, aesKeyBits) = when { + encOid.contentEquals(OID_AES_256_CBC) -> "AES/CBC/PKCS5Padding" to 256 + encOid.contentEquals(OID_AES_192_CBC) -> "AES/CBC/PKCS5Padding" to 192 + encOid.contentEquals(OID_AES_128_CBC) -> "AES/CBC/PKCS5Padding" to 128 + encOid.contentEquals(OID_DES_EDE3_CBC) -> "DESede/CBC/PKCS5Padding" to 192 + else -> error(oidName(encOid)) + } + val finalKeyBits = if (keyBits > 0) keyBits else aesKeyBits + require(finalKeyBits in 128..512) { "implausible PBKDF2 key size" } - 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)) + val spec = PBEKeySpec(password, salt, iterations, finalKeyBits) + val secretKey = try { + SecretKeyFactory.getInstance(prfName).generateSecret(spec) + } catch (e: java.security.NoSuchAlgorithmException) { + // e.g. PBKDF2WithHmacSHA512 needs API 26+; surface the real reason. + error(prfName.replace("PBKDF2WithHmac", "PBKDF2-HMAC-") + " (needs Android 8.0+)") + } + val cipher = Cipher.getInstance(cipherName) + cipher.init( + Cipher.DECRYPT_MODE, + if (cipherName == "AES/CBC/PKCS5Padding") SecretKeySpec(secretKey.encoded, "AES") + else SecretKeySpec(secretKey.encoded, "DESede"), + IvParameterSpec(iv) + ) return cipher.doFinal(encrypted) } + /** Human-readable name for a known algorithm OID. */ + private fun oidName(oid: ByteArray?): String = when { + oid == null -> "unknown algorithm" + oid.contentEquals(OID_PBKDF2) -> "PBKDF2" + oid.contentEquals(OID_HMAC_SHA1) -> "PBKDF2-HMAC-SHA1" + oid.contentEquals(OID_HMAC_SHA256) -> "PBKDF2-HMAC-SHA256" + oid.contentEquals(OID_HMAC_SHA384) -> "PBKDF2-HMAC-SHA384" + oid.contentEquals(OID_HMAC_SHA512) -> "PBKDF2-HMAC-SHA512" + oid.contentEquals(OID_AES_256_CBC) -> "AES-256-CBC" + oid.contentEquals(OID_AES_192_CBC) -> "AES-192-CBC" + oid.contentEquals(OID_AES_128_CBC) -> "AES-128-CBC" + oid.contentEquals(OID_DES_EDE3_CBC) -> "DES-EDE3-CBC" + else -> "unknown algorithm" + } + private fun parseSafeBags( safeContentsDer: ByteArray, password: CharArray, diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/lotw/Pkcs12ReaderTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/lotw/Pkcs12ReaderTest.kt index ebaa9711..34cd997b 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/lotw/Pkcs12ReaderTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/lotw/Pkcs12ReaderTest.kt @@ -36,6 +36,67 @@ class Pkcs12ReaderTest { private fun fixture(): ByteArray = javaClass.classLoader!!.getResourceAsStream("test_pbes2.p12")!!.use { it.readBytes() } + private fun fixture(name: String): ByteArray = + javaClass.classLoader!!.getResourceAsStream(name)!!.use { it.readBytes() } + + @Test + fun readsAes192Variant() { + val (key, cert) = Pkcs12Reader.read(fixture("test_pbes2_aes192.p12"), "testpass123".toCharArray()) + assertEquals("RSA", key.algorithm) + assertEquals("RSA", cert.publicKey.algorithm) + assertTrue("key/cert pair", keyMatches(key, cert)) + } + + @Test + fun readsDesEde3CbcVariant() { + val (key, cert) = Pkcs12Reader.read(fixture("test_pbes2_desede3.p12"), "testpass123".toCharArray()) + assertEquals("RSA", key.algorithm) + assertEquals("RSA", cert.publicKey.algorithm) + assertTrue("key/cert pair", keyMatches(key, cert)) + } + + @Test + fun unsupportedCipherNamesTheAlgorithm() { + // A valid PBES2 file re-encrypted with an unsupported cipher must report the + // algorithm name (so the user can see exactly what to re-export with). + val fixtureBytes = fixture("test_pbes2.p12") + val e = assertThrows(IllegalStateException::class.java) { + // Simulate: patch the AES-256-CBC OID inside the file to an unknown OID. + val bogus = ByteArray(fixtureBytes.size) { fixtureBytes[it] } + // find AES-256-CBC OID bytes 0x60 86 48 01 65 03 04 01 2a + val oid = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x2a) + var idx = -1 + outer@ for (i in 0..bogus.size - oid.size) { + for (j in oid.indices) if (bogus[i + j] != oid[j]) continue@outer + idx = i; break + } + require(idx >= 0) { "AES-256-CBC OID not found in fixture" } + bogus[idx] = 0x7f.toByte() // corrupt the OID tag byte → unknown cipher + Pkcs12Reader.read(bogus, "testpass123".toCharArray()) + } + assertTrue("mentions algorithm", e.message.orEmpty().contains("algorithm")) + } + + private fun keyMatches(key: java.security.PrivateKey, cert: java.security.cert.X509Certificate): Boolean { + val challenge = "Look4Sat LoTW certificate key check".toByteArray(Charsets.US_ASCII) + val signed = Signature.getInstance("SHA1withRSA").run { + initSign(key); update(challenge); sign() + } + return Signature.getInstance("SHA1withRSA").run { + initVerify(cert); update(challenge); verify(signed) + } + } + + @Test + fun wrongPasswordFailsWithBadPadding() { + // A wrong password must surface as a decryption failure (BadPadding), which + // LoTWKeyMaterial maps to CERTIFICATE_PASSWORD — not a format error. + val e = assertThrows(Exception::class.java) { + Pkcs12Reader.read(fixture(), "definitely-wrong-password".toCharArray()) + } + assertTrue("BadPadding", e is javax.crypto.BadPaddingException) + } + @Test fun readsPbes2KeyAndCertificate() { val (key, cert) = Pkcs12Reader.read(fixture(), "testpass123".toCharArray()) diff --git a/core/data/src/test/resources/test_pbes2_aes192.p12 b/core/data/src/test/resources/test_pbes2_aes192.p12 new file mode 100644 index 00000000..1608fe6f Binary files /dev/null and b/core/data/src/test/resources/test_pbes2_aes192.p12 differ diff --git a/core/data/src/test/resources/test_pbes2_desede3.p12 b/core/data/src/test/resources/test_pbes2_desede3.p12 new file mode 100644 index 00000000..593e0e25 Binary files /dev/null and b/core/data/src/test/resources/test_pbes2_desede3.p12 differ diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 87d411f5..e8f0ca91 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -210,7 +210,8 @@ 证书密码错误,请检查 TQSL 导出 .p12 时设置的密码 证书已过期或尚未生效 不是有效的 LoTW 证书文件 - 该 .p12 是新版 PBES2/AES-256 格式(OpenSSL 3 / 新版 TQSL 导出),Android 无法直接读取。请用旧格式重新导出(TQSL 旧加密导出或 OpenSSL -legacy 转换)后再导入 + 不支持的 .p12 加密,请用 TQSL 默认加密重新导出后再试 + 不支持的 .p12 加密(%1$s),请用 TQSL 默认加密重新导出后再试 导入失败,请重试 %1$s · DXCC %2$d · 有效期至 %3$s 移除证书 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index e1ef78e2..a76797d4 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -241,7 +241,8 @@ Incorrect certificate password. Check the password you set when exporting the .p12 from TQSL. Certificate is expired or not yet valid. Not a valid LoTW certificate file. - 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. + Unsupported .p12 encryption. Re-export from TQSL and try again. + Unsupported .p12 encryption (%1$s). Re-export from TQSL and try again. Import failed. Try again. %1$s · DXCC %2$d · expires %3$s Remove certificate diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LoTWUploadConfigDialog.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LoTWUploadConfigDialog.kt index a6c93b3e..29575798 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LoTWUploadConfigDialog.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LoTWUploadConfigDialog.kt @@ -92,6 +92,7 @@ fun LoTWUploadConfigDialog( stationMeta: LoTWStationMeta?, busy: Boolean, error: LoTWUploadError?, + errorDetail: String = "", onDismiss: () -> Unit, onImport: (ByteArray, CharArray) -> Unit, onRemove: () -> Unit, @@ -162,15 +163,20 @@ fun LoTWUploadConfigDialog( ) error?.let { Text( - text = stringResource( - when (it) { - LoTWUploadError.PASSWORD -> R.string.prefs_lotw_upload_error_password - LoTWUploadError.EXPIRED -> R.string.prefs_lotw_upload_error_expired - 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 - } - ), + text = when { + it == LoTWUploadError.FORMAT && errorDetail.isNotBlank() -> + stringResource(R.string.prefs_lotw_upload_error_format_alg, errorDetail) + it == LoTWUploadError.FORMAT -> stringResource(R.string.prefs_lotw_upload_error_format) + else -> stringResource( + when (it) { + LoTWUploadError.PASSWORD -> R.string.prefs_lotw_upload_error_password + LoTWUploadError.EXPIRED -> R.string.prefs_lotw_upload_error_expired + LoTWUploadError.INVALID_FILE -> R.string.prefs_lotw_upload_error_invalid + LoTWUploadError.UNKNOWN -> R.string.prefs_lotw_upload_error_unknown + LoTWUploadError.FORMAT -> R.string.prefs_lotw_upload_error_format + } + ) + }, color = MaterialTheme.colorScheme.error, fontSize = 12.sp ) diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt index 6bd412a8..e5390daa 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt @@ -282,6 +282,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> stationMeta = uiState.lotwStationMeta, busy = uiState.lotwUploadBusy, error = uiState.lotwUploadError, + errorDetail = uiState.lotwUploadErrorDetail, onDismiss = { dialogs.lotwUpload = false }, onImport = { bytes, password -> onAction(SettingsAction.ImportLoTWCertificate(bytes, password)) }, onRemove = { onAction(SettingsAction.RemoveLoTWCertificate) }, diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt index 5cf419f6..c19f4f3b 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt @@ -79,6 +79,8 @@ data class SettingsState( val lotwUploadBusy: Boolean = false, /** Last certificate import outcome; shown inside the upload config dialog. */ val lotwUploadError: LoTWUploadError? = null, + /** Parser detail for FORMAT errors (e.g. the unsupported algorithm name). */ + val lotwUploadErrorDetail: String = "", /** One-click logbook upload: prepared preview awaiting confirmation. */ val logbookPreview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview? = null, val logbookUploadBusy: Boolean = false, diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt index 4830ea81..28fdc047 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt @@ -423,9 +423,9 @@ class SettingsViewModel( com.rtbishop.look4sat.core.domain.repository.LoTWProblem.CERTIFICATE_FORMAT -> LoTWUploadError.FORMAT else -> LoTWUploadError.INVALID_FILE } - _uiState.update { it.copy(lotwUploadBusy = false, lotwUploadError = error) } + _uiState.update { it.copy(lotwUploadBusy = false, lotwUploadError = error, lotwUploadErrorDetail = e.detail) } } catch (_: Exception) { - _uiState.update { it.copy(lotwUploadBusy = false, lotwUploadError = LoTWUploadError.UNKNOWN) } + _uiState.update { it.copy(lotwUploadBusy = false, lotwUploadError = LoTWUploadError.UNKNOWN, lotwUploadErrorDetail = "") } } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c917fed3..ac1da21c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,8 @@ [versions] #noinspection UnusedVersionCatalogEntry -appVersionCode = "523" +appVersionCode = "526" #noinspection UnusedVersionCatalogEntry -appVersionName = "4.4.7-ba7opf.17" +appVersionName = "4.4.7-ba7opf.17.3" #noinspection UnusedVersionCatalogEntry compileSdk = "37" #noinspection UnusedVersionCatalogEntry