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:
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)
|
||||
}
|
||||
}
|
||||
+44
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user