* 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>
215 lines
7.5 KiB
Kotlin
215 lines
7.5 KiB
Kotlin
package com.openless.app
|
|
|
|
import java.io.File
|
|
import java.lang.reflect.Modifier
|
|
import java.security.GeneralSecurityException
|
|
import java.security.InvalidKeyException
|
|
import java.security.UnrecoverableKeyException
|
|
import javax.crypto.KeyGenerator
|
|
import javax.crypto.SecretKey
|
|
import org.junit.Assert.assertArrayEquals
|
|
import org.junit.Assert.assertEquals
|
|
import org.junit.Assert.assertFalse
|
|
import org.junit.Assert.assertThrows
|
|
import org.junit.Assert.assertTrue
|
|
import org.junit.Test
|
|
|
|
class OpenLessCredentialCipherTest {
|
|
private fun key(): SecretKey {
|
|
return KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
|
}
|
|
|
|
@Test
|
|
fun roundTrip() {
|
|
val key = key()
|
|
val plaintext = "credential-secret".toByteArray()
|
|
val aad = "format-version-account".toByteArray()
|
|
|
|
val packet = OpenLessCredentialCipher.seal(key, plaintext, aad)
|
|
|
|
assertArrayEquals(plaintext, OpenLessCredentialCipher.open(key, packet, aad))
|
|
assertFalse(packet.toString(Charsets.UTF_8).contains("credential-secret"))
|
|
}
|
|
|
|
@Test
|
|
fun freshNonce() {
|
|
val key = key()
|
|
val plaintext = "same plaintext".toByteArray()
|
|
val aad = "same aad".toByteArray()
|
|
|
|
val first = OpenLessCredentialCipher.seal(key, plaintext, aad)
|
|
val second = OpenLessCredentialCipher.seal(key, plaintext, aad)
|
|
|
|
assertFalse(first.contentEquals(second))
|
|
assertFalse(
|
|
first
|
|
.copyOfRange(1, 1 + OpenLessCredentialCipher.NONCE_BYTES)
|
|
.contentEquals(second.copyOfRange(1, 1 + OpenLessCredentialCipher.NONCE_BYTES))
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun tamperedCiphertext() {
|
|
val key = key()
|
|
val aad = "authenticated metadata".toByteArray()
|
|
val packet = OpenLessCredentialCipher.seal(key, "secret".toByteArray(), aad)
|
|
packet[packet.lastIndex] = (packet.last().toInt() xor 1).toByte()
|
|
|
|
assertThrows(GeneralSecurityException::class.java) {
|
|
OpenLessCredentialCipher.open(key, packet, aad)
|
|
}
|
|
}
|
|
|
|
@Test
|
|
fun tamperedNonce() {
|
|
val key = key()
|
|
val aad = "authenticated metadata".toByteArray()
|
|
val packet = OpenLessCredentialCipher.seal(key, "secret".toByteArray(), aad)
|
|
packet[1] = (packet[1].toInt() xor 1).toByte()
|
|
|
|
assertThrows(GeneralSecurityException::class.java) {
|
|
OpenLessCredentialCipher.open(key, packet, aad)
|
|
}
|
|
}
|
|
|
|
@Test
|
|
fun tamperedAad() {
|
|
val key = key()
|
|
val packet =
|
|
OpenLessCredentialCipher.seal(
|
|
key,
|
|
"secret".toByteArray(),
|
|
"account-a".toByteArray(),
|
|
)
|
|
|
|
assertThrows(GeneralSecurityException::class.java) {
|
|
OpenLessCredentialCipher.open(key, packet, "account-b".toByteArray())
|
|
}
|
|
}
|
|
|
|
@Test
|
|
fun facadeMethodsExposeExactStaticJniSignatures() {
|
|
val facade = OpenLessCredentialVault::class.java
|
|
val signatures: List<Pair<String, Array<Class<*>>>> =
|
|
listOf(
|
|
"seal" to arrayOf<Class<*>>(ByteArray::class.java, ByteArray::class.java),
|
|
"open" to arrayOf<Class<*>>(ByteArray::class.java, ByteArray::class.java),
|
|
"deleteKey" to emptyArray(),
|
|
"migrationComplete" to emptyArray(),
|
|
"markMigrationComplete" to emptyArray(),
|
|
)
|
|
for ((name, parameters) in signatures) {
|
|
val method = facade.getDeclaredMethod(name, *parameters)
|
|
assertTrue("$name must be static for JNI", Modifier.isStatic(method.modifiers))
|
|
assertEquals(ByteArray::class.java, method.returnType)
|
|
}
|
|
}
|
|
|
|
@Test
|
|
fun unrecoverableKeyExceptionRemainsRetryable() {
|
|
assertEquals(
|
|
CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE,
|
|
credentialStatusForKeyLoadFailure(UnrecoverableKeyException("backend busy")),
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun invalidKeyExceptionIsTreatedAsUnrecoverable() {
|
|
assertEquals(
|
|
CREDENTIAL_STATUS_KEY_MISSING,
|
|
credentialStatusForCipherKeyFailure(InvalidKeyException("Keystore operation failed")),
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun softwareAesRoundTripWithoutAndroidKeyStore() {
|
|
val dir = File.createTempFile("ol-sw-aes", "dir")
|
|
assertTrue(dir.delete())
|
|
assertTrue(dir.mkdirs())
|
|
try {
|
|
val store = SoftwareAesCredentialStore(dir)
|
|
val plaintext = "credential-secret".toByteArray()
|
|
val aad = "format-version-account".toByteArray()
|
|
val sealed = store.seal(plaintext, aad)
|
|
assertEquals(CREDENTIAL_STATUS_OK, sealed.first())
|
|
val packet = sealed.copyOfRange(1, sealed.size)
|
|
val opened = store.open(packet, aad)
|
|
assertEquals(CREDENTIAL_STATUS_OK, opened.first())
|
|
assertArrayEquals(plaintext, opened.copyOfRange(1, opened.size))
|
|
assertTrue(File(dir, SoftwareAesCredentialStore.SOFTWARE_KEY_NAME).isFile)
|
|
assertFalse(store.isMigrated())
|
|
assertEquals(CREDENTIAL_STATUS_OK, store.markMigrated().first())
|
|
assertTrue(store.isMigrated())
|
|
} finally {
|
|
dir.deleteRecursively()
|
|
}
|
|
}
|
|
|
|
@Test
|
|
fun softwareAesMissingKeyIsReportedAsMissing() {
|
|
val dir = File.createTempFile("ol-sw-aes-missing", "dir")
|
|
assertTrue(dir.delete())
|
|
assertTrue(dir.mkdirs())
|
|
try {
|
|
val store = SoftwareAesCredentialStore(dir)
|
|
assertEquals(
|
|
CREDENTIAL_STATUS_KEY_MISSING,
|
|
store.open(byteArrayOf(12) + ByteArray(12 + 16), "aad".toByteArray()).first(),
|
|
)
|
|
} finally {
|
|
dir.deleteRecursively()
|
|
}
|
|
}
|
|
|
|
@Test
|
|
fun openFallbackPrefersSuccessAndNeverDowngradesARecoverableFailureToMissing() {
|
|
val success = byteArrayOf(CREDENTIAL_STATUS_OK, 7)
|
|
var afterSuccessCalled = false
|
|
assertArrayEquals(
|
|
success,
|
|
credentialOpenWithFallback(
|
|
{ byteArrayOf(CREDENTIAL_STATUS_AUTHENTICATION_FAILED) },
|
|
{ success },
|
|
{
|
|
afterSuccessCalled = true
|
|
byteArrayOf(CREDENTIAL_STATUS_OK, 8)
|
|
},
|
|
),
|
|
)
|
|
assertFalse(afterSuccessCalled)
|
|
assertEquals(
|
|
CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE,
|
|
credentialOpenWithFallback(
|
|
{ byteArrayOf(CREDENTIAL_STATUS_KEY_MISSING) },
|
|
{ byteArrayOf(CREDENTIAL_STATUS_AUTHENTICATION_FAILED) },
|
|
{ byteArrayOf(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE) },
|
|
)
|
|
.first(),
|
|
)
|
|
assertEquals(
|
|
CREDENTIAL_STATUS_AUTHENTICATION_FAILED,
|
|
credentialOpenWithFallback(
|
|
{ byteArrayOf(CREDENTIAL_STATUS_KEY_MISSING) },
|
|
{ byteArrayOf(CREDENTIAL_STATUS_AUTHENTICATION_FAILED) },
|
|
)
|
|
.first(),
|
|
)
|
|
assertEquals(
|
|
CREDENTIAL_STATUS_MALFORMED,
|
|
credentialOpenWithFallback(
|
|
{ byteArrayOf(CREDENTIAL_STATUS_KEY_MISSING) },
|
|
{ byteArrayOf(CREDENTIAL_STATUS_MALFORMED) },
|
|
)
|
|
.first(),
|
|
)
|
|
assertEquals(
|
|
CREDENTIAL_STATUS_KEY_MISSING,
|
|
credentialOpenWithFallback(
|
|
{ byteArrayOf(CREDENTIAL_STATUS_KEY_MISSING) },
|
|
{ byteArrayOf(CREDENTIAL_STATUS_KEY_MISSING) },
|
|
)
|
|
.first(),
|
|
)
|
|
}
|
|
}
|