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>
This commit is contained in:
authored and GitHub committed 2026-09-13 01:58:26 +08:00
1 parent 7971df2199
commit 82357e8df3
14 files changed
+1084 -107

No files matched your search

@@ -1,18 +1,29 @@
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
@@ -24,6 +35,67 @@ private fun credentialResponse(status: Byte, payload: ByteArray = byteArrayOf())
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
@@ -31,6 +103,14 @@ internal fun credentialStatusForKeyLoadFailure(error: GeneralSecurityException):
}
}
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
@@ -41,18 +121,22 @@ internal class AndroidKeystoreCredentialVault(private val alias: String) {
OpenLessCredentialCipher.seal(getOrCreateKey(), plaintext, aad),
)
} catch (error: KeyPermanentlyInvalidatedException) {
credentialResponse(credentialStatusForKeyLoadFailure(error))
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.
credentialResponse(credentialStatusForKeyLoadFailure(error))
diagnosticResponse(credentialStatusForKeyLoadFailure(error), error)
} catch (error: InvalidKeyException) {
diagnosticResponse(credentialStatusForCipherKeyFailure(error), error)
} catch (_: IllegalArgumentException) {
credentialResponse(CREDENTIAL_STATUS_MALFORMED)
} catch (_: GeneralSecurityException) {
credentialResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE)
} catch (_: IOException) {
credentialResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE)
} 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)
}
}
@@ -65,19 +149,23 @@ internal class AndroidKeystoreCredentialVault(private val alias: String) {
OpenLessCredentialCipher.open(key, packet, aad),
)
} catch (error: KeyPermanentlyInvalidatedException) {
credentialResponse(credentialStatusForKeyLoadFailure(error))
diagnosticResponse(credentialStatusForKeyLoadFailure(error), error)
} catch (error: UnrecoverableKeyException) {
credentialResponse(credentialStatusForKeyLoadFailure(error))
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 (_: GeneralSecurityException) {
credentialResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE)
} catch (_: IOException) {
credentialResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE)
} 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)
}
}
@@ -89,10 +177,12 @@ internal class AndroidKeystoreCredentialVault(private val alias: String) {
keyStore.deleteEntry(alias)
}
credentialResponse(CREDENTIAL_STATUS_OK)
} catch (_: GeneralSecurityException) {
credentialResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE)
} catch (_: IOException) {
credentialResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE)
} 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)
}
}
@@ -103,10 +193,12 @@ internal class AndroidKeystoreCredentialVault(private val alias: String) {
CREDENTIAL_STATUS_OK,
byteArrayOf(if (loadKeyStore().containsAlias(alias)) 1 else 0),
)
} catch (_: GeneralSecurityException) {
credentialResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE)
} catch (_: IOException) {
credentialResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE)
} 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)
}
}
@@ -116,13 +208,15 @@ internal class AndroidKeystoreCredentialVault(private val alias: String) {
getOrCreateKey()
credentialResponse(CREDENTIAL_STATUS_OK)
} catch (error: KeyPermanentlyInvalidatedException) {
credentialResponse(credentialStatusForKeyLoadFailure(error))
diagnosticResponse(credentialStatusForKeyLoadFailure(error), error)
} catch (error: UnrecoverableKeyException) {
credentialResponse(credentialStatusForKeyLoadFailure(error))
} catch (_: GeneralSecurityException) {
credentialResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE)
} catch (_: IOException) {
credentialResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE)
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)
}
}
@@ -140,6 +234,11 @@ internal class AndroidKeystoreCredentialVault(private val alias: String) {
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(
@@ -165,21 +264,279 @@ internal class AndroidKeystoreCredentialVault(private val alias: String) {
}
}
/**
* 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 {
private const val KEY_ALIAS = "com.openless.app.credentials.v2"
private const val MIGRATION_MARKER_ALIAS = "com.openless.app.credentials.v2.migrated"
// 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 = backend.seal(plaintext, aad)
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 = backend.open(packet, aad)
@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 = backend.deleteKey()
@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 = migrationMarker.keyExists()
@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 = migrationMarker.ensureKey()
@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)
}
}
@@ -1,6 +1,8 @@
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
@@ -32,6 +34,19 @@ class OpenLessCredentialVaultInstrumentedTest {
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()
@@ -66,6 +81,35 @@ class OpenLessCredentialVaultInstrumentedTest {
}
}
@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()
@@ -1,7 +1,9 @@
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
@@ -110,4 +112,103 @@ class OpenLessCredentialCipherTest {
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(),
)
}
}
@@ -3037,11 +3037,23 @@ impl OpenLessBackend {
}
pub async fn start(&self) -> Result<StartupSnapshot, BackendError> {
let credentials = self
.deps
.credential_store
.status(self.get_preferences())
.await?;
let preferences = self.get_preferences();
let credentials = match self.deps.credential_store.status(preferences.clone()).await {
Ok(credentials) => credentials,
Err(error) if error.code == BackendErrorCode::Persistence => {
// Vault unreadable (e.g. Android Keystore temporarily unavailable)
// must not fail the 2.0 handshake. Dictation still gates on read().
log::warn!("[core] startup credential status unavailable: {error}");
CredentialsStatus {
pipeline_mode: crate::shared_types::effective_pipeline_mode(
preferences.multimodal_pipeline_enabled,
preferences.pipeline_mode,
),
..CredentialsStatus::default()
}
}
Err(error) => return Err(error),
};
let mut state = self.state.write().expect("backend state lock poisoned");
state.credentials = credentials;
if state.running {
@@ -8588,6 +8600,75 @@ mod tests {
);
}
struct PersistenceOnlyCredentialStore;
impl crate::credentials::CredentialStore for PersistenceOnlyCredentialStore {
fn status(
&self,
_preferences: crate::shared_types::UserPreferences,
) -> BoxFuture<'static, Result<CredentialsStatus, BackendError>> {
Box::pin(async {
Err(BackendError::new(
BackendErrorCode::Persistence,
"无法读取已保存的凭据:temporarily unavailable",
))
})
}
fn read(
&self,
_key: crate::credentials::CredentialKey,
) -> BoxFuture<'static, Result<Option<crate::credentials::SecretValue>, BackendError>>
{
Box::pin(async { Ok(None) })
}
fn write(
&self,
_key: crate::credentials::CredentialKey,
_value: crate::credentials::SecretValue,
) -> BoxFuture<'static, Result<(), BackendError>> {
Box::pin(async { Ok(()) })
}
fn remove(
&self,
_key: crate::credentials::CredentialKey,
) -> BoxFuture<'static, Result<(), BackendError>> {
Box::pin(async { Ok(()) })
}
}
#[tokio::test]
async fn start_survives_persistent_vault_read_failure() {
let data_dir = TestDataDir::new("vault-persistence-start");
let backend = OpenLessBackend::new(
BackendConfig {
data_dir: data_dir.path().to_path_buf(),
..BackendConfig::default()
},
BackendDependencies {
host_actions: Arc::new(FakeHost::default()),
text_inserter: Arc::new(FakeInserter),
dictation_engine: Arc::new(FakeEngine),
task_spawner: Arc::new(TokioTaskSpawner),
credential_store: Arc::new(PersistenceOnlyCredentialStore),
services: crate::domains::BackendServices::unsupported(),
local_asr_runtime: None,
marketplace_config: None,
selection_runtime: None,
selection_polisher: None,
qa_runtime: None,
},
)
.unwrap();
let first = backend.start().await.expect("first start must not fail");
let second = backend.start().await.expect("handshake start must not fail");
assert!(first.backend.running);
assert!(second.backend.running);
let _ = data_dir;
}
#[tokio::test]
async fn front_app_is_captured_without_reading_documents_when_cursor_context_is_disabled() {
let data_dir = TestDataDir::new("host-context-privacy");
+36 -1
View File
@@ -1645,7 +1645,9 @@ mod linux_app {
ui.ctx().copy_text(display);
}
}
None => ui.label("完整指纹不可用。请勿安装或信任下载的证书。"),
None => {
ui.label("完整指纹不可用。请勿安装或信任下载的证书。");
}
}
ui.label("安装或开启完全信任前,在手机系统的证书详情中核对全部 SHA-256 字符,必须与此处一致。网页、描述文件名称和标识不能证明证书身份。若不一致或无法查看,请停止并移除已下载或安装的描述文件。");
ui.label("描述文件应只包含一张根证书。若有其他证书、VPN 或设备管理配置,请勿安装。首次下载仍可能被局域网攻击者替换;核验后再信任。根证书可签发其他证书,不再使用时请移除。");
@@ -3002,6 +3004,7 @@ mod linux_app {
port: 8443,
urls: vec!["https://old.example.invalid".into()],
urls_stale,
ca_fingerprint_sha256: None,
locale: "en".into(),
connection_count: 0,
active_session_id: None,
@@ -3015,6 +3018,38 @@ mod linux_app {
}
}
#[test]
fn running_remote_status_shows_ca_fingerprint_or_unavailable_warning() {
let mut app = disconnected_app();
let fingerprint = "ab".repeat(32);
app.remote_access = Some((
openless_core::RemoteInputStatus {
enabled: true,
running: true,
starting: false,
port: 8443,
urls: vec!["https://phone.example.invalid".into()],
urls_stale: false,
ca_fingerprint_sha256: Some(fingerprint.clone()),
locale: "zh-CN".into(),
connection_count: 0,
active_session_id: None,
},
"fixture-pin".into(),
));
let text = rendered_text(|ui| app.remote_ui(ui));
assert!(text.contains("本机根证书 SHA-256"), "{text}");
assert!(
text.contains("AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB"),
"{text}"
);
assert!(!text.contains("完整指纹不可用"), "{text}");
app.remote_access.as_mut().unwrap().0.ca_fingerprint_sha256 = None;
let text = rendered_text(|ui| app.remote_ui(ui));
assert!(text.contains("完整指纹不可用。请勿安装或信任下载的证书。"), "{text}");
}
#[test]
fn continuation_turn_keeps_receiving_output_and_approval() {
let mut app = OpenLessEguiApp::new(
@@ -14,6 +14,10 @@ const paths = {
rustStore: new URL('../src-tauri/src/persistence/android_credentials.rs', import.meta.url),
credentials: new URL('../src-tauri/src/persistence/credentials.rs', import.meta.url),
jni: new URL('../src-tauri/src/android/jni.rs', import.meta.url),
credentialCommands: new URL('../src-tauri/src/commands/credentials.rs', import.meta.url),
coreApi: new URL('../crates/openless-core/src/api.rs', import.meta.url),
mobileRuntime: new URL('../src-tauri/src/mobile_runtime.rs', import.meta.url),
channelList: new URL('../src/pages/settings/ChannelList.tsx', import.meta.url),
copyScript: new URL('./copy-android-scaffolding.mjs', import.meta.url),
ci: new URL('../../../.github/workflows/ci.yml', import.meta.url),
};
@@ -37,18 +41,35 @@ function requirePattern(source, pattern, message) {
}
}
const [cipher, vault, unitTest, instrumentedTest, rustStore, credentials, jni, copyScript, ci] =
await Promise.all([
requiredSource('pure AES-GCM codec', paths.cipher),
requiredSource('Android Keystore bridge', paths.vault),
requiredSource('JVM cipher tests', paths.unitTest),
requiredSource('Android Keystore instrumentation tests', paths.instrumentedTest),
requiredSource('Rust Android credential store', paths.rustStore),
requiredSource('credentials integration', paths.credentials),
requiredSource('JNI bridge', paths.jni),
requiredSource('Android scaffolding copier', paths.copyScript),
requiredSource('PR CI workflow', paths.ci),
]);
const [
cipher,
vault,
unitTest,
instrumentedTest,
rustStore,
credentials,
jni,
credentialCommands,
coreApi,
mobileRuntime,
channelList,
copyScript,
ci,
] = await Promise.all([
requiredSource('pure AES-GCM codec', paths.cipher),
requiredSource('Android Keystore bridge', paths.vault),
requiredSource('JVM cipher tests', paths.unitTest),
requiredSource('Android Keystore instrumentation tests', paths.instrumentedTest),
requiredSource('Rust Android credential store', paths.rustStore),
requiredSource('credentials integration', paths.credentials),
requiredSource('JNI bridge', paths.jni),
requiredSource('credential commands', paths.credentialCommands),
requiredSource('Core API', paths.coreApi),
requiredSource('mobile runtime', paths.mobileRuntime),
requiredSource('channel list', paths.channelList),
requiredSource('Android scaffolding copier', paths.copyScript),
requiredSource('PR CI workflow', paths.ci),
]);
requirePattern(cipher, /AES\/GCM\/NoPadding/, 'cipher must use AES/GCM/NoPadding');
requirePattern(cipher, /NONCE_BYTES\s*=\s*12/, 'cipher must require a 12-byte nonce');
@@ -79,6 +100,16 @@ if (
for (const pattern of [
/is\s+KeyPermanentlyInvalidatedException\s*->\s*CREDENTIAL_STATUS_KEY_MISSING/,
/else\s*->\s*CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE/,
/fun\s+credentialStatusForCipherKeyFailure/,
/is\s+UserNotAuthenticatedException\s*->\s*CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE/,
/com\.openless\.app\.credentials\.v3/,
/com\.openless\.app\.credentials\.v2/,
/runOnMain/,
/class SoftwareAesCredentialStore/,
/credentials\.sw\.key/,
/SecretKeySpec/,
/FileOutputStream/,
/\.fd\.sync\(\)/,
]) {
requirePattern(vault, pattern, `Keystore failure classifier is missing ${pattern}`);
}
@@ -93,10 +124,20 @@ for (const pattern of [
/tamperedCiphertext/,
/tamperedAad/,
/unrecoverableKeyExceptionRemainsRetryable/,
/invalidKeyExceptionIsTreatedAsUnrecoverable/,
/softwareAesRoundTripWithoutAndroidKeyStore/,
/softwareAesMissingKeyIsReportedAsMissing/,
/openFallbackPrefersSuccessAndNeverDowngradesARecoverableFailureToMissing/,
]) {
requirePattern(unitTest, pattern, `JVM crypto tests are missing ${pattern}`);
}
for (const pattern of [/assertNull\([^)]*\.encoded/, /deletedKey/, /tamperedCiphertext/]) {
for (const pattern of [
/assertNull\([^)]*\.encoded/,
/deletedKey/,
/tamperedCiphertext/,
/publicFacadeReadsBeta1V2Envelope/,
/softwareKeyCreatedBeforeEnvelopeCommitDoesNotHideV2Envelope/,
]) {
requirePattern(
instrumentedTest,
pattern,
@@ -104,6 +145,35 @@ for (const pattern of [/assertNull\([^)]*\.encoded/, /deletedKey/, /tamperedCiph
);
}
for (const pattern of [/recreate\s*=\s*true/, /deleteEntryQuiet/]) {
if (pattern.test(vault)) {
throw new Error(`credential sealing must not destructively rotate a live key: ${pattern}`);
}
}
const diagnosticSources = [
vault,
rustStore,
credentials,
jni,
credentialCommands,
coreApi,
mobileRuntime,
channelList,
].join('\n');
for (const pattern of [
/#region agent log/,
/\[agent-dbg\]/,
/f73b06/,
/hypothesisId/,
/debug-f73b06\.log/,
/127\.0\.0\.1:7807/,
]) {
if (pattern.test(diagnosticSources)) {
throw new Error(`one-off agent diagnostic must not ship: ${pattern}`);
}
}
for (const pattern of [
/openless-android-credentials/,
/version:\s*u32/,
@@ -55,7 +55,7 @@ if ($backendSource -match 'qa_runtime:\s*None' -or
exit 1
}
$installer = $mainSource.IndexOf('ensure_fcitx5_ready(&config)?')
$installer = $mainSource.IndexOf('ensure_fcitx5_ready(&config)')
$listener = $mainSource.IndexOf('Fcitx5HotkeyListener::start')
if ($installer -lt 0 -or $listener -lt 0 -or $installer -gt $listener) {
Write-Error "Linux AppImage fcitx5 installation must run before the hotkey listener"
+2 -1
View File
@@ -33,6 +33,8 @@ serde_json = "1"
semver = "1"
# OpenRouter ASR 把音频以标准 base64(带 padding)放进 JSON body(issue #582)。
base64 = "0.22"
# Android in-app updater and host tests for Tauri-wrapped minisign pubkey/signature.
minisign-verify = "0.2"
sha2 = "0.10"
# 讯飞 RTASR/IFASR 签名:signa = Base64(HmacSHA1(MD5(appid + ts), apiKey))。
md-5 = "0.10"
@@ -120,7 +122,6 @@ features = ["linux-native-sync-persistent", "crypto-rust"]
jni = "0.21"
ndk-context = "0.1"
tao = "0.35"
minisign-verify = "0.2"
[target.'cfg(target_os = "macos")'.dependencies]
block2 = "0.5"
+97 -21
View File
@@ -209,22 +209,64 @@ pub mod android {
}
}
fn log_keystore_failure(method: &str, kind: &str, detail: &str) {
let safe: String = detail
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | ':' | '-' | '/') {
ch
} else {
' '
}
})
.take(120)
.collect();
if safe.is_empty() {
log::warn!("[vault] Android Keystore method={method} status={kind}");
} else {
log::warn!("[vault] Android Keystore method={method} status={kind} detail={safe}");
}
}
fn keystore_temporarily_unavailable<T>(env: &mut JNIEnv) -> Result<T, String> {
clear_pending_exception(env);
Err(KEYSTORE_TEMPORARILY_UNAVAILABLE.to_string())
}
fn credential_response(response: Vec<u8>) -> Result<Vec<u8>, String> {
fn credential_response(method: &str, response: Vec<u8>) -> Result<Vec<u8>, String> {
let Some((&status, payload)) = response.split_first() else {
log_keystore_failure(method, "empty_response", "");
return Err(KEYSTORE_TEMPORARILY_UNAVAILABLE.to_string());
};
match status {
0 => Ok(payload.to_vec()),
1 => Err(KEYSTORE_KEY_MISSING.to_string()),
2 => Err(KEYSTORE_AUTHENTICATION_FAILED.to_string()),
3 => Err(KEYSTORE_TEMPORARILY_UNAVAILABLE.to_string()),
4 => Err(KEYSTORE_MALFORMED.to_string()),
_ => Err(KEYSTORE_TEMPORARILY_UNAVAILABLE.to_string()),
0 => {
if method == "deleteKey" && payload == b"legacy-key-cleanup-deferred" {
log_keystore_failure(method, "legacy_cleanup_deferred", "");
}
Ok(payload.to_vec())
}
1 => {
let detail = String::from_utf8_lossy(payload);
log_keystore_failure(method, "status_key_missing", &detail);
Err(KEYSTORE_KEY_MISSING.to_string())
}
2 => {
log_keystore_failure(method, "authentication_failed", "");
Err(KEYSTORE_AUTHENTICATION_FAILED.to_string())
}
3 => {
let detail = String::from_utf8_lossy(payload);
log_keystore_failure(method, "status_temporarily_unavailable", &detail);
Err(KEYSTORE_TEMPORARILY_UNAVAILABLE.to_string())
}
4 => {
log_keystore_failure(method, "malformed", "");
Err(KEYSTORE_MALFORMED.to_string())
}
_ => {
log_keystore_failure(method, "status_unknown", &status.to_string());
Err(KEYSTORE_TEMPORARILY_UNAVAILABLE.to_string())
}
}
}
@@ -236,15 +278,24 @@ pub mod android {
with_android_env(|env, context| {
let class = match load_context_class(env, context, CREDENTIAL_VAULT_CLASS) {
Ok(class) => class,
Err(_) => return keystore_temporarily_unavailable(env),
Err(error) => {
log_keystore_failure(method, "class_load", &error);
return keystore_temporarily_unavailable(env);
}
};
let first_array = match env.byte_array_from_slice(first) {
Ok(array) => array,
Err(_) => return keystore_temporarily_unavailable(env),
Err(error) => {
log_keystore_failure(method, "jni_array", &error.to_string());
return keystore_temporarily_unavailable(env);
}
};
let second_array = match env.byte_array_from_slice(second) {
Ok(array) => array,
Err(_) => return keystore_temporarily_unavailable(env),
Err(error) => {
log_keystore_failure(method, "jni_array", &error.to_string());
return keystore_temporarily_unavailable(env);
}
};
let first_object = JObject::from(first_array);
let second_object = JObject::from(second_array);
@@ -258,21 +309,31 @@ pub mod android {
],
) {
Ok(value) => value,
Err(_) => return keystore_temporarily_unavailable(env),
Err(error) => {
log_keystore_failure(method, "jni_call", &error.to_string());
return keystore_temporarily_unavailable(env);
}
};
let object = match value.l() {
Ok(object) => object,
Err(_) => return keystore_temporarily_unavailable(env),
Err(error) => {
log_keystore_failure(method, "jni_object", &error.to_string());
return keystore_temporarily_unavailable(env);
}
};
if object.is_null() {
log_keystore_failure(method, "null_response", "");
return Err(KEYSTORE_TEMPORARILY_UNAVAILABLE.to_string());
}
let array = JByteArray::from(object);
let response = match env.convert_byte_array(&array) {
Ok(response) => response,
Err(_) => return keystore_temporarily_unavailable(env),
Err(error) => {
log_keystore_failure(method, "jni_bytes", &error.to_string());
return keystore_temporarily_unavailable(env);
}
};
credential_response(response)
credential_response(method, response)
})
}
@@ -280,25 +341,38 @@ pub mod android {
with_android_env(|env, context| {
let class = match load_context_class(env, context, CREDENTIAL_VAULT_CLASS) {
Ok(class) => class,
Err(_) => return keystore_temporarily_unavailable(env),
Err(error) => {
log_keystore_failure(method, "class_load", &error);
return keystore_temporarily_unavailable(env);
}
};
let value = match env.call_static_method(class, method, "()[B", &[]) {
Ok(value) => value,
Err(_) => return keystore_temporarily_unavailable(env),
Err(error) => {
log_keystore_failure(method, "jni_call", &error.to_string());
return keystore_temporarily_unavailable(env);
}
};
let object = match value.l() {
Ok(object) => object,
Err(_) => return keystore_temporarily_unavailable(env),
Err(error) => {
log_keystore_failure(method, "jni_object", &error.to_string());
return keystore_temporarily_unavailable(env);
}
};
if object.is_null() {
log_keystore_failure(method, "null_response", "");
return Err(KEYSTORE_TEMPORARILY_UNAVAILABLE.to_string());
}
let array = JByteArray::from(object);
let response = match env.convert_byte_array(&array) {
Ok(response) => response,
Err(_) => return keystore_temporarily_unavailable(env),
Err(error) => {
log_keystore_failure(method, "jni_bytes", &error.to_string());
return keystore_temporarily_unavailable(env);
}
};
credential_response(response)
credential_response(method, response)
})
}
@@ -315,14 +389,16 @@ pub mod android {
plaintext: &[u8],
aad: &[u8],
) -> Result<Vec<u8>, AndroidKeystoreFailure> {
call_credential_vault_two_arrays("seal", plaintext, aad).map_err(classify_keystore_failure)
call_credential_vault_two_arrays("seal", plaintext, aad)
.map_err(classify_keystore_failure)
}
pub(crate) fn keystore_open(
sealed: &[u8],
aad: &[u8],
) -> Result<Vec<u8>, AndroidKeystoreFailure> {
call_credential_vault_two_arrays("open", sealed, aad).map_err(classify_keystore_failure)
call_credential_vault_two_arrays("open", sealed, aad)
.map_err(classify_keystore_failure)
}
pub(crate) fn keystore_delete_key() -> Result<(), AndroidKeystoreFailure> {
@@ -4,13 +4,12 @@
mod android_impl {
use std::path::PathBuf;
use minisign_verify::{PublicKey, Signature};
use serde::Deserialize;
use tauri::{AppHandle, Emitter};
use crate::android::updater_logic::{
beta_manifest_urls, format_manifest_error, map_abi_to_arch, stable_manifest_urls,
version_is_newer, INSTALLER_NOT_OPENED_MSG, UPDATER_PUBKEY_B64,
verify_updater_signature, version_is_newer, INSTALLER_NOT_OPENED_MSG, UPDATER_PUBKEY_B64,
};
use crate::commands::{
fetch_latest_beta_release, parse_latest_beta_from_atom, AppUpdateMetadata,
@@ -125,14 +124,7 @@ mod android_impl {
}
fn verify_signature(apk_bytes: &[u8], signature_b64: &str) -> Result<(), String> {
let public_key = PublicKey::from_base64(UPDATER_PUBKEY_B64)
.map_err(|e| format!("parse updater pubkey: {e}"))?;
let signature = Signature::decode(signature_b64.trim())
.map_err(|e| format!("decode signature: {e}"))?;
public_key
.verify(apk_bytes, &signature, false)
.map_err(|e| format!("signature verify failed: {e}"))?;
Ok(())
verify_updater_signature(apk_bytes, signature_b64, UPDATER_PUBKEY_B64)
}
fn updates_cache_dir() -> Result<PathBuf, String> {
@@ -54,6 +54,52 @@ pub fn format_manifest_error(status: u16, url: &str) -> String {
}
}
/// Unwrap a Tauri updater blob into minisign text.
///
/// `plugins.updater.pubkey` and `.sig` files store the minisign file as
/// standard Base64. Desktop `tauri-plugin-updater` base64-decodes first, then
/// calls `PublicKey::decode` / `Signature::decode`. Passing the wrapped pubkey
/// to `PublicKey::from_base64` fails with "Invalid encoding in minisign data"
/// because that API expects the 42-byte key line, not the 114-byte file.
pub fn decode_tauri_minisign_text(encoded: &str) -> Result<String, String> {
let trimmed = encoded.trim();
if trimmed.is_empty() {
return Err("empty minisign blob".to_string());
}
if trimmed.starts_with("untrusted comment:") {
return Ok(trimmed.to_string());
}
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD
.decode(trimmed)
.map_err(|e| format!("decode minisign blob: {e}"))?;
String::from_utf8(bytes).map_err(|e| format!("minisign blob is not UTF-8: {e}"))
}
pub fn parse_updater_public_key(pubkey_b64: &str) -> Result<minisign_verify::PublicKey, String> {
let text = decode_tauri_minisign_text(pubkey_b64)?;
minisign_verify::PublicKey::decode(&text).map_err(|e| format!("parse updater pubkey: {e}"))
}
pub fn parse_updater_signature(signature: &str) -> Result<minisign_verify::Signature, String> {
let text = decode_tauri_minisign_text(signature)?;
minisign_verify::Signature::decode(&text).map_err(|e| format!("decode signature: {e}"))
}
/// Verify APK bytes against a Tauri updater signature (prehashed, same as desktop).
pub fn verify_updater_signature(
data: &[u8],
signature: &str,
pubkey_b64: &str,
) -> Result<(), String> {
let public_key = parse_updater_public_key(pubkey_b64)?;
let signature = parse_updater_signature(signature)?;
public_key
.verify(data, &signature, true)
.map_err(|e| format!("signature verify failed: {e}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -152,4 +198,48 @@ mod tests {
.expect("plugins.updater.pubkey in tauri.conf.json");
assert_eq!(conf_pubkey, UPDATER_PUBKEY_B64);
}
#[test]
fn updater_pubkey_decodes_to_minisign_file() {
let text = decode_tauri_minisign_text(UPDATER_PUBKEY_B64).expect("decode pubkey");
assert!(
text.starts_with("untrusted comment: minisign public key:"),
"decoded={text:?}"
);
let key_line = text.lines().nth(1).expect("minisign pubkey has a key line");
assert!(
key_line.starts_with("RW"),
"key line should be the minisign RW blob, got {key_line}"
);
}
#[test]
fn wrapped_updater_pubkey_is_not_a_raw_42_byte_key() {
let err = minisign_verify::PublicKey::from_base64(UPDATER_PUBKEY_B64)
.expect_err("Tauri-wrapped pubkey must not parse as a raw 42-byte key");
assert!(
err.to_string().contains("Invalid encoding"),
"unexpected error: {err}"
);
}
#[test]
fn updater_pubkey_parses_after_tauri_unwrap() {
parse_updater_public_key(UPDATER_PUBKEY_B64)
.expect("Tauri-wrapped pubkey must parse after unwrap");
}
#[test]
fn decode_tauri_minisign_text_passthrough_raw_file() {
let raw = "untrusted comment: minisign public key: ABC\nRWABC";
assert_eq!(decode_tauri_minisign_text(&format!("{raw}\n")).unwrap(), raw);
assert_eq!(decode_tauri_minisign_text(raw).unwrap(), raw);
}
#[test]
fn decode_tauri_minisign_text_rejects_garbage() {
assert!(decode_tauri_minisign_text("%%%not-base64%%%").is_err());
assert!(decode_tauri_minisign_text("").is_err());
assert!(decode_tauri_minisign_text(" ").is_err());
}
}
@@ -37,9 +37,7 @@ impl openless_core::credentials::CredentialMetadataStore for SystemCredentialMet
'static,
Result<openless_core::CredentialMetadata, openless_core::BackendError>,
> {
run_credential_task(|| {
CredentialsVault::load_metadata().map_err(credential_persistence_error)
})
run_credential_task(|| after_vault_attempt(CredentialsVault::load_metadata()))
}
fn save_metadata(
@@ -57,8 +55,7 @@ impl openless_core::credentials::CredentialMetadataStore for SystemCredentialMet
channel_id: String,
) -> futures_util::future::BoxFuture<'static, Result<bool, openless_core::BackendError>> {
run_credential_task(move || {
CredentialsVault::channel_has_secrets(kind, &channel_id)
.map_err(credential_persistence_error)
after_vault_attempt(CredentialsVault::channel_has_secrets(kind, &channel_id))
})
}
}
@@ -115,7 +112,9 @@ impl openless_core::CredentialStore for SystemCredentialStore {
Result<CredentialsStatus, openless_core::BackendError>,
> {
let model_store = self.model_store.clone();
run_credential_task(move || credentials_status(preferences, model_store.as_deref()))
run_credential_task(move || {
after_vault_backend(credentials_status(preferences, model_store.as_deref()))
})
}
fn read(
@@ -126,7 +125,9 @@ impl openless_core::CredentialStore for SystemCredentialStore {
Result<Option<openless_core::SecretValue>, openless_core::BackendError>,
> {
run_credential_task(move || {
read_vault_credential(&key).map(|value| value.map(openless_core::SecretValue::new))
after_vault_backend(
read_vault_credential(&key).map(|value| value.map(openless_core::SecretValue::new)),
)
})
}
@@ -421,10 +422,37 @@ fn invalid_credential_key(key: &openless_core::CredentialKey) -> openless_core::
fn credential_persistence_error(error: anyhow::Error) -> openless_core::BackendError {
openless_core::BackendError::new(
openless_core::BackendErrorCode::Persistence,
format!("credential vault operation failed: {error}"),
format!("credential vault operation failed: {error:#}"),
)
}
fn require_readable_vault() -> Result<(), openless_core::BackendError> {
match CredentialsVault::last_read_error() {
Some(error) => Err(openless_core::BackendError::new(
openless_core::BackendErrorCode::Persistence,
format!("无法读取已保存的凭据:{error}"),
)),
None => Ok(()),
}
}
/// Call after a real vault load/retry. Surfaces the recorded Keystore chain
/// instead of a generic English anyhow mapping, and never short-circuits first.
fn after_vault_attempt<T>(
result: Result<T, anyhow::Error>,
) -> Result<T, openless_core::BackendError> {
after_vault_backend(result.map_err(credential_persistence_error))
}
fn after_vault_backend<T>(
result: Result<T, openless_core::BackendError>,
) -> Result<T, openless_core::BackendError> {
if CredentialsVault::last_read_error().is_some() {
require_readable_vault()?;
}
result
}
#[tauri::command]
pub async fn get_credentials(core: CoreState<'_>) -> Result<CredentialsStatus, String> {
core.get_credentials_status()
@@ -99,18 +99,65 @@ fn android_marketplace_legacy_scrubbed() -> &'static Mutex<bool> {
/// Failed reads remain retryable and must not become a cached empty configuration.
/// External Keychain edits take effect on the next app launch.
static CREDENTIALS_CACHE: OnceLock<Mutex<Option<CredsRoot>>> = OnceLock::new();
static LAST_VAULT_READ_ERROR: OnceLock<Mutex<Option<String>>> = OnceLock::new();
static LAST_VAULT_READ_ERROR_LOGGED: OnceLock<Mutex<Option<String>>> = OnceLock::new();
fn credentials_cache() -> &'static Mutex<Option<CredsRoot>> {
CREDENTIALS_CACHE.get_or_init(|| Mutex::new(None))
}
fn last_vault_read_error_slot() -> &'static Mutex<Option<String>> {
LAST_VAULT_READ_ERROR.get_or_init(|| Mutex::new(None))
}
fn last_vault_read_error_logged_slot() -> &'static Mutex<Option<String>> {
LAST_VAULT_READ_ERROR_LOGGED.get_or_init(|| Mutex::new(None))
}
fn store_credentials_cache(root: &CredsRoot) {
*credentials_cache().lock() = Some(root.clone());
clear_vault_read_error();
}
fn record_vault_read_failure(error: &anyhow::Error) {
let chain = format!("{error:#}");
*last_vault_read_error_slot().lock() = Some(chain.clone());
let mut logged = last_vault_read_error_logged_slot().lock();
if logged.as_deref() != Some(chain.as_str()) {
log::warn!("[vault] credential read failed: {chain}");
*logged = Some(chain);
}
}
/// Mutations must not persist an empty default over an unreadable envelope.
/// Returning `Err` lets Core surface Persistence after a real Keystore retry.
#[cfg(any(target_os = "android", test))]
fn android_credentials_root_for_update(
loader: impl FnOnce() -> Result<Option<CredsRoot>>,
) -> Result<CredsRoot> {
match loader() {
Ok(loaded) => {
let root = loaded.unwrap_or_default();
clear_vault_read_error();
store_credentials_cache(&root);
Ok(root)
}
Err(error) => {
record_vault_read_failure(&error);
Err(error)
}
}
}
fn clear_vault_read_error() {
*last_vault_read_error_slot().lock() = None;
*last_vault_read_error_logged_slot().lock() = None;
}
#[cfg(test)]
fn reset_credentials_cache_for_tests() {
*credentials_cache().lock() = None;
clear_vault_read_error();
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
@@ -1465,6 +1512,7 @@ fn load_credentials_into_cache_with(
match loader() {
Ok(root) => {
let root = root.unwrap_or_default();
clear_vault_read_error();
store_credentials_cache(&root);
root
}
@@ -1472,7 +1520,7 @@ fn load_credentials_into_cache_with(
// Do not cache the fallback. In particular, a failed legacy-token
// scrub must be retried by the next startup/getter call rather than
// hidden for the rest of the process.
log::warn!("[vault] credential read failed: {e}");
record_vault_read_failure(&e);
CredsRoot::default()
}
}
@@ -1523,12 +1571,7 @@ fn load_credentials_for_update_raw() -> Result<CredsRoot> {
#[cfg(target_os = "android")]
{
let root = match load_android_credentials()? {
Some(root) => root,
None => CredsRoot::default(),
};
store_credentials_cache(&root);
return Ok(root);
return android_credentials_root_for_update(load_android_credentials);
}
#[cfg(not(target_os = "android"))]
@@ -1537,6 +1580,7 @@ fn load_credentials_for_update_raw() -> Result<CredsRoot> {
// 同 load_credentials:不再每次 update 都尝试 delete legacy keyring
// entries,避免反复触发 macOS Keychain ACL 弹窗。
remove_legacy_credentials_file_best_effort();
clear_vault_read_error();
store_credentials_cache(&root);
Ok(root)
}
@@ -1545,11 +1589,15 @@ fn load_credentials_for_update_raw() -> Result<CredsRoot> {
// save_credentials,cache 会被刷新;如果只返回 default root(没 legacy),
// 我们这里再显式 cache 一次防御性补一下。
let root = migrate_legacy_sources_for_update()?;
clear_vault_read_error();
store_credentials_cache(&root);
Ok(root)
}
// 错误路径不缓存 —— 同 load_credentials 注释;让下次读重试 keyring。
Err(e) => Err(e),
Err(e) => {
record_vault_read_failure(&e);
Err(e)
}
}
}
@@ -2121,6 +2169,13 @@ impl CredentialsVault {
/// 系统凭据库 service name;macOS 下对应 Keychain service。
pub const SERVICE_NAME: &'static str = "com.openless.app";
/// Last envelope/keyring read failure, if this process has not successfully
/// loaded credentials since. Distinguishes "vault unreadable" from
/// "user has not configured a provider" (empty default is volcengine).
pub fn last_read_error() -> Option<String> {
last_vault_read_error_slot().lock().clone()
}
pub fn load_metadata() -> Result<openless_core::CredentialMetadata> {
let _guard = credentials_lock().lock();
Ok(credential_metadata(&load_credentials_for_update()?))
@@ -2589,15 +2644,15 @@ mod tests {
#[cfg(not(windows))]
use super::load_android_credentials_from_source_with_crypto;
use super::{
android_persistable_credentials, chunk_json_payload, credentials_cache,
get_android_marketplace_token_at, load_android_credentials_from_path,
android_credentials_root_for_update, android_persistable_credentials, chunk_json_payload,
credentials_cache, get_android_marketplace_token_at, load_android_credentials_from_path,
load_android_credentials_from_path_with_crypto, load_credentials_into_cache_with,
lookup_account, lookup_marketplace_github_token, lookup_omni_account,
omni_extra_headers_json, omni_temperature_string, parse_extra_headers_json,
parse_llm_temperature, reset_credentials_cache_for_tests,
set_llm_extra_headers_for_provider_in_root, set_llm_temperature_for_provider_in_root,
write_account, write_marketplace_github_token, write_omni_account, CredentialAccount,
CredsAsrEntry, CredsLlmEntry, CredsRoot, MarketplaceGithubToken,
CredentialsVault, CredsAsrEntry, CredsLlmEntry, CredsRoot, MarketplaceGithubToken,
KEYRING_CHUNK_MAX_UTF16_UNITS,
};
use anyhow::anyhow;
@@ -3300,11 +3355,24 @@ mod tests {
#[test]
fn android_startup_failure_does_not_cache_default_or_suppress_retry() {
use anyhow::Context;
reset_credentials_cache_for_tests();
let first =
load_credentials_into_cache_with(|| Err(anyhow!("injected startup scrub failure")));
let first = load_credentials_into_cache_with(|| {
Err(anyhow!("injected startup scrub failure")
.context("read Android credential envelope"))
});
assert!(lookup_marketplace_github_token(&first).is_none());
assert!(credentials_cache().lock().is_none());
let first_error =
CredentialsVault::last_read_error().expect("vault error should be recorded");
assert!(
first_error.contains("injected startup scrub failure"),
"error chain should include the inner cause, got {first_error}"
);
assert!(
first_error.contains("read Android credential envelope"),
"error chain should include the outer context, got {first_error}"
);
let dir =
std::env::temp_dir().join(format!("openless-android-startup-{}", uuid::Uuid::new_v4()));
@@ -3314,11 +3382,38 @@ mod tests {
assert!(lookup_marketplace_github_token(&second).is_none());
assert!(credentials_cache().lock().is_some());
assert!(
CredentialsVault::last_read_error().is_none(),
"successful read must clear the last vault error"
);
assert_android_secret_unrecoverable(&path, "gho_legacy_startup_secret");
*credentials_cache().lock() = Some(CredsRoot::default());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn android_for_update_path_retries_and_does_not_cache_on_envelope_error() {
use anyhow::Context;
reset_credentials_cache_for_tests();
let err = android_credentials_root_for_update(|| {
Err(anyhow!("temporarily unavailable")
.context("Android credential authentication or key operation failed")
.context("read Android credential envelope"))
})
.expect_err("mutations must not receive a default root to persist");
assert!(credentials_cache().lock().is_none());
let error = CredentialsVault::last_read_error().expect("vault error should be recorded");
assert!(
error.contains("temporarily unavailable"),
"error chain should include the Keystore kind, got {error}"
);
let chain = format!("{err:#}");
assert!(
chain.contains("temporarily unavailable"),
"returned error should include the Keystore kind, got {chain}"
);
}
#[test]
fn parse_llm_temperature_accepts_empty_and_valid_range() {
assert_eq!(parse_llm_temperature("").unwrap(), None);
@@ -141,6 +141,12 @@ function modelAccountFor(kind: ChannelKind): string {
return kind === 'llm' ? 'ark.model_id' : 'asr.model';
}
function failedOpMessage(error: unknown, fallback: string): string {
const detail = error instanceof Error ? error.message : String(error);
const trimmed = detail.trim();
return trimmed || fallback;
}
/**
* 把后端的错误串压成按钮上放得下的短标签,且要**能指导行动**:
* 401 是 key 不对、429 是被限流等会儿再说、超时是网络——用户看到才知道该改什么。
@@ -290,7 +296,8 @@ export function ChannelList({
await refresh();
} catch (error) {
console.error('[channels] create failed', error);
emitSaved('failed', t('common.operationFailed'));
const message = failedOpMessage(error, t('common.operationFailed'));
emitSaved('failed', message);
} finally {
setCreatingBusy(false);
}