Files
openless/openless-all/app/android/kotlin/androidTest/OpenLessCredentialVaultInstrumentedTest.kt
82357e8df3 fix(android): 对齐应用内更新 minisign 并修复凭据 vault (#1061)
* 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>
2026-09-13 01:58:26 +08:00

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(),
)
}
}