diff --git a/openless-all/app/android/kotlin/OpenLessCredentialVault.kt b/openless-all/app/android/kotlin/OpenLessCredentialVault.kt index e8e8dd1b9..40eb4e075 100644 --- a/openless-all/app/android/kotlin/OpenLessCredentialVault.kt +++ b/openless-all/app/android/kotlin/OpenLessCredentialVault.kt @@ -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(1) + val error = arrayOfNulls(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) + } } diff --git a/openless-all/app/android/kotlin/androidTest/OpenLessCredentialVaultInstrumentedTest.kt b/openless-all/app/android/kotlin/androidTest/OpenLessCredentialVaultInstrumentedTest.kt index 9f20a28ff..1c911895a 100644 --- a/openless-all/app/android/kotlin/androidTest/OpenLessCredentialVaultInstrumentedTest.kt +++ b/openless-all/app/android/kotlin/androidTest/OpenLessCredentialVaultInstrumentedTest.kt @@ -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() diff --git a/openless-all/app/android/kotlin/test/OpenLessCredentialCipherTest.kt b/openless-all/app/android/kotlin/test/OpenLessCredentialCipherTest.kt index a3d0a9fdd..590b7f273 100644 --- a/openless-all/app/android/kotlin/test/OpenLessCredentialCipherTest.kt +++ b/openless-all/app/android/kotlin/test/OpenLessCredentialCipherTest.kt @@ -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(), + ) + } } diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index e7433ef62..499436b6a 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -3037,11 +3037,23 @@ impl OpenLessBackend { } pub async fn start(&self) -> Result { - 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> { + Box::pin(async { + Err(BackendError::new( + BackendErrorCode::Persistence, + "无法读取已保存的凭据:temporarily unavailable", + )) + }) + } + + fn read( + &self, + _key: crate::credentials::CredentialKey, + ) -> BoxFuture<'static, Result, 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"); diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 2aff6bae9..d2e798788 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -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( diff --git a/openless-all/app/scripts/android-credential-keystore-contract.test.mjs b/openless-all/app/scripts/android-credential-keystore-contract.test.mjs index a2554e9e3..23529ef3e 100644 --- a/openless-all/app/scripts/android-credential-keystore-contract.test.mjs +++ b/openless-all/app/scripts/android-credential-keystore-contract.test.mjs @@ -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/, diff --git a/openless-all/app/scripts/check-linux-public-surface.ps1 b/openless-all/app/scripts/check-linux-public-surface.ps1 index 74be5ed03..3aee13013 100644 --- a/openless-all/app/scripts/check-linux-public-surface.ps1 +++ b/openless-all/app/scripts/check-linux-public-surface.ps1 @@ -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" diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index e886e37a2..13fc610a6 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -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" diff --git a/openless-all/app/src-tauri/src/android/jni.rs b/openless-all/app/src-tauri/src/android/jni.rs index 137d6eee8..ef0f687bc 100644 --- a/openless-all/app/src-tauri/src/android/jni.rs +++ b/openless-all/app/src-tauri/src/android/jni.rs @@ -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(env: &mut JNIEnv) -> Result { clear_pending_exception(env); Err(KEYSTORE_TEMPORARILY_UNAVAILABLE.to_string()) } - fn credential_response(response: Vec) -> Result, String> { + fn credential_response(method: &str, response: Vec) -> Result, 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, 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, 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> { diff --git a/openless-all/app/src-tauri/src/android/updater.rs b/openless-all/app/src-tauri/src/android/updater.rs index 24d6f7a8e..94a43bb9d 100644 --- a/openless-all/app/src-tauri/src/android/updater.rs +++ b/openless-all/app/src-tauri/src/android/updater.rs @@ -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 { diff --git a/openless-all/app/src-tauri/src/android/updater_logic.rs b/openless-all/app/src-tauri/src/android/updater_logic.rs index 072d7b675..bb90e64c8 100644 --- a/openless-all/app/src-tauri/src/android/updater_logic.rs +++ b/openless-all/app/src-tauri/src/android/updater_logic.rs @@ -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 { + 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 { + 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 { + 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()); + } } diff --git a/openless-all/app/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index 423aeb8a0..bd9ce5194 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -37,9 +37,7 @@ impl openless_core::credentials::CredentialMetadataStore for SystemCredentialMet 'static, Result, > { - 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> { 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, > { 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, 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( + result: Result, +) -> Result { + after_vault_backend(result.map_err(credential_persistence_error)) +} + +fn after_vault_backend( + result: Result, +) -> Result { + if CredentialsVault::last_read_error().is_some() { + require_readable_vault()?; + } + result +} + #[tauri::command] pub async fn get_credentials(core: CoreState<'_>) -> Result { core.get_credentials_status() diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index 3b1c019b1..f6a26fd9a 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -99,18 +99,65 @@ fn android_marketplace_legacy_scrubbed() -> &'static Mutex { /// 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>> = OnceLock::new(); +static LAST_VAULT_READ_ERROR: OnceLock>> = OnceLock::new(); +static LAST_VAULT_READ_ERROR_LOGGED: OnceLock>> = OnceLock::new(); fn credentials_cache() -> &'static Mutex> { CREDENTIALS_CACHE.get_or_init(|| Mutex::new(None)) } +fn last_vault_read_error_slot() -> &'static Mutex> { + LAST_VAULT_READ_ERROR.get_or_init(|| Mutex::new(None)) +} + +fn last_vault_read_error_logged_slot() -> &'static Mutex> { + 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>, +) -> Result { + 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 { #[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 { // 同 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 { // 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 { + last_vault_read_error_slot().lock().clone() + } + pub fn load_metadata() -> Result { 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); diff --git a/openless-all/app/src/pages/settings/ChannelList.tsx b/openless-all/app/src/pages/settings/ChannelList.tsx index 5807e0b23..ed3f6c7ca 100644 --- a/openless-all/app/src/pages/settings/ChannelList.tsx +++ b/openless-all/app/src/pages/settings/ChannelList.tsx @@ -140,6 +140,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 是被限流等会儿再说、超时是网络——用户看到才知道该改什么。 @@ -264,7 +270,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); }