* fix(android): parse Tauri-wrapped minisign keys so in-app APK install can open Android updater called PublicKey::from_base64 on the wrapped tauri.conf pubkey, so install died before installApk. Also keep vault envelope failures from looking like an unconfigured Volcengine ASR provider. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(linux-egui): 补齐根证书指纹字段并让 fcitx5 门禁匹配实际安装调用 Release Linux egui 在 beta 上因 RemoteInputStatus 缺字段、match 臂类型不一致,以及门禁要求 ensure_fcitx5_ready(?) 而失败;安装调用本身已在监听器之前,错误会展示在 UI 里。 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(android): Keystore 暂时不可用时不要让 Tauri setup abort Core.start() 会走 configuration_snapshot → load_credentials_for_update;信封读失败若返回 Err,setup hook 直接 SIGABRT。改为记录 last_read_error 并返回未缓存默认值,应用可以启动。 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(android): 凭据信封暂时读失败时不要挡住 2.0 启动握手 Core.start() 在 setup 成功后还会被 event bridge 和 get_startup_snapshot 再调一次;此时 last_read_error 已记下,require_readable_vault 把 Persistence 抛成「暂时无法启动」。握手改为降级空凭据状态继续运行。 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(android): 创建渠道时重试 Keystore,失败不覆盖空信封 启动时信封暂时读失败后,require_readable_vault 会在真正 load 之前短路,导致添加提供商一直操作失败。改为每次读写都重试 Keystore;读失败返回 Persistence 且不缓存/不写入默认空根;前端展示后端具体错误。 Co-authored-by: Cursor <cursoragent@cursor.com> * debug(android): 把 Keystore 暂时不可用的具体异常名带回日志 添加提供商已会重试信封,但 Keystore 仍返回 temporarily unavailable。JNI 原先丢掉 Kotlin 异常与 JNI 失败路径,无法区分 class load、status 3 还是 BackendBusy。 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(android): 无法用于 Cipher 的 Keystore 密钥按失效信封恢复 真机 open 稳定抛出 InvalidKeyException(非 UserNotAuthenticated)。继续当成 temporarily unavailable 会让已保存凭据永远读不出来。改为走密钥缺失路径,删除无法解密的信封后可以重新配置提供商。 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(android): 凭据 Keystore 写入改走主线程并换用 v3 别名 清掉失效信封后,增加提供商仍在 seal 上失败(ProviderException/KeyStoreException)。HyperOS 上 Keystore2 从 JNI 后台线程调用常失败;改为主线程执行,并换用未损坏的 v3 别名,失败时删除别名再生成一次。 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(android): KeyMint AES-GCM 失败时回退到应用私有软件密钥 HyperOS 上 seal 稳定返回 KeyStoreException 10,主线程与 v3 别名仍无法写信封。Keystore 不可用时改用 UID 私有软件 AES-GCM,避免增加提供商失败。 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(android): preserve credentials across key fallback --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Chris233 <h-chris233@outlook.com>
134 lines
4.5 KiB
Kotlin
134 lines
4.5 KiB
Kotlin
package com.openless.app
|
|
|
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
import androidx.test.platform.app.InstrumentationRegistry
|
|
import java.io.File
|
|
import java.security.KeyStore
|
|
import java.util.UUID
|
|
import org.junit.After
|
|
import org.junit.Assert.assertArrayEquals
|
|
import org.junit.Assert.assertEquals
|
|
import org.junit.Assert.assertNull
|
|
import org.junit.Before
|
|
import org.junit.Test
|
|
import org.junit.runner.RunWith
|
|
|
|
@RunWith(AndroidJUnit4::class)
|
|
class OpenLessCredentialVaultInstrumentedTest {
|
|
private lateinit var alias: String
|
|
private lateinit var vault: AndroidKeystoreCredentialVault
|
|
|
|
@Before
|
|
fun setUp() {
|
|
alias = "com.openless.app.credentials.test.${UUID.randomUUID()}"
|
|
vault = AndroidKeystoreCredentialVault(alias)
|
|
}
|
|
|
|
@After
|
|
fun tearDown() {
|
|
vault.deleteKey()
|
|
}
|
|
|
|
private fun payload(response: ByteArray): ByteArray {
|
|
assertEquals(CREDENTIAL_STATUS_OK, response.first())
|
|
return response.copyOfRange(1, response.size)
|
|
}
|
|
|
|
private fun legacyVault() =
|
|
AndroidKeystoreCredentialVault("com.openless.app.credentials.v2")
|
|
|
|
private fun softwareStore(): SoftwareAesCredentialStore {
|
|
val filesDir = InstrumentationRegistry.getInstrumentation().targetContext.filesDir
|
|
return SoftwareAesCredentialStore(File(filesDir, "OpenLess"))
|
|
}
|
|
|
|
private fun resetFacadeState() {
|
|
OpenLessCredentialVault.deleteKey()
|
|
legacyVault().deleteKey()
|
|
}
|
|
|
|
@Test
|
|
fun roundTripUsesNonExportableKey() {
|
|
val plaintext = "instrumented credential".toByteArray()
|
|
val aad = "format-version-account".toByteArray()
|
|
val packet = payload(vault.seal(plaintext, aad))
|
|
|
|
assertArrayEquals(plaintext, payload(vault.open(packet, aad)))
|
|
val key = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }.getKey(alias, null)
|
|
assertNull(key.encoded)
|
|
}
|
|
|
|
@Test
|
|
fun keyMarkerStateTracksKeyCreationAndDeletion() {
|
|
assertArrayEquals(byteArrayOf(0), payload(vault.keyExists()))
|
|
assertEquals(CREDENTIAL_STATUS_OK, vault.ensureKey().first())
|
|
assertArrayEquals(byteArrayOf(1), payload(vault.keyExists()))
|
|
assertEquals(CREDENTIAL_STATUS_OK, vault.deleteKey().first())
|
|
assertArrayEquals(byteArrayOf(0), payload(vault.keyExists()))
|
|
}
|
|
|
|
@Test
|
|
fun publicFacadeRoundTripExercisesJvmStaticEntryPoints() {
|
|
OpenLessCredentialVault.deleteKey()
|
|
try {
|
|
val plaintext = "facade credential".toByteArray()
|
|
val aad = "facade aad".toByteArray()
|
|
val packet = payload(OpenLessCredentialVault.seal(plaintext, aad))
|
|
|
|
assertArrayEquals(plaintext, payload(OpenLessCredentialVault.open(packet, aad)))
|
|
} finally {
|
|
OpenLessCredentialVault.deleteKey()
|
|
}
|
|
}
|
|
|
|
@Test
|
|
fun publicFacadeReadsBeta1V2Envelope() {
|
|
resetFacadeState()
|
|
try {
|
|
val plaintext = "beta1 credential".toByteArray()
|
|
val aad = "format-version-account".toByteArray()
|
|
val packet = payload(legacyVault().seal(plaintext, aad))
|
|
|
|
assertArrayEquals(plaintext, payload(OpenLessCredentialVault.open(packet, aad)))
|
|
} finally {
|
|
resetFacadeState()
|
|
}
|
|
}
|
|
|
|
@Test
|
|
fun softwareKeyCreatedBeforeEnvelopeCommitDoesNotHideV2Envelope() {
|
|
resetFacadeState()
|
|
try {
|
|
val plaintext = "still-v2 credential".toByteArray()
|
|
val aad = "format-version-account".toByteArray()
|
|
val packet = payload(legacyVault().seal(plaintext, aad))
|
|
payload(softwareStore().seal("discarded candidate".toByteArray(), aad))
|
|
|
|
assertArrayEquals(plaintext, payload(OpenLessCredentialVault.open(packet, aad)))
|
|
} finally {
|
|
resetFacadeState()
|
|
}
|
|
}
|
|
|
|
@Test
|
|
fun deletedKeyIsReportedAsMissing() {
|
|
val aad = "format-version-account".toByteArray()
|
|
val packet = payload(vault.seal("secret".toByteArray(), aad))
|
|
assertEquals(CREDENTIAL_STATUS_OK, vault.deleteKey().first())
|
|
|
|
assertEquals(CREDENTIAL_STATUS_KEY_MISSING, vault.open(packet, aad).first())
|
|
}
|
|
|
|
@Test
|
|
fun tamperedCiphertextIsRejected() {
|
|
val aad = "format-version-account".toByteArray()
|
|
val packet = payload(vault.seal("secret".toByteArray(), aad))
|
|
packet[packet.lastIndex] = (packet.last().toInt() xor 1).toByte()
|
|
|
|
assertEquals(
|
|
CREDENTIAL_STATUS_AUTHENTICATION_FAILED,
|
|
vault.open(packet, aad).first(),
|
|
)
|
|
}
|
|
}
|