Files
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

543 lines
21 KiB
Kotlin

package com.openless.app
import android.os.Handler
import android.os.Looper
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.security.keystore.KeyProperties
import android.security.keystore.UserNotAuthenticatedException
import androidx.annotation.Keep
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.security.GeneralSecurityException
import java.security.InvalidKeyException
import java.security.KeyStore
import java.security.KeyStoreException
import java.security.SecureRandom
import java.security.UnrecoverableKeyException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
import javax.crypto.AEADBadTagException
import javax.crypto.BadPaddingException
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.SecretKeySpec
internal const val CREDENTIAL_STATUS_OK: Byte = 0
internal const val CREDENTIAL_STATUS_KEY_MISSING: Byte = 1
internal const val CREDENTIAL_STATUS_AUTHENTICATION_FAILED: Byte = 2
internal const val CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE: Byte = 3
internal const val CREDENTIAL_STATUS_MALFORMED: Byte = 4
private fun credentialResponse(status: Byte, payload: ByteArray = byteArrayOf()): ByteArray {
return byteArrayOf(status) + payload
}
internal fun credentialOpenWithFallback(vararg attempts: () -> ByteArray): ByteArray {
var temporarilyUnavailable: ByteArray? = null
var malformed: ByteArray? = null
var authenticationFailed: ByteArray? = null
for (attempt in attempts) {
val response = attempt()
when (response.firstOrNull()) {
CREDENTIAL_STATUS_OK -> return response
CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE ->
temporarilyUnavailable = temporarilyUnavailable ?: response
CREDENTIAL_STATUS_MALFORMED -> malformed = malformed ?: response
CREDENTIAL_STATUS_AUTHENTICATION_FAILED ->
authenticationFailed = authenticationFailed ?: response
CREDENTIAL_STATUS_KEY_MISSING -> {}
else ->
temporarilyUnavailable =
temporarilyUnavailable
?: credentialResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE)
}
}
return temporarilyUnavailable
?: malformed
?: authenticationFailed
?: credentialResponse(CREDENTIAL_STATUS_KEY_MISSING)
}
private fun diagnosticResponse(status: Byte, error: Throwable): ByteArray {
val name = buildString {
append(error.javaClass.simpleName.take(40))
error.cause?.javaClass?.simpleName?.let { cause ->
append('/')
append(cause.take(40))
}
keystoreNumericCode(error)?.let { code ->
append(':')
append(code)
}
append(':')
append(if (Looper.myLooper() == Looper.getMainLooper()) "main" else "bg")
}
return credentialResponse(status, name.toByteArray(Charsets.UTF_8))
}
private fun keystoreNumericCode(error: Throwable): Int? {
var current: Throwable? = error
while (current != null) {
try {
for (methodName in arrayOf("getNumericErrorCode", "getErrorCode")) {
val method =
current.javaClass.methods.firstOrNull { it.name == methodName && it.parameterCount == 0 }
?: continue
when (val value = method.invoke(current)) {
is Int -> return value
}
}
} catch (_: Throwable) {}
current = current.cause
}
return null
}
internal fun credentialStatusForKeyLoadFailure(error: GeneralSecurityException): Byte {
return when (error) {
is KeyPermanentlyInvalidatedException -> CREDENTIAL_STATUS_KEY_MISSING
else -> CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE
}
}
internal fun credentialStatusForCipherKeyFailure(error: InvalidKeyException): Byte {
return when (error) {
is UserNotAuthenticatedException -> CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE
// Cipher cannot use this key, so the envelope cannot be recovered.
else -> CREDENTIAL_STATUS_KEY_MISSING
}
}
/** AndroidKeyStore owner with fixed, secret-free status responses for JNI. */
internal class AndroidKeystoreCredentialVault(private val alias: String) {
@Synchronized
fun seal(plaintext: ByteArray, aad: ByteArray): ByteArray {
return try {
credentialResponse(
CREDENTIAL_STATUS_OK,
OpenLessCredentialCipher.seal(getOrCreateKey(), plaintext, aad),
)
} catch (error: KeyPermanentlyInvalidatedException) {
diagnosticResponse(credentialStatusForKeyLoadFailure(error), error)
} catch (error: UnrecoverableKeyException) {
// Keystore2 wraps backend-busy and other provider failures in this
// broad JCA exception too. Only an absent alias or the explicit
// permanent-invalidated exception is safe to treat as data loss.
diagnosticResponse(credentialStatusForKeyLoadFailure(error), error)
} catch (error: InvalidKeyException) {
diagnosticResponse(credentialStatusForCipherKeyFailure(error), error)
} catch (_: IllegalArgumentException) {
credentialResponse(CREDENTIAL_STATUS_MALFORMED)
} catch (error: GeneralSecurityException) {
diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error)
} catch (error: IOException) {
diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error)
} catch (error: RuntimeException) {
diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error)
}
}
@Synchronized
fun open(packet: ByteArray, aad: ByteArray): ByteArray {
return try {
val key = existingKey() ?: return credentialResponse(CREDENTIAL_STATUS_KEY_MISSING)
credentialResponse(
CREDENTIAL_STATUS_OK,
OpenLessCredentialCipher.open(key, packet, aad),
)
} catch (error: KeyPermanentlyInvalidatedException) {
diagnosticResponse(credentialStatusForKeyLoadFailure(error), error)
} catch (error: UnrecoverableKeyException) {
diagnosticResponse(credentialStatusForKeyLoadFailure(error), error)
} catch (error: InvalidKeyException) {
diagnosticResponse(credentialStatusForCipherKeyFailure(error), error)
} catch (_: AEADBadTagException) {
credentialResponse(CREDENTIAL_STATUS_AUTHENTICATION_FAILED)
} catch (_: BadPaddingException) {
credentialResponse(CREDENTIAL_STATUS_AUTHENTICATION_FAILED)
} catch (_: IllegalArgumentException) {
credentialResponse(CREDENTIAL_STATUS_MALFORMED)
} catch (error: GeneralSecurityException) {
diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error)
} catch (error: IOException) {
diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error)
} catch (error: RuntimeException) {
diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error)
}
}
@Synchronized
fun deleteKey(): ByteArray {
return try {
val keyStore = loadKeyStore()
if (keyStore.containsAlias(alias)) {
keyStore.deleteEntry(alias)
}
credentialResponse(CREDENTIAL_STATUS_OK)
} catch (error: GeneralSecurityException) {
diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error)
} catch (error: IOException) {
diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error)
} catch (error: RuntimeException) {
diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error)
}
}
@Synchronized
fun keyExists(): ByteArray {
return try {
credentialResponse(
CREDENTIAL_STATUS_OK,
byteArrayOf(if (loadKeyStore().containsAlias(alias)) 1 else 0),
)
} catch (error: GeneralSecurityException) {
diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error)
} catch (error: IOException) {
diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error)
} catch (error: RuntimeException) {
diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error)
}
}
@Synchronized
fun ensureKey(): ByteArray {
return try {
getOrCreateKey()
credentialResponse(CREDENTIAL_STATUS_OK)
} catch (error: KeyPermanentlyInvalidatedException) {
diagnosticResponse(credentialStatusForKeyLoadFailure(error), error)
} catch (error: UnrecoverableKeyException) {
diagnosticResponse(credentialStatusForKeyLoadFailure(error), error)
} catch (error: GeneralSecurityException) {
diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error)
} catch (error: IOException) {
diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error)
} catch (error: RuntimeException) {
diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error)
}
}
@Throws(GeneralSecurityException::class, IOException::class)
private fun existingKey(): SecretKey? {
val keyStore = loadKeyStore()
if (!keyStore.containsAlias(alias)) {
return null
}
return keyStore.getKey(alias, null) as? SecretKey
}
@Throws(GeneralSecurityException::class, IOException::class)
private fun getOrCreateKey(): SecretKey {
existingKey()?.let {
return it
}
return createKey()
}
@Throws(GeneralSecurityException::class, IOException::class)
private fun createKey(): SecretKey {
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER)
generator.init(
KeyGenParameterSpec.Builder(
alias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.setRandomizedEncryptionRequired(true)
.build()
)
return generator.generateKey()
}
@Throws(KeyStoreException::class, IOException::class, GeneralSecurityException::class)
private fun loadKeyStore(): KeyStore {
return KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) }
}
private companion object {
const val KEYSTORE_PROVIDER = "AndroidKeyStore"
}
}
/**
* App-private AES-GCM wrapping key used when AndroidKeyStore/KeyMint rejects
* AES-GCM (observed as KeyStoreException numeric 10 on some HyperOS devices).
* The raw key is UID-scoped, same as the envelope file; it is not hardware-backed.
*/
internal class SoftwareAesCredentialStore(private val directory: File) {
fun keyExists(): Boolean {
val file = keyFile()
return file.isFile && file.length() == KEY_BYTES.toLong()
}
fun isMigrated(): Boolean = migratedFile().isFile
fun seal(plaintext: ByteArray, aad: ByteArray): ByteArray {
return try {
val key = loadOrCreateKey()
credentialResponse(CREDENTIAL_STATUS_OK, OpenLessCredentialCipher.seal(key, plaintext, aad))
} catch (_: IllegalArgumentException) {
credentialResponse(CREDENTIAL_STATUS_MALFORMED)
} catch (error: GeneralSecurityException) {
diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error)
} catch (error: IOException) {
diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error)
} catch (error: RuntimeException) {
diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error)
}
}
fun open(packet: ByteArray, aad: ByteArray): ByteArray {
return try {
val key = loadExistingKey() ?: return credentialResponse(CREDENTIAL_STATUS_KEY_MISSING)
credentialResponse(
CREDENTIAL_STATUS_OK,
OpenLessCredentialCipher.open(key, packet, aad),
)
} catch (_: AEADBadTagException) {
credentialResponse(CREDENTIAL_STATUS_AUTHENTICATION_FAILED)
} catch (_: BadPaddingException) {
credentialResponse(CREDENTIAL_STATUS_AUTHENTICATION_FAILED)
} catch (_: IllegalArgumentException) {
credentialResponse(CREDENTIAL_STATUS_MALFORMED)
} catch (error: GeneralSecurityException) {
diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error)
} catch (error: IOException) {
diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error)
} catch (error: RuntimeException) {
diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error)
}
}
fun deleteKey(): ByteArray {
return try {
deleteIfPresent(keyFile())
deleteIfPresent(migratedFile())
credentialResponse(CREDENTIAL_STATUS_OK)
} catch (error: IOException) {
diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error)
} catch (error: RuntimeException) {
diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error)
}
}
fun markMigrated(): ByteArray {
return try {
if (!directory.exists() && !directory.mkdirs() && !directory.isDirectory) {
throw IOException("software-migrated-dir")
}
migratedFile().writeBytes(byteArrayOf(1))
restrictPrivate(migratedFile())
credentialResponse(CREDENTIAL_STATUS_OK)
} catch (error: IOException) {
diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error)
} catch (error: RuntimeException) {
diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error)
}
}
private fun keyFile() = File(directory, SOFTWARE_KEY_NAME)
private fun migratedFile() = File(directory, SOFTWARE_MIGRATED_NAME)
@Throws(GeneralSecurityException::class, IOException::class)
private fun loadExistingKey(): SecretKey? {
val file = keyFile()
if (!file.isFile) {
return null
}
val raw = file.readBytes()
if (raw.size != KEY_BYTES) {
throw GeneralSecurityException("software-key-size")
}
return SecretKeySpec(raw, "AES")
}
@Throws(GeneralSecurityException::class, IOException::class)
private fun loadOrCreateKey(): SecretKey {
loadExistingKey()?.let {
return it
}
if (!directory.exists() && !directory.mkdirs() && !directory.isDirectory) {
throw IOException("software-key-dir")
}
val raw = ByteArray(KEY_BYTES)
SecureRandom().nextBytes(raw)
val file = keyFile()
val tmp = File(directory, "$SOFTWARE_KEY_NAME.tmp")
try {
FileOutputStream(tmp).use { output ->
output.write(raw)
output.fd.sync()
}
restrictPrivate(tmp)
if (!tmp.renameTo(file)) {
return loadExistingKey() ?: throw IOException("software-key-install")
}
} finally {
if (tmp.exists()) {
tmp.delete()
}
}
return SecretKeySpec(raw, "AES")
}
@Throws(IOException::class)
private fun deleteIfPresent(file: File) {
if (file.exists() && !file.delete()) {
throw IOException("software-key-delete")
}
}
private fun restrictPrivate(file: File) {
file.setReadable(false, false)
file.setWritable(false, false)
file.setReadable(true, true)
file.setWritable(true, true)
}
companion object {
const val SOFTWARE_KEY_NAME = "credentials.sw.key"
const val SOFTWARE_MIGRATED_NAME = "credentials.sw.migrated"
const val KEY_BYTES = 32
}
}
@Keep
object OpenLessCredentialVault {
// v2 alias on this HyperOS device became unusable (InvalidKeyException /
// ProviderException). v3 is a fresh Keystore2 slot after envelope wipe.
private const val KEY_ALIAS = "com.openless.app.credentials.v3"
private const val MIGRATION_MARKER_ALIAS = "com.openless.app.credentials.v3.migrated"
private const val LEGACY_KEY_ALIAS = "com.openless.app.credentials.v2"
private const val LEGACY_MIGRATION_MARKER_ALIAS = "com.openless.app.credentials.v2.migrated"
private val backend = AndroidKeystoreCredentialVault(KEY_ALIAS)
private val migrationMarker = AndroidKeystoreCredentialVault(MIGRATION_MARKER_ALIAS)
private val legacyBackend = AndroidKeystoreCredentialVault(LEGACY_KEY_ALIAS)
private val legacyMigrationMarker =
AndroidKeystoreCredentialVault(LEGACY_MIGRATION_MARKER_ALIAS)
@JvmStatic
fun seal(plaintext: ByteArray, aad: ByteArray): ByteArray =
runOnMain {
val software = softwareStore()
if (software?.keyExists() == true) {
return@runOnMain software.seal(plaintext, aad)
}
val keystore = backend.seal(plaintext, aad)
if (
keystore.first() == CREDENTIAL_STATUS_OK ||
keystore.first() == CREDENTIAL_STATUS_MALFORMED
) {
return@runOnMain keystore
}
val fallback = software?.seal(plaintext, aad) ?: return@runOnMain keystore
if (fallback.first() == CREDENTIAL_STATUS_OK) fallback else keystore
}
@JvmStatic
fun open(packet: ByteArray, aad: ByteArray): ByteArray =
runOnMain {
val software = softwareStore()
credentialOpenWithFallback(
{
software?.open(packet, aad)
?: credentialResponse(CREDENTIAL_STATUS_KEY_MISSING)
},
{ backend.open(packet, aad) },
{ legacyBackend.open(packet, aad) },
)
}
@JvmStatic
fun deleteKey(): ByteArray =
runOnMain {
val software = softwareStore()?.deleteKey() ?: credentialResponse(CREDENTIAL_STATUS_OK)
val keystore = backend.deleteKey()
val legacy = legacyBackend.deleteKey()
when {
software.first() != CREDENTIAL_STATUS_OK -> software
keystore.first() != CREDENTIAL_STATUS_OK -> keystore
legacy.first() != CREDENTIAL_STATUS_OK ->
credentialResponse(
CREDENTIAL_STATUS_OK,
"legacy-key-cleanup-deferred".toByteArray(Charsets.UTF_8),
)
else -> keystore
}
}
@JvmStatic
fun migrationComplete(): ByteArray =
runOnMain {
val software = softwareStore()
if (software?.isMigrated() == true) {
credentialResponse(CREDENTIAL_STATUS_OK, byteArrayOf(1))
} else {
val current = migrationMarker.keyExists()
if (
current.first() != CREDENTIAL_STATUS_OK ||
current.contentEquals(
credentialResponse(CREDENTIAL_STATUS_OK, byteArrayOf(1))
)
) {
current
} else {
legacyMigrationMarker.keyExists()
}
}
}
@JvmStatic
fun markMigrationComplete(): ByteArray =
runOnMain {
val software = softwareStore()
if (software?.keyExists() == true) {
return@runOnMain software.markMigrated()
}
val keystore = migrationMarker.ensureKey()
if (keystore.first() == CREDENTIAL_STATUS_OK) {
return@runOnMain keystore
}
software?.markMigrated() ?: keystore
}
private fun softwareStore(): SoftwareAesCredentialStore? {
val context = OpenLessAppContext.context ?: return null
return SoftwareAesCredentialStore(File(context.filesDir, "OpenLess"))
}
private fun runOnMain(block: () -> ByteArray): ByteArray {
if (Looper.myLooper() == Looper.getMainLooper()) {
return block()
}
val result = arrayOfNulls<ByteArray>(1)
val error = arrayOfNulls<Throwable>(1)
val latch = CountDownLatch(1)
Handler(Looper.getMainLooper()).post {
try {
result[0] = block()
} catch (thrown: Throwable) {
error[0] = thrown
} finally {
latch.countDown()
}
}
if (!latch.await(8, TimeUnit.SECONDS)) {
return diagnosticResponse(
CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE,
TimeoutException("keystore-main-timeout"),
)
}
error[0]?.let { thrown ->
return diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, thrown)
}
return result[0] ?: credentialResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE)
}
}