fix(android): protect credentials with Android Keystore (#838)
* fix(android): protect credentials with Keystore * fix(android): harden credential migration * fix(android): recover sanitized credential migration * fix(ci): target Android x86_64 test variant * fix(ci): prepare Tauri Android test project * fix(ci): set Tauri Android build metadata * fix(ci): isolate Android Keystore test build * fix(ci): generate Tauri Android Kotlin bridge * fix(android): harden credential persistence * fix(android): preserve credential migration roots * fix(android): recover verified credential writes * fix(android): clear unrecoverable credential recovery * fix(ci): accept CRLF in Android credential contract --------- Co-authored-by: Codex <codex@openai.com> Co-authored-by: baiqing <lbx12309@icloud.com> Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
11 files changed
+2544
-263
No files matched your search
@@ -34,7 +34,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sdkmanager "ndk;26.1.10909125"
|
||||
sdkmanager "ndk;26.1.10909125" "platforms;android-36" "build-tools;36.0.0"
|
||||
ndk_dir="$ANDROID_HOME/ndk/26.1.10909125"
|
||||
echo "ANDROID_NDK_HOME=$ndk_dir" >> "$GITHUB_ENV"
|
||||
echo "NDK_HOME=$ndk_dir" >> "$GITHUB_ENV"
|
||||
@@ -51,6 +51,10 @@ jobs:
|
||||
echo "CXX_aarch64_linux_android=$prebuilt/bin/aarch64-linux-android24-clang++" >> "$GITHUB_ENV"
|
||||
echo "AR_aarch64_linux_android=$prebuilt/bin/llvm-ar" >> "$GITHUB_ENV"
|
||||
echo "CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=$prebuilt/bin/aarch64-linux-android24-clang" >> "$GITHUB_ENV"
|
||||
echo "CC_x86_64_linux_android=$prebuilt/bin/x86_64-linux-android24-clang" >> "$GITHUB_ENV"
|
||||
echo "CXX_x86_64_linux_android=$prebuilt/bin/x86_64-linux-android24-clang++" >> "$GITHUB_ENV"
|
||||
echo "AR_x86_64_linux_android=$prebuilt/bin/llvm-ar" >> "$GITHUB_ENV"
|
||||
echo "CARGO_TARGET_X86_64_LINUX_ANDROID_LINKER=$prebuilt/bin/x86_64-linux-android24-clang" >> "$GITHUB_ENV"
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -58,9 +62,16 @@ jobs:
|
||||
cache: npm
|
||||
cache-dependency-path: openless-all/app/package-lock.json
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: zulu
|
||||
java-version: "17"
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-linux-android
|
||||
targets: aarch64-linux-android,x86_64-linux-android
|
||||
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
- name: Install Linux check dependencies
|
||||
run: |
|
||||
@@ -80,6 +91,56 @@ jobs:
|
||||
- name: Check Tauri backend (Android target)
|
||||
run: cargo check --manifest-path src-tauri/Cargo.toml --target aarch64-linux-android
|
||||
|
||||
- name: Initialize generated Android project
|
||||
run: npm run tauri -- android init --ci
|
||||
|
||||
- name: Generate Tauri Gradle dependency scripts
|
||||
env:
|
||||
TAURI_ANDROID_PROJECT_PATH: ${{ github.workspace }}/openless-all/app/src-tauri/gen/android
|
||||
TAURI_ANDROID_PACKAGE_UNESCAPED: com.openless.app
|
||||
WRY_ANDROID_LIBRARY: openless_lib
|
||||
WRY_ANDROID_KOTLIN_FILES_OUT_DIR: ${{ github.workspace }}/openless-all/app/src-tauri/gen/android/app/src/main/java/com/openless/app/generated
|
||||
WRY_ANDROID_PACKAGE: com.openless.app
|
||||
run: |
|
||||
mkdir -p "$WRY_ANDROID_KOTLIN_FILES_OUT_DIR"
|
||||
cargo check --manifest-path src-tauri/Cargo.toml --target x86_64-linux-android
|
||||
|
||||
- name: Copy Android production and test scaffolding
|
||||
run: node scripts/copy-android-scaffolding.mjs
|
||||
|
||||
- name: Run JVM credential tests and compile instrumentation tests
|
||||
# These tests are Kotlin-only. The Rust target is checked above; direct Gradle
|
||||
# rustBuild requires the live Tauri CLI RPC server used by `tauri android build`.
|
||||
run: >-
|
||||
src-tauri/gen/android/gradlew
|
||||
--project-dir src-tauri/gen/android
|
||||
:app:testX86_64DebugUnitTest
|
||||
:app:assembleX86_64DebugAndroidTest
|
||||
-x :app:rustBuildX86_64Debug
|
||||
--no-daemon
|
||||
|
||||
- name: Enable KVM for Android instrumentation tests
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
|
||||
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Run Android Keystore instrumentation tests
|
||||
uses: reactivecircus/android-emulator-runner@v2
|
||||
with:
|
||||
api-level: 35
|
||||
target: google_apis
|
||||
arch: x86_64
|
||||
# The instrumentation suite exercises AndroidKeyStore without launching Tauri.
|
||||
script: >-
|
||||
cd openless-all/app &&
|
||||
src-tauri/gen/android/gradlew
|
||||
--project-dir src-tauri/gen/android
|
||||
:app:connectedX86_64DebugAndroidTest
|
||||
-x :app:rustBuildX86_64Debug
|
||||
--no-daemon
|
||||
|
||||
cross-platform:
|
||||
name: ${{ matrix.label }} checks
|
||||
strategy:
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.openless.app
|
||||
|
||||
import java.security.GeneralSecurityException
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
/** Pure JCA AES-GCM packet codec; AndroidKeyStore ownership lives in the facade. */
|
||||
internal object OpenLessCredentialCipher {
|
||||
internal const val NONCE_BYTES = 12
|
||||
internal const val TAG_BITS = 128
|
||||
private const val TAG_BYTES = TAG_BITS / 8
|
||||
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||
|
||||
@Throws(GeneralSecurityException::class)
|
||||
fun seal(key: SecretKey, plaintext: ByteArray, aad: ByteArray): ByteArray {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key)
|
||||
val nonce = cipher.iv
|
||||
require(nonce.size == NONCE_BYTES) { "unexpected AES-GCM nonce length" }
|
||||
cipher.updateAAD(aad)
|
||||
val ciphertext = cipher.doFinal(plaintext)
|
||||
return byteArrayOf(nonce.size.toByte()) + nonce + ciphertext
|
||||
}
|
||||
|
||||
@Throws(GeneralSecurityException::class)
|
||||
fun open(key: SecretKey, packet: ByteArray, aad: ByteArray): ByteArray {
|
||||
if (packet.isEmpty()) {
|
||||
throw IllegalArgumentException("malformed credential packet")
|
||||
}
|
||||
val nonceLength = packet[0].toInt() and 0xff
|
||||
if (
|
||||
nonceLength != NONCE_BYTES ||
|
||||
packet.size < 1 + nonceLength + TAG_BYTES
|
||||
) {
|
||||
throw IllegalArgumentException("malformed credential packet")
|
||||
}
|
||||
val nonce = packet.copyOfRange(1, 1 + nonceLength)
|
||||
val ciphertext = packet.copyOfRange(1 + nonceLength, packet.size)
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, nonce))
|
||||
cipher.updateAAD(aad)
|
||||
return cipher.doFinal(ciphertext)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package com.openless.app
|
||||
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||
import android.security.keystore.KeyProperties
|
||||
import androidx.annotation.Keep
|
||||
import java.io.IOException
|
||||
import java.security.GeneralSecurityException
|
||||
import java.security.KeyStore
|
||||
import java.security.KeyStoreException
|
||||
import java.security.UnrecoverableKeyException
|
||||
import javax.crypto.AEADBadTagException
|
||||
import javax.crypto.BadPaddingException
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
|
||||
internal const val CREDENTIAL_STATUS_OK: Byte = 0
|
||||
internal const val CREDENTIAL_STATUS_KEY_MISSING: Byte = 1
|
||||
internal const val CREDENTIAL_STATUS_AUTHENTICATION_FAILED: Byte = 2
|
||||
internal const val CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE: Byte = 3
|
||||
internal const val CREDENTIAL_STATUS_MALFORMED: Byte = 4
|
||||
|
||||
private fun credentialResponse(status: Byte, payload: ByteArray = byteArrayOf()): ByteArray {
|
||||
return byteArrayOf(status) + payload
|
||||
}
|
||||
|
||||
internal fun credentialStatusForKeyLoadFailure(error: GeneralSecurityException): Byte {
|
||||
return when (error) {
|
||||
is KeyPermanentlyInvalidatedException -> CREDENTIAL_STATUS_KEY_MISSING
|
||||
else -> CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE
|
||||
}
|
||||
}
|
||||
|
||||
/** AndroidKeyStore owner with fixed, secret-free status responses for JNI. */
|
||||
internal class AndroidKeystoreCredentialVault(private val alias: String) {
|
||||
@Synchronized
|
||||
fun seal(plaintext: ByteArray, aad: ByteArray): ByteArray {
|
||||
return try {
|
||||
credentialResponse(
|
||||
CREDENTIAL_STATUS_OK,
|
||||
OpenLessCredentialCipher.seal(getOrCreateKey(), plaintext, aad),
|
||||
)
|
||||
} catch (error: KeyPermanentlyInvalidatedException) {
|
||||
credentialResponse(credentialStatusForKeyLoadFailure(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))
|
||||
} catch (_: IllegalArgumentException) {
|
||||
credentialResponse(CREDENTIAL_STATUS_MALFORMED)
|
||||
} catch (_: GeneralSecurityException) {
|
||||
credentialResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE)
|
||||
} catch (_: IOException) {
|
||||
credentialResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun open(packet: ByteArray, aad: ByteArray): ByteArray {
|
||||
return try {
|
||||
val key = existingKey() ?: return credentialResponse(CREDENTIAL_STATUS_KEY_MISSING)
|
||||
credentialResponse(CREDENTIAL_STATUS_OK, OpenLessCredentialCipher.open(key, packet, aad))
|
||||
} catch (error: KeyPermanentlyInvalidatedException) {
|
||||
credentialResponse(credentialStatusForKeyLoadFailure(error))
|
||||
} catch (error: UnrecoverableKeyException) {
|
||||
credentialResponse(credentialStatusForKeyLoadFailure(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)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun deleteKey(): ByteArray {
|
||||
return try {
|
||||
val keyStore = loadKeyStore()
|
||||
if (keyStore.containsAlias(alias)) {
|
||||
keyStore.deleteEntry(alias)
|
||||
}
|
||||
credentialResponse(CREDENTIAL_STATUS_OK)
|
||||
} catch (_: GeneralSecurityException) {
|
||||
credentialResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE)
|
||||
} catch (_: IOException) {
|
||||
credentialResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun keyExists(): ByteArray {
|
||||
return try {
|
||||
credentialResponse(
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun ensureKey(): ByteArray {
|
||||
return try {
|
||||
getOrCreateKey()
|
||||
credentialResponse(CREDENTIAL_STATUS_OK)
|
||||
} catch (error: KeyPermanentlyInvalidatedException) {
|
||||
credentialResponse(credentialStatusForKeyLoadFailure(error))
|
||||
} catch (error: UnrecoverableKeyException) {
|
||||
credentialResponse(credentialStatusForKeyLoadFailure(error))
|
||||
} catch (_: GeneralSecurityException) {
|
||||
credentialResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE)
|
||||
} catch (_: IOException) {
|
||||
credentialResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(GeneralSecurityException::class, IOException::class)
|
||||
private fun existingKey(): SecretKey? {
|
||||
val keyStore = loadKeyStore()
|
||||
if (!keyStore.containsAlias(alias)) {
|
||||
return null
|
||||
}
|
||||
return keyStore.getKey(alias, null) as? SecretKey
|
||||
}
|
||||
|
||||
@Throws(GeneralSecurityException::class, IOException::class)
|
||||
private fun getOrCreateKey(): SecretKey {
|
||||
existingKey()?.let { return it }
|
||||
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER)
|
||||
generator.init(
|
||||
KeyGenParameterSpec.Builder(
|
||||
alias,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
||||
)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(256)
|
||||
.setRandomizedEncryptionRequired(true)
|
||||
.build(),
|
||||
)
|
||||
return generator.generateKey()
|
||||
}
|
||||
|
||||
@Throws(KeyStoreException::class, IOException::class, GeneralSecurityException::class)
|
||||
private fun loadKeyStore(): KeyStore {
|
||||
return KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEYSTORE_PROVIDER = "AndroidKeyStore"
|
||||
}
|
||||
}
|
||||
|
||||
@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"
|
||||
private val backend = AndroidKeystoreCredentialVault(KEY_ALIAS)
|
||||
private val migrationMarker = AndroidKeystoreCredentialVault(MIGRATION_MARKER_ALIAS)
|
||||
|
||||
@JvmStatic
|
||||
fun seal(plaintext: ByteArray, aad: ByteArray): ByteArray = backend.seal(plaintext, aad)
|
||||
|
||||
@JvmStatic
|
||||
fun open(packet: ByteArray, aad: ByteArray): ByteArray = backend.open(packet, aad)
|
||||
|
||||
@JvmStatic
|
||||
fun deleteKey(): ByteArray = backend.deleteKey()
|
||||
|
||||
@JvmStatic
|
||||
fun migrationComplete(): ByteArray = migrationMarker.keyExists()
|
||||
|
||||
@JvmStatic
|
||||
fun markMigrationComplete(): ByteArray = migrationMarker.ensureKey()
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package com.openless.app
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import java.security.KeyStore
|
||||
import java.util.UUID
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class OpenLessCredentialVaultInstrumentedTest {
|
||||
private lateinit var alias: String
|
||||
private lateinit var vault: AndroidKeystoreCredentialVault
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
alias = "com.openless.app.credentials.test.${UUID.randomUUID()}"
|
||||
vault = AndroidKeystoreCredentialVault(alias)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
vault.deleteKey()
|
||||
}
|
||||
|
||||
private fun payload(response: ByteArray): ByteArray {
|
||||
assertEquals(CREDENTIAL_STATUS_OK, response.first())
|
||||
return response.copyOfRange(1, response.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun roundTripUsesNonExportableKey() {
|
||||
val plaintext = "instrumented credential".toByteArray()
|
||||
val aad = "format-version-account".toByteArray()
|
||||
val packet = payload(vault.seal(plaintext, aad))
|
||||
|
||||
assertArrayEquals(plaintext, payload(vault.open(packet, aad)))
|
||||
val key = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }.getKey(alias, null)
|
||||
assertNull(key.encoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keyMarkerStateTracksKeyCreationAndDeletion() {
|
||||
assertArrayEquals(byteArrayOf(0), payload(vault.keyExists()))
|
||||
assertEquals(CREDENTIAL_STATUS_OK, vault.ensureKey().first())
|
||||
assertArrayEquals(byteArrayOf(1), payload(vault.keyExists()))
|
||||
assertEquals(CREDENTIAL_STATUS_OK, vault.deleteKey().first())
|
||||
assertArrayEquals(byteArrayOf(0), payload(vault.keyExists()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publicFacadeRoundTripExercisesJvmStaticEntryPoints() {
|
||||
OpenLessCredentialVault.deleteKey()
|
||||
try {
|
||||
val plaintext = "facade credential".toByteArray()
|
||||
val aad = "facade aad".toByteArray()
|
||||
val packet = payload(OpenLessCredentialVault.seal(plaintext, aad))
|
||||
|
||||
assertArrayEquals(plaintext, payload(OpenLessCredentialVault.open(packet, aad)))
|
||||
} finally {
|
||||
OpenLessCredentialVault.deleteKey()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletedKeyIsReportedAsMissing() {
|
||||
val aad = "format-version-account".toByteArray()
|
||||
val packet = payload(vault.seal("secret".toByteArray(), aad))
|
||||
assertEquals(CREDENTIAL_STATUS_OK, vault.deleteKey().first())
|
||||
|
||||
assertEquals(CREDENTIAL_STATUS_KEY_MISSING, vault.open(packet, aad).first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tamperedCiphertextIsRejected() {
|
||||
val aad = "format-version-account".toByteArray()
|
||||
val packet = payload(vault.seal("secret".toByteArray(), aad))
|
||||
packet[packet.lastIndex] = (packet.last().toInt() xor 1).toByte()
|
||||
|
||||
assertEquals(
|
||||
CREDENTIAL_STATUS_AUTHENTICATION_FAILED,
|
||||
vault.open(packet, aad).first(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.openless.app
|
||||
|
||||
import java.lang.reflect.Modifier
|
||||
import java.security.GeneralSecurityException
|
||||
import java.security.UnrecoverableKeyException
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class OpenLessCredentialCipherTest {
|
||||
private fun key(): SecretKey {
|
||||
return KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun roundTrip() {
|
||||
val key = key()
|
||||
val plaintext = "credential-secret".toByteArray()
|
||||
val aad = "format-version-account".toByteArray()
|
||||
|
||||
val packet = OpenLessCredentialCipher.seal(key, plaintext, aad)
|
||||
|
||||
assertArrayEquals(plaintext, OpenLessCredentialCipher.open(key, packet, aad))
|
||||
assertFalse(packet.toString(Charsets.UTF_8).contains("credential-secret"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freshNonce() {
|
||||
val key = key()
|
||||
val plaintext = "same plaintext".toByteArray()
|
||||
val aad = "same aad".toByteArray()
|
||||
|
||||
val first = OpenLessCredentialCipher.seal(key, plaintext, aad)
|
||||
val second = OpenLessCredentialCipher.seal(key, plaintext, aad)
|
||||
|
||||
assertFalse(first.contentEquals(second))
|
||||
assertFalse(
|
||||
first.copyOfRange(1, 1 + OpenLessCredentialCipher.NONCE_BYTES).contentEquals(
|
||||
second.copyOfRange(1, 1 + OpenLessCredentialCipher.NONCE_BYTES),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tamperedCiphertext() {
|
||||
val key = key()
|
||||
val aad = "authenticated metadata".toByteArray()
|
||||
val packet = OpenLessCredentialCipher.seal(key, "secret".toByteArray(), aad)
|
||||
packet[packet.lastIndex] = (packet.last().toInt() xor 1).toByte()
|
||||
|
||||
assertThrows(GeneralSecurityException::class.java) {
|
||||
OpenLessCredentialCipher.open(key, packet, aad)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tamperedNonce() {
|
||||
val key = key()
|
||||
val aad = "authenticated metadata".toByteArray()
|
||||
val packet = OpenLessCredentialCipher.seal(key, "secret".toByteArray(), aad)
|
||||
packet[1] = (packet[1].toInt() xor 1).toByte()
|
||||
|
||||
assertThrows(GeneralSecurityException::class.java) {
|
||||
OpenLessCredentialCipher.open(key, packet, aad)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tamperedAad() {
|
||||
val key = key()
|
||||
val packet = OpenLessCredentialCipher.seal(
|
||||
key,
|
||||
"secret".toByteArray(),
|
||||
"account-a".toByteArray(),
|
||||
)
|
||||
|
||||
assertThrows(GeneralSecurityException::class.java) {
|
||||
OpenLessCredentialCipher.open(key, packet, "account-b".toByteArray())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun facadeMethodsExposeExactStaticJniSignatures() {
|
||||
val facade = OpenLessCredentialVault::class.java
|
||||
val signatures: List<Pair<String, Array<Class<*>>>> = listOf(
|
||||
"seal" to arrayOf<Class<*>>(ByteArray::class.java, ByteArray::class.java),
|
||||
"open" to arrayOf<Class<*>>(ByteArray::class.java, ByteArray::class.java),
|
||||
"deleteKey" to emptyArray(),
|
||||
"migrationComplete" to emptyArray(),
|
||||
"markMigrationComplete" to emptyArray(),
|
||||
)
|
||||
for ((name, parameters) in signatures) {
|
||||
val method = facade.getDeclaredMethod(name, *parameters)
|
||||
assertTrue("$name must be static for JNI", Modifier.isStatic(method.modifiers))
|
||||
assertEquals(ByteArray::class.java, method.returnType)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unrecoverableKeyExceptionRemainsRetryable() {
|
||||
assertEquals(
|
||||
CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE,
|
||||
credentialStatusForKeyLoadFailure(UnrecoverableKeyException("backend busy")),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { access, readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../..', import.meta.url));
|
||||
|
||||
const paths = {
|
||||
cipher: new URL('../android/kotlin/OpenLessCredentialCipher.kt', import.meta.url),
|
||||
vault: new URL('../android/kotlin/OpenLessCredentialVault.kt', import.meta.url),
|
||||
unitTest: new URL(
|
||||
'../android/kotlin/test/OpenLessCredentialCipherTest.kt',
|
||||
import.meta.url,
|
||||
),
|
||||
instrumentedTest: new URL(
|
||||
'../android/kotlin/androidTest/OpenLessCredentialVaultInstrumentedTest.kt',
|
||||
import.meta.url,
|
||||
),
|
||||
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),
|
||||
copyScript: new URL('./copy-android-scaffolding.mjs', import.meta.url),
|
||||
ci: new URL('../../../.github/workflows/ci.yml', import.meta.url),
|
||||
};
|
||||
|
||||
function display(url) {
|
||||
return fileURLToPath(url).replace(`${repoRoot}/`, '');
|
||||
}
|
||||
|
||||
async function requiredSource(name, url) {
|
||||
try {
|
||||
await access(url);
|
||||
} catch {
|
||||
throw new Error(`missing ${name}: ${display(url)}`);
|
||||
}
|
||||
return readFile(url, 'utf8');
|
||||
}
|
||||
|
||||
function requirePattern(source, pattern, message) {
|
||||
if (!pattern.test(source)) {
|
||||
throw new Error(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),
|
||||
]);
|
||||
|
||||
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');
|
||||
requirePattern(cipher, /TAG_BITS\s*=\s*128/, 'cipher must use a 128-bit GCM tag');
|
||||
requirePattern(cipher, /updateAAD/, 'cipher must authenticate caller-provided AAD');
|
||||
|
||||
for (const pattern of [
|
||||
/AndroidKeyStore/,
|
||||
/setBlockModes\([^)]*BLOCK_MODE_GCM/,
|
||||
/setEncryptionPaddings\([^)]*ENCRYPTION_PADDING_NONE/,
|
||||
/setKeySize\(256\)/,
|
||||
/setRandomizedEncryptionRequired\(true\)/,
|
||||
/fun\s+seal\s*\(/,
|
||||
/fun\s+open\s*\(/,
|
||||
/fun\s+deleteKey\s*\(/,
|
||||
/fun\s+migrationComplete\s*\(/,
|
||||
/fun\s+markMigrationComplete\s*\(/,
|
||||
]) {
|
||||
requirePattern(vault, pattern, `Keystore bridge is missing ${pattern}`);
|
||||
}
|
||||
if (
|
||||
/catch\s*\([^:]+:\s*UnrecoverableKeyException\)\s*\{\s*credentialResponse\(CREDENTIAL_STATUS_KEY_MISSING\)/.test(
|
||||
vault,
|
||||
)
|
||||
) {
|
||||
throw new Error('UnrecoverableKeyException must never trigger destructive key-missing cleanup');
|
||||
}
|
||||
for (const pattern of [
|
||||
/is\s+KeyPermanentlyInvalidatedException\s*->\s*CREDENTIAL_STATUS_KEY_MISSING/,
|
||||
/else\s*->\s*CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE/,
|
||||
]) {
|
||||
requirePattern(vault, pattern, `Keystore failure classifier is missing ${pattern}`);
|
||||
}
|
||||
if (/\b(?:Log\.|println\s*\()/.test(vault)) {
|
||||
throw new Error('Keystore bridge must not log secret-bearing inputs or crypto exceptions');
|
||||
}
|
||||
|
||||
for (const pattern of [
|
||||
/roundTrip/,
|
||||
/freshNonce/,
|
||||
/tamperedNonce/,
|
||||
/tamperedCiphertext/,
|
||||
/tamperedAad/,
|
||||
/unrecoverableKeyExceptionRemainsRetryable/,
|
||||
]) {
|
||||
requirePattern(unitTest, pattern, `JVM crypto tests are missing ${pattern}`);
|
||||
}
|
||||
for (const pattern of [/assertNull\([^)]*\.encoded/, /deletedKey/, /tamperedCiphertext/]) {
|
||||
requirePattern(
|
||||
instrumentedTest,
|
||||
pattern,
|
||||
`Android Keystore instrumentation tests are missing ${pattern}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const pattern of [
|
||||
/openless-android-credentials/,
|
||||
/version:\s*u32/,
|
||||
/account:\s*String/,
|
||||
/nonce:\s*String/,
|
||||
/ciphertext:\s*String/,
|
||||
/serde\(deny_unknown_fields\)/,
|
||||
/KeyMissingOrInvalidated/,
|
||||
/AuthenticationFailed/,
|
||||
/TemporarilyUnavailable/,
|
||||
/migration_complete/,
|
||||
/mark_migration_complete/,
|
||||
/recover_verified_sanitized_legacy/,
|
||||
/recover_verified_v2_temporary/,
|
||||
/mode\(0o600\)/,
|
||||
/sync_all\(\)/,
|
||||
]) {
|
||||
requirePattern(rustStore, pattern, `Rust v2 store is missing ${pattern}`);
|
||||
}
|
||||
if (/Stub:\s*base64 envelope/.test(credentials)) {
|
||||
throw new Error('legacy Base64 stub is still the Android credential writer');
|
||||
}
|
||||
|
||||
for (const pattern of [
|
||||
/keystore_seal/,
|
||||
/keystore_open/,
|
||||
/keystore_delete_key/,
|
||||
/keystore_migration_complete/,
|
||||
/keystore_mark_migration_complete/,
|
||||
/JByteArray/,
|
||||
]) {
|
||||
requirePattern(jni, pattern, `JNI bridge is missing ${pattern}`);
|
||||
}
|
||||
requirePattern(
|
||||
jni,
|
||||
/fn\s+app_files_dir\s*\([\s\S]*?getFilesDir[\s\S]*?getAbsolutePath/,
|
||||
'JNI bridge must resolve the app-private Context files directory',
|
||||
);
|
||||
requirePattern(
|
||||
jni,
|
||||
/fn\s+with_tao_android_env[\s\S]*?main_android_context/,
|
||||
'startup persistence must use Tao\'s non-panicking Android context registry',
|
||||
);
|
||||
const androidCredentialPath = credentials.match(
|
||||
/fn\s+android_credentials_path\s*\([^)]*\)\s*->\s*Result<PathBuf>[\s\S]*?\r?\n}\r?\n/,
|
||||
);
|
||||
if (!androidCredentialPath) {
|
||||
throw new Error('missing Android credential path resolver');
|
||||
}
|
||||
requirePattern(
|
||||
androidCredentialPath[0],
|
||||
/app_files_dir\(\)[\s\S]*?join\("OpenLess"\)[\s\S]*?join\(ANDROID_CREDENTIALS_FILE\)/,
|
||||
'Android credentials must use the app-private files directory',
|
||||
);
|
||||
if (/TAURI_ANDROID_APP_DATA_DIR|temp_dir/.test(androidCredentialPath[0])) {
|
||||
throw new Error('Android credential storage must not fall back to environment or temporary storage');
|
||||
}
|
||||
for (const pattern of [
|
||||
/android_legacy_credentials_paths/,
|
||||
/load_android_credentials_from_source_with_crypto/,
|
||||
/remove_migrated_android_legacy_credentials/,
|
||||
/android_legacy_root_migrates_to_private_destination_and_is_erased/,
|
||||
]) {
|
||||
requirePattern(credentials, pattern, `Android credential root migration is missing ${pattern}`);
|
||||
}
|
||||
requirePattern(
|
||||
rustStore,
|
||||
/let verified = open_envelope\(&persisted, crypto\)\?;[\s\S]*?if verified != plaintext \{[\s\S]*?\}[\s\S]*?mark_migration_complete\(\)[\s\S]*?fault\(WriteStage::AfterVerification\)/,
|
||||
'legacy downgrade barrier must be durable before the v2 envelope is installed',
|
||||
);
|
||||
requirePattern(
|
||||
rustStore,
|
||||
/verified_v2_commit_barrier_recovers_verified_temp_after_pre_rename_failure/,
|
||||
'Rust store must recover the verified pre-rename v2 candidate without accepting legacy',
|
||||
);
|
||||
requirePattern(
|
||||
rustStore,
|
||||
/invalidated_key_clears_pending_v2_recovery_candidate/,
|
||||
'Rust store must clear an unrecoverable pending v2 candidate so credentials can be reconfigured',
|
||||
);
|
||||
|
||||
for (const file of [
|
||||
'OpenLessCredentialCipher.kt',
|
||||
'OpenLessCredentialVault.kt',
|
||||
'OpenLessCredentialCipherTest.kt',
|
||||
'OpenLessCredentialVaultInstrumentedTest.kt',
|
||||
]) {
|
||||
if (!copyScript.includes(file)) {
|
||||
throw new Error(`Android scaffolding does not copy ${file}`);
|
||||
}
|
||||
}
|
||||
requirePattern(
|
||||
copyScript,
|
||||
/testInstrumentationRunner[\s\S]*androidx\.test\.runner\.AndroidJUnitRunner/,
|
||||
'generated Android project must declare the AndroidX instrumentation runner',
|
||||
);
|
||||
|
||||
requirePattern(
|
||||
ci,
|
||||
/targets:\s*aarch64-linux-android,x86_64-linux-android/,
|
||||
'PR CI must install the Rust target used by the x86_64 emulator',
|
||||
);
|
||||
requirePattern(
|
||||
ci,
|
||||
/testX86_64DebugUnitTest/,
|
||||
'PR CI must execute JVM credential tests for the x86_64 flavor',
|
||||
);
|
||||
requirePattern(
|
||||
ci,
|
||||
/assembleX86_64DebugAndroidTest/,
|
||||
'PR CI must compile x86_64 instrumentation tests',
|
||||
);
|
||||
requirePattern(
|
||||
ci,
|
||||
/connectedX86_64DebugAndroidTest/,
|
||||
'PR CI must execute x86_64 Android Keystore instrumentation tests on a device',
|
||||
);
|
||||
if (
|
||||
/:app:(?:testDebugUnitTest|assembleDebugAndroidTest|connectedDebugAndroidTest)\b/.test(
|
||||
ci,
|
||||
)
|
||||
) {
|
||||
throw new Error('PR CI must not use Android tasks without the required ABI flavor');
|
||||
}
|
||||
requirePattern(
|
||||
rustStore,
|
||||
/successful_v2_rejects_legacy_base64_downgrade/,
|
||||
'Rust store must test that legacy migration closes after v2 succeeds',
|
||||
);
|
||||
requirePattern(
|
||||
credentials,
|
||||
/android_bearer_is_scrubbed_before_failed_keystore_migration_returns/,
|
||||
'credentials integration must preserve the fail-closed Marketplace bearer scrub',
|
||||
);
|
||||
|
||||
console.log('android-credential-keystore-contract.test.mjs passed');
|
||||
@@ -6,10 +6,16 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
const kotlinRoot = join(appRoot, 'android/kotlin');
|
||||
const kotlinTestRoot = join(kotlinRoot, 'test');
|
||||
const kotlinAndroidTestRoot = join(kotlinRoot, 'androidTest');
|
||||
const manifestsRoot = join(appRoot, 'android/manifests');
|
||||
const androidIconRoot = join(appRoot, 'src-tauri/icons/android');
|
||||
const androidAppRoot = join(appRoot, 'src-tauri/gen/android/app');
|
||||
const genRoot = join(appRoot, 'src-tauri/gen/android/app/src/main');
|
||||
const kotlinDest = join(genRoot, 'java/com/openless/app');
|
||||
const kotlinTestDest = join(androidAppRoot, 'src/test/java/com/openless/app');
|
||||
const kotlinAndroidTestDest = join(androidAppRoot, 'src/androidTest/java/com/openless/app');
|
||||
const androidAppGradle = join(androidAppRoot, 'build.gradle.kts');
|
||||
const resDest = join(genRoot, 'res');
|
||||
const resXmlDest = join(genRoot, 'res/xml');
|
||||
|
||||
@@ -19,6 +25,8 @@ const KOTLIN_FILES = [
|
||||
'OpenLessPermissionBridge.kt',
|
||||
'MicrophonePermissionActivity.kt',
|
||||
'OpenLessAndroidPreferences.kt',
|
||||
'OpenLessCredentialCipher.kt',
|
||||
'OpenLessCredentialVault.kt',
|
||||
'OpenLessApplication.kt',
|
||||
'OpenLessOverlayService.kt',
|
||||
'OpenLessOverlayBridge.kt',
|
||||
@@ -28,6 +36,9 @@ const KOTLIN_FILES = [
|
||||
'OpenLessUpdateInstaller.kt',
|
||||
];
|
||||
|
||||
const KOTLIN_TEST_FILES = ['OpenLessCredentialCipherTest.kt'];
|
||||
const KOTLIN_ANDROID_TEST_FILES = ['OpenLessCredentialVaultInstrumentedTest.kt'];
|
||||
|
||||
const XML_FILES = [
|
||||
['res/xml/openless_accessibility_config.xml', 'openless_accessibility_config.xml'],
|
||||
];
|
||||
@@ -136,22 +147,11 @@ function copyDirectoryContents(srcRoot, destRoot, dryRun) {
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { dryRun } = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (!existsSync(join(appRoot, 'src-tauri/gen/android'))) {
|
||||
throw new Error(
|
||||
`Generated Android project not found under src-tauri/gen/android.\nRun "npm run tauri -- android init --ci" first.`,
|
||||
);
|
||||
}
|
||||
|
||||
ensureDir(kotlinDest, dryRun);
|
||||
ensureDir(resXmlDest, dryRun);
|
||||
copyDirectoryContents(androidIconRoot, resDest, dryRun);
|
||||
|
||||
for (const file of KOTLIN_FILES) {
|
||||
const src = join(kotlinRoot, file);
|
||||
const dest = join(kotlinDest, file);
|
||||
function copyNamedFiles(files, srcRoot, destRoot, dryRun) {
|
||||
ensureDir(destRoot, dryRun);
|
||||
for (const file of files) {
|
||||
const src = join(srcRoot, file);
|
||||
const dest = join(destRoot, file);
|
||||
if (!existsSync(src)) {
|
||||
throw new Error(`Missing scaffolding file: ${src}`);
|
||||
}
|
||||
@@ -162,6 +162,57 @@ function main() {
|
||||
copyFileSync(src, dest);
|
||||
console.log(`Copied ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureInstrumentationRunner(dryRun) {
|
||||
if (!existsSync(androidAppGradle)) {
|
||||
throw new Error(`Missing generated Android Gradle file: ${androidAppGradle}`);
|
||||
}
|
||||
const existing = readFileSync(androidAppGradle, 'utf8');
|
||||
if (existing.includes('testInstrumentationRunner')) {
|
||||
console.log(`Android instrumentation runner already present in ${androidAppGradle}; skipping.`);
|
||||
return;
|
||||
}
|
||||
const defaultConfig = /^(\s*)defaultConfig\s*\{\s*$/m;
|
||||
const match = existing.match(defaultConfig);
|
||||
if (!match) {
|
||||
throw new Error(`defaultConfig block not found in ${androidAppGradle}`);
|
||||
}
|
||||
const runner =
|
||||
`${match[0]}\n${match[1]} ` +
|
||||
'testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"';
|
||||
const updated = existing.replace(defaultConfig, runner);
|
||||
if (dryRun) {
|
||||
console.log(`[dry-run] Would add testInstrumentationRunner to ${androidAppGradle}`);
|
||||
return;
|
||||
}
|
||||
writeFileSync(androidAppGradle, updated, 'utf8');
|
||||
console.log(`Added Android instrumentation runner to ${androidAppGradle}`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { dryRun } = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (!existsSync(join(appRoot, 'src-tauri/gen/android'))) {
|
||||
throw new Error(
|
||||
`Generated Android project not found under src-tauri/gen/android.\nRun "npm run tauri -- android init --ci" first.`,
|
||||
);
|
||||
}
|
||||
|
||||
ensureDir(kotlinDest, dryRun);
|
||||
ensureDir(kotlinTestDest, dryRun);
|
||||
ensureDir(kotlinAndroidTestDest, dryRun);
|
||||
ensureDir(resXmlDest, dryRun);
|
||||
copyDirectoryContents(androidIconRoot, resDest, dryRun);
|
||||
copyNamedFiles(KOTLIN_FILES, kotlinRoot, kotlinDest, dryRun);
|
||||
copyNamedFiles(KOTLIN_TEST_FILES, kotlinTestRoot, kotlinTestDest, dryRun);
|
||||
copyNamedFiles(
|
||||
KOTLIN_ANDROID_TEST_FILES,
|
||||
kotlinAndroidTestRoot,
|
||||
kotlinAndroidTestDest,
|
||||
dryRun,
|
||||
);
|
||||
ensureInstrumentationRunner(dryRun);
|
||||
|
||||
for (const [relSrc, destName] of XML_FILES) {
|
||||
const src = join(manifestsRoot, relSrc);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub mod android {
|
||||
use jni::objects::{JClass, JObject, JString, JValue};
|
||||
use jni::objects::{JByteArray, JClass, JObject, JString, JValue};
|
||||
use jni::JNIEnv;
|
||||
use jni::JavaVM;
|
||||
|
||||
@@ -107,6 +107,219 @@ pub mod android {
|
||||
Ok(jstring(env, value)?.into())
|
||||
}
|
||||
|
||||
fn with_tao_android_env<R>(
|
||||
f: impl for<'local> FnOnce(&mut JNIEnv<'local>, &JObject<'local>) -> Result<R, String>,
|
||||
) -> Result<R, String> {
|
||||
let android_context = tao::platform::android::prelude::main_android_context()
|
||||
.ok_or_else(|| "Tao Android context not yet initialized".to_string())?;
|
||||
let vm = unsafe {
|
||||
JavaVM::from_raw(android_context.java_vm.cast())
|
||||
.map_err(|error| format!("attach Android JVM: {error}"))?
|
||||
};
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
.map_err(|error| format!("attach Android thread: {error}"))?;
|
||||
let raw_context = android_context.context_jobject as jni::sys::jobject;
|
||||
if raw_context.is_null() {
|
||||
return Err("Tao Android context is null".to_string());
|
||||
}
|
||||
// SAFETY: Tao keeps this activity reference alive for the Android
|
||||
// runtime; it is only borrowed for the duration of `f`.
|
||||
let context = unsafe { JObject::from_raw(raw_context) };
|
||||
f(&mut env, &context)
|
||||
}
|
||||
|
||||
/// Returns the app-private files directory supplied by Android's Context.
|
||||
pub(crate) fn app_files_dir() -> Result<String, String> {
|
||||
// Persistence initializes before mobile_runtime::setup initializes
|
||||
// ndk-context, so use Tao's non-panicking activity registry here.
|
||||
with_tao_android_env(|env, context| {
|
||||
let directory = env
|
||||
.call_method(context, "getFilesDir", "()Ljava/io/File;", &[])
|
||||
.and_then(|value| value.l())
|
||||
.map_err(|error| format!("Context.getFilesDir: {error}"))?;
|
||||
if directory.is_null() {
|
||||
return Err("Context.getFilesDir returned null".to_string());
|
||||
}
|
||||
let path = env
|
||||
.call_method(&directory, "getAbsolutePath", "()Ljava/lang/String;", &[])
|
||||
.and_then(|value| value.l())
|
||||
.map_err(|error| format!("File.getAbsolutePath: {error}"))?;
|
||||
if path.is_null() {
|
||||
return Err("Context files directory has no path".to_string());
|
||||
}
|
||||
let path = env
|
||||
.get_string(&JString::from(path))
|
||||
.map_err(|error| format!("read Context files directory: {error}"))?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
if path.is_empty() {
|
||||
return Err("Context files directory is empty".to_string());
|
||||
}
|
||||
Ok(path)
|
||||
})
|
||||
}
|
||||
|
||||
const CREDENTIAL_VAULT_CLASS: &str = "com.openless.app.OpenLessCredentialVault";
|
||||
const KEYSTORE_KEY_MISSING: &str = "openless-keystore-key-missing";
|
||||
const KEYSTORE_AUTHENTICATION_FAILED: &str = "openless-keystore-authentication-failed";
|
||||
const KEYSTORE_TEMPORARILY_UNAVAILABLE: &str = "openless-keystore-temporarily-unavailable";
|
||||
const KEYSTORE_MALFORMED: &str = "openless-keystore-malformed";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum AndroidKeystoreFailure {
|
||||
KeyMissingOrInvalidated,
|
||||
AuthenticationFailed,
|
||||
TemporarilyUnavailable,
|
||||
Malformed,
|
||||
}
|
||||
|
||||
fn clear_pending_exception(env: &mut JNIEnv) {
|
||||
if env.exception_check().unwrap_or(false) {
|
||||
let _ = env.exception_clear();
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
let Some((&status, payload)) = response.split_first() else {
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
fn call_credential_vault_two_arrays(
|
||||
method: &str,
|
||||
first: &[u8],
|
||||
second: &[u8],
|
||||
) -> Result<Vec<u8>, String> {
|
||||
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),
|
||||
};
|
||||
let first_array = match env.byte_array_from_slice(first) {
|
||||
Ok(array) => array,
|
||||
Err(_) => return keystore_temporarily_unavailable(env),
|
||||
};
|
||||
let second_array = match env.byte_array_from_slice(second) {
|
||||
Ok(array) => array,
|
||||
Err(_) => return keystore_temporarily_unavailable(env),
|
||||
};
|
||||
let first_object = JObject::from(first_array);
|
||||
let second_object = JObject::from(second_array);
|
||||
let value = match env.call_static_method(
|
||||
class,
|
||||
method,
|
||||
"([B[B)[B",
|
||||
&[
|
||||
JValue::Object(&first_object),
|
||||
JValue::Object(&second_object),
|
||||
],
|
||||
) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return keystore_temporarily_unavailable(env),
|
||||
};
|
||||
let object = match value.l() {
|
||||
Ok(object) => object,
|
||||
Err(_) => return keystore_temporarily_unavailable(env),
|
||||
};
|
||||
if object.is_null() {
|
||||
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),
|
||||
};
|
||||
credential_response(response)
|
||||
})
|
||||
}
|
||||
|
||||
fn call_credential_vault_no_args(method: &str) -> Result<Vec<u8>, String> {
|
||||
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),
|
||||
};
|
||||
let value = match env.call_static_method(class, method, "()[B", &[]) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return keystore_temporarily_unavailable(env),
|
||||
};
|
||||
let object = match value.l() {
|
||||
Ok(object) => object,
|
||||
Err(_) => return keystore_temporarily_unavailable(env),
|
||||
};
|
||||
if object.is_null() {
|
||||
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),
|
||||
};
|
||||
credential_response(response)
|
||||
})
|
||||
}
|
||||
|
||||
fn classify_keystore_failure(error: String) -> AndroidKeystoreFailure {
|
||||
match error.as_str() {
|
||||
KEYSTORE_KEY_MISSING => AndroidKeystoreFailure::KeyMissingOrInvalidated,
|
||||
KEYSTORE_AUTHENTICATION_FAILED => AndroidKeystoreFailure::AuthenticationFailed,
|
||||
KEYSTORE_MALFORMED => AndroidKeystoreFailure::Malformed,
|
||||
_ => AndroidKeystoreFailure::TemporarilyUnavailable,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn keystore_seal(
|
||||
plaintext: &[u8],
|
||||
aad: &[u8],
|
||||
) -> Result<Vec<u8>, AndroidKeystoreFailure> {
|
||||
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)
|
||||
}
|
||||
|
||||
pub(crate) fn keystore_delete_key() -> Result<(), AndroidKeystoreFailure> {
|
||||
call_credential_vault_no_args("deleteKey")
|
||||
.map(|_| ())
|
||||
.map_err(classify_keystore_failure)
|
||||
}
|
||||
|
||||
pub(crate) fn keystore_migration_complete() -> Result<bool, AndroidKeystoreFailure> {
|
||||
let payload = call_credential_vault_no_args("migrationComplete")
|
||||
.map_err(classify_keystore_failure)?;
|
||||
match payload.as_slice() {
|
||||
[0] => Ok(false),
|
||||
[1] => Ok(true),
|
||||
_ => Err(AndroidKeystoreFailure::Malformed),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn keystore_mark_migration_complete() -> Result<(), AndroidKeystoreFailure> {
|
||||
call_credential_vault_no_args("markMigrationComplete")
|
||||
.map(|_| ())
|
||||
.map_err(classify_keystore_failure)
|
||||
}
|
||||
|
||||
pub fn start_activity_class(
|
||||
env: &mut JNIEnv,
|
||||
context: &JObject,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -16,15 +16,13 @@
|
||||
//! "marketplace": { "githubAccessToken": "<desktop-only secret>" }
|
||||
//! }
|
||||
//!
|
||||
//! Android's generic credential envelope is only reversible Base64 today. Until
|
||||
//! the Keystore-backed vault lands, Marketplace OAuth is process-memory-only on
|
||||
//! Android and is deliberately stripped from `credentials.enc.json`.
|
||||
//! Android stores the same payload in a versioned AES-GCM envelope whose key is
|
||||
//! non-exportable from Android Keystore. Marketplace OAuth remains
|
||||
//! process-memory-only and is deliberately stripped from `credentials.enc.json`.
|
||||
//!
|
||||
//! "ark.api_key"/"volcengine.app_key" 等账户名按 Swift 语义路由到 active provider。
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
#[cfg(any(target_os = "android", test))]
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
@@ -376,22 +374,141 @@ fn keyring_entry_for(account: &str) -> Result<keyring::Entry> {
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
fn android_credentials_path() -> Result<PathBuf> {
|
||||
Ok(super::data_dir()?.join(ANDROID_CREDENTIALS_FILE))
|
||||
let files_dir = crate::android::jni::android::app_files_dir()
|
||||
.map_err(|error| anyhow::anyhow!("resolve Android credential directory: {error}"))?;
|
||||
Ok(PathBuf::from(files_dir)
|
||||
.join("OpenLess")
|
||||
.join(ANDROID_CREDENTIALS_FILE))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
fn android_legacy_credentials_paths(current_path: &Path) -> Vec<PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
let mut add_path = |path: PathBuf| {
|
||||
if path != current_path && !paths.contains(&path) {
|
||||
paths.push(path);
|
||||
}
|
||||
};
|
||||
if let Ok(dir) = std::env::var("TAURI_ANDROID_APP_DATA_DIR") {
|
||||
add_path(
|
||||
PathBuf::from(dir)
|
||||
.join("OpenLess")
|
||||
.join(ANDROID_CREDENTIALS_FILE),
|
||||
);
|
||||
}
|
||||
add_path(
|
||||
std::env::temp_dir()
|
||||
.join("OpenLess")
|
||||
.join(ANDROID_CREDENTIALS_FILE),
|
||||
);
|
||||
paths
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
fn remove_migrated_android_legacy_credentials(current_path: &Path) -> Result<()> {
|
||||
for legacy_path in android_legacy_credentials_paths(current_path) {
|
||||
super::android_credentials::secure_remove(&legacy_path)
|
||||
.map_err(anyhow::Error::new)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"remove migrated Android legacy envelope {}",
|
||||
legacy_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
fn load_android_credentials() -> Result<Option<CredsRoot>> {
|
||||
let path = android_credentials_path()?;
|
||||
let loaded = load_android_credentials_from_path(&path);
|
||||
if loaded.is_ok() {
|
||||
*android_marketplace_legacy_scrubbed().lock() = true;
|
||||
let mut crypto = super::android_credentials::AndroidKeystoreCrypto;
|
||||
let loaded = match load_android_credentials_from_path_with_crypto(&path, &mut crypto)? {
|
||||
Some(root) => Some(root),
|
||||
None => {
|
||||
let mut migrated = None;
|
||||
for legacy_path in android_legacy_credentials_paths(&path) {
|
||||
if let Some(root) = load_android_credentials_from_source_with_crypto(
|
||||
&legacy_path,
|
||||
&path,
|
||||
&mut crypto,
|
||||
)? {
|
||||
migrated = Some(root);
|
||||
break;
|
||||
}
|
||||
}
|
||||
migrated
|
||||
}
|
||||
};
|
||||
if loaded.is_some() {
|
||||
remove_migrated_android_legacy_credentials(&path)?;
|
||||
}
|
||||
loaded
|
||||
*android_marketplace_legacy_scrubbed().lock() = true;
|
||||
Ok(loaded)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
fn load_android_credentials_from_path(path: &Path) -> Result<Option<CredsRoot>> {
|
||||
let mut crypto = super::android_credentials::AndroidKeystoreCrypto;
|
||||
load_android_credentials_from_path_with_crypto(path, &mut crypto)
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_os = "android")))]
|
||||
fn load_android_credentials_from_path(path: &Path) -> Result<Option<CredsRoot>> {
|
||||
let mut crypto = super::android_credentials::TestCrypto::default();
|
||||
load_android_credentials_from_path_with_crypto(path, &mut crypto)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "android", test))]
|
||||
fn load_android_credentials_from_path(path: &Path) -> Result<Option<CredsRoot>> {
|
||||
load_android_credentials_from_path_with_fault(path, &mut |_| Ok(()))
|
||||
fn load_android_credentials_from_path_with_crypto(
|
||||
path: &Path,
|
||||
crypto: &mut impl super::android_credentials::AndroidCredentialsCrypto,
|
||||
) -> Result<Option<CredsRoot>> {
|
||||
load_android_credentials_from_source_with_crypto(path, path, crypto)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "android", test))]
|
||||
fn load_android_credentials_from_source_with_crypto(
|
||||
source_path: &Path,
|
||||
destination_path: &Path,
|
||||
crypto: &mut impl super::android_credentials::AndroidCredentialsCrypto,
|
||||
) -> Result<Option<CredsRoot>> {
|
||||
use super::android_credentials::ReadOutcome;
|
||||
|
||||
let loaded = super::android_credentials::read(source_path, crypto)
|
||||
.map_err(anyhow::Error::new)
|
||||
.context("read Android credential envelope")?;
|
||||
let (bytes, needs_rewrite) = match loaded {
|
||||
ReadOutcome::Missing => return Ok(None),
|
||||
ReadOutcome::Legacy(bytes) => (bytes, true),
|
||||
ReadOutcome::Plaintext(bytes) => (bytes, false),
|
||||
};
|
||||
let root = serde_json::from_slice::<CredsRoot>(&bytes)
|
||||
.context("parse Android credential payload")?;
|
||||
let cleaned = android_persistable_credentials(&root);
|
||||
let contained_marketplace_token = lookup_marketplace_github_token(&root).is_some();
|
||||
if needs_rewrite && contained_marketplace_token {
|
||||
let sanitized = serde_json::to_vec(&cleaned)
|
||||
.context("encode bearer-free Android legacy payload")?;
|
||||
super::android_credentials::rewrite_legacy_without_bearer(source_path, &sanitized)
|
||||
.map_err(anyhow::Error::new)
|
||||
.context("scrub Marketplace bearer before Android Keystore migration")?;
|
||||
}
|
||||
if needs_rewrite || contained_marketplace_token || source_path != destination_path {
|
||||
write_android_credentials_envelope_with_crypto(destination_path, &cleaned, crypto)
|
||||
.context("migrate Android credential envelope")?;
|
||||
}
|
||||
if source_path != destination_path {
|
||||
super::android_credentials::secure_remove(source_path)
|
||||
.map_err(anyhow::Error::new)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"remove migrated Android legacy envelope {}",
|
||||
source_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok(Some(cleaned))
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "android", test))]
|
||||
@@ -422,60 +539,8 @@ fn get_android_marketplace_token_at(
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
fn ensure_android_marketplace_legacy_scrubbed() -> Result<()> {
|
||||
let path = android_credentials_path()?;
|
||||
ensure_android_marketplace_legacy_scrubbed_at(&path, android_marketplace_legacy_scrubbed())
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "android", test))]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum AndroidEnvelopeStage {
|
||||
LegacyOpen,
|
||||
LegacySync,
|
||||
LegacyRemove,
|
||||
ParentSyncAfterErase,
|
||||
AfterErase,
|
||||
TempOpen,
|
||||
AfterTempOpen,
|
||||
TempWrite,
|
||||
AfterTempWrite,
|
||||
TempFlush,
|
||||
AfterTempFlush,
|
||||
TempSync,
|
||||
AfterTempSync,
|
||||
Rename,
|
||||
AfterRename,
|
||||
ParentSyncAfterRename,
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "android", test))]
|
||||
fn load_android_credentials_from_path_with_fault(
|
||||
path: &Path,
|
||||
fault: &mut impl FnMut(AndroidEnvelopeStage) -> io::Result<()>,
|
||||
) -> Result<Option<CredsRoot>> {
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let bytes = std::fs::read(path).with_context(|| format!("read failed: {}", path.display()))?;
|
||||
if bytes.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
// Stub: base64 envelope — replace with Keystore-backed AES when JNI lands.
|
||||
use base64::Engine;
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(bytes)
|
||||
.context("decode android credentials envelope")?;
|
||||
let mut root =
|
||||
serde_json::from_slice::<CredsRoot>(&decoded).context("parse android credentials json")?;
|
||||
if lookup_marketplace_github_token(&root).is_some() {
|
||||
// Files written by unreleased/dev builds may contain a recoverable
|
||||
// bearer token. Erase that legacy secret durably *before* creating the
|
||||
// sanitized replacement. A crash can therefore lose generic Android
|
||||
// credentials, but can never leave the old bearer recoverable.
|
||||
write_marketplace_github_token(&mut root, None);
|
||||
scrub_android_legacy_envelope_with_fault(path, &root, fault)
|
||||
.context("scrub Marketplace token from Android credential envelope")?;
|
||||
}
|
||||
Ok(Some(root))
|
||||
let _ = load_android_credentials()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
@@ -486,115 +551,21 @@ fn save_android_credentials(root: &CredsRoot) -> Result<()> {
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
fn write_android_credentials_envelope(path: &Path, root: &CredsRoot) -> Result<()> {
|
||||
write_android_credentials_envelope_with_fault(path, root, false, &mut |_| Ok(()))
|
||||
let mut crypto = super::android_credentials::AndroidKeystoreCrypto;
|
||||
write_android_credentials_envelope_with_crypto(path, root, &mut crypto)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "android", test))]
|
||||
fn scrub_android_legacy_envelope_with_fault(
|
||||
fn write_android_credentials_envelope_with_crypto(
|
||||
path: &Path,
|
||||
root: &CredsRoot,
|
||||
fault: &mut impl FnMut(AndroidEnvelopeStage) -> io::Result<()>,
|
||||
) -> Result<()> {
|
||||
write_android_credentials_envelope_with_fault(path, root, true, fault)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "android", test))]
|
||||
fn sync_android_credentials_parent(path: &Path) -> io::Result<()> {
|
||||
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
std::fs::File::open(parent)?.sync_all()
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "android", test))]
|
||||
fn fail_closed_remove_android_envelope(path: &Path, tmp: &Path) {
|
||||
// Truncate+sync first so even a crash that resurrects the directory entry
|
||||
// after unlink cannot resurrect the bearer bytes.
|
||||
if let Ok(file) = std::fs::OpenOptions::new().write(true).open(path) {
|
||||
let _ = file.set_len(0);
|
||||
let _ = file.sync_all();
|
||||
}
|
||||
let _ = std::fs::remove_file(path);
|
||||
let _ = std::fs::remove_file(tmp);
|
||||
let _ = sync_android_credentials_parent(path);
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "android", test))]
|
||||
fn write_android_credentials_envelope_with_fault(
|
||||
path: &Path,
|
||||
root: &CredsRoot,
|
||||
erase_legacy_secret_first: bool,
|
||||
fault: &mut impl FnMut(AndroidEnvelopeStage) -> io::Result<()>,
|
||||
crypto: &mut impl super::android_credentials::AndroidCredentialsCrypto,
|
||||
) -> Result<()> {
|
||||
let cleaned = android_persistable_credentials(root);
|
||||
let json = serde_json::to_string(&cleaned).context("encode credentials failed")?;
|
||||
use base64::Engine;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(json.as_bytes());
|
||||
super::ensure_dir(path.parent().unwrap_or_else(|| Path::new(".")))?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
|
||||
let persisted = (|| -> Result<()> {
|
||||
if erase_legacy_secret_first {
|
||||
fault(AndroidEnvelopeStage::LegacyOpen)?;
|
||||
let legacy = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(path)
|
||||
.with_context(|| format!("open legacy envelope failed: {}", path.display()))?;
|
||||
fault(AndroidEnvelopeStage::LegacySync)?;
|
||||
legacy
|
||||
.sync_all()
|
||||
.with_context(|| format!("sync erased envelope failed: {}", path.display()))?;
|
||||
drop(legacy);
|
||||
fault(AndroidEnvelopeStage::LegacyRemove)?;
|
||||
std::fs::remove_file(path)
|
||||
.with_context(|| format!("remove legacy envelope failed: {}", path.display()))?;
|
||||
fault(AndroidEnvelopeStage::ParentSyncAfterErase)?;
|
||||
sync_android_credentials_parent(path)
|
||||
.with_context(|| format!("sync erased envelope directory: {}", path.display()))?;
|
||||
fault(AndroidEnvelopeStage::AfterErase)?;
|
||||
}
|
||||
|
||||
fault(AndroidEnvelopeStage::TempOpen)?;
|
||||
let mut output = std::fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&tmp)
|
||||
.with_context(|| format!("open failed: {}", tmp.display()))?;
|
||||
fault(AndroidEnvelopeStage::AfterTempOpen)?;
|
||||
fault(AndroidEnvelopeStage::TempWrite)?;
|
||||
output
|
||||
.write_all(encoded.as_bytes())
|
||||
.with_context(|| format!("write failed: {}", tmp.display()))?;
|
||||
fault(AndroidEnvelopeStage::AfterTempWrite)?;
|
||||
fault(AndroidEnvelopeStage::TempFlush)?;
|
||||
output
|
||||
.flush()
|
||||
.with_context(|| format!("flush failed: {}", tmp.display()))?;
|
||||
fault(AndroidEnvelopeStage::AfterTempFlush)?;
|
||||
fault(AndroidEnvelopeStage::TempSync)?;
|
||||
output
|
||||
.sync_all()
|
||||
.with_context(|| format!("sync failed: {}", tmp.display()))?;
|
||||
fault(AndroidEnvelopeStage::AfterTempSync)?;
|
||||
drop(output);
|
||||
fault(AndroidEnvelopeStage::Rename)?;
|
||||
std::fs::rename(&tmp, path)
|
||||
.with_context(|| format!("replace failed: {}", path.display()))?;
|
||||
fault(AndroidEnvelopeStage::AfterRename)?;
|
||||
fault(AndroidEnvelopeStage::ParentSyncAfterRename)?;
|
||||
sync_android_credentials_parent(path)
|
||||
.with_context(|| format!("sync replacement directory: {}", path.display()))?;
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
if persisted.is_err() {
|
||||
if erase_legacy_secret_first {
|
||||
fail_closed_remove_android_envelope(path, &tmp);
|
||||
} else {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
}
|
||||
persisted
|
||||
let json = serde_json::to_vec(&cleaned).context("encode Android credential payload")?;
|
||||
super::android_credentials::write_verified(path, &json, crypto)
|
||||
.map_err(anyhow::Error::new)
|
||||
.context("write Android credential envelope")
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "android", test))]
|
||||
@@ -1434,14 +1405,15 @@ mod tests {
|
||||
use super::{
|
||||
android_persistable_credentials, chunk_json_payload, credentials_cache,
|
||||
get_android_marketplace_token_at, load_android_credentials_from_path,
|
||||
load_android_credentials_from_path_with_fault, load_android_credentials_into_cache_with,
|
||||
load_android_credentials_from_path_with_crypto, load_android_credentials_into_cache_with,
|
||||
lookup_marketplace_github_token, parse_extra_headers_json,
|
||||
reset_credentials_cache_for_tests, write_marketplace_github_token, AndroidEnvelopeStage,
|
||||
CredsRoot, MarketplaceGithubToken, KEYRING_CHUNK_MAX_UTF16_UNITS,
|
||||
reset_credentials_cache_for_tests, write_marketplace_github_token, CredsRoot,
|
||||
MarketplaceGithubToken, KEYRING_CHUNK_MAX_UTF16_UNITS,
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
use super::load_android_credentials_from_source_with_crypto;
|
||||
use anyhow::anyhow;
|
||||
use parking_lot::Mutex;
|
||||
use std::io;
|
||||
|
||||
#[test]
|
||||
fn credential_payload_chunks_stay_under_windows_blob_limit() {
|
||||
@@ -1558,24 +1530,64 @@ mod tests {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("credentials.enc.json");
|
||||
std::fs::write(&path, encoded).unwrap();
|
||||
let mut crypto = super::super::android_credentials::TestCrypto::default();
|
||||
|
||||
let loaded = load_android_credentials_from_path(&path)
|
||||
let loaded = load_android_credentials_from_path_with_crypto(&path, &mut crypto)
|
||||
.unwrap()
|
||||
.expect("credential envelope should load");
|
||||
let disk = std::fs::read_to_string(&path).unwrap();
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(disk)
|
||||
.unwrap();
|
||||
let decoded = String::from_utf8(decoded).unwrap();
|
||||
let loaded_again = load_android_credentials_from_path_with_crypto(&path, &mut crypto)
|
||||
.unwrap()
|
||||
.expect("migrated credential envelope should load");
|
||||
|
||||
assert_eq!(lookup_marketplace_github_token(&loaded), None);
|
||||
assert!(!decoded.contains(token));
|
||||
assert!(!decoded.contains("githubAccessToken"));
|
||||
assert!(!decoded.contains("marketplace"));
|
||||
assert_eq!(lookup_marketplace_github_token(&loaded_again), None);
|
||||
assert!(disk.starts_with('{'));
|
||||
assert!(disk.contains("openless-android-credentials"));
|
||||
assert!(!disk.contains(token));
|
||||
assert!(!disk.contains("githubAccessToken"));
|
||||
assert!(!disk.contains("marketplace"));
|
||||
assert!(!path.with_extension("json.tmp").exists());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn android_legacy_root_migrates_to_private_destination_and_is_erased() {
|
||||
use base64::Engine;
|
||||
|
||||
let root_dir = std::env::temp_dir().join(format!(
|
||||
"openless-android-cross-root-migration-{}",
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
let legacy_path = root_dir.join("legacy").join("credentials.enc.json");
|
||||
let destination_path = root_dir.join("files").join("credentials.enc.json");
|
||||
let plaintext = br#"{"version":1,"providers":{"llm":{"ark":{"apiKey":"sk-migrate"}}}}"#;
|
||||
std::fs::create_dir_all(legacy_path.parent().unwrap()).unwrap();
|
||||
std::fs::write(
|
||||
&legacy_path,
|
||||
base64::engine::general_purpose::STANDARD.encode(plaintext),
|
||||
)
|
||||
.unwrap();
|
||||
let mut crypto = super::super::android_credentials::TestCrypto::default();
|
||||
|
||||
assert!(load_android_credentials_from_source_with_crypto(
|
||||
&legacy_path,
|
||||
&destination_path,
|
||||
&mut crypto,
|
||||
)
|
||||
.unwrap()
|
||||
.is_some());
|
||||
assert!(!legacy_path.exists());
|
||||
assert!(std::fs::read_to_string(&destination_path)
|
||||
.unwrap()
|
||||
.contains("openless-android-credentials"));
|
||||
assert!(load_android_credentials_from_path_with_crypto(&destination_path, &mut crypto)
|
||||
.unwrap()
|
||||
.is_some());
|
||||
std::fs::remove_dir_all(root_dir).unwrap();
|
||||
}
|
||||
|
||||
fn write_legacy_android_envelope(path: &std::path::Path, token: &str) {
|
||||
use base64::Engine;
|
||||
|
||||
@@ -1590,7 +1602,11 @@ mod tests {
|
||||
fn assert_android_secret_unrecoverable(path: &std::path::Path, token: &str) {
|
||||
use base64::Engine;
|
||||
|
||||
for candidate in [path.to_path_buf(), path.with_extension("json.tmp")] {
|
||||
for candidate in [
|
||||
path.to_path_buf(),
|
||||
path.with_extension("json.tmp"),
|
||||
path.with_extension("legacy.tmp"),
|
||||
] {
|
||||
let Ok(bytes) = std::fs::read(&candidate) else {
|
||||
continue;
|
||||
};
|
||||
@@ -1610,72 +1626,48 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn android_legacy_scrub_is_fail_closed_for_every_io_failure() {
|
||||
let stages = [
|
||||
AndroidEnvelopeStage::LegacyOpen,
|
||||
AndroidEnvelopeStage::LegacySync,
|
||||
AndroidEnvelopeStage::LegacyRemove,
|
||||
AndroidEnvelopeStage::ParentSyncAfterErase,
|
||||
AndroidEnvelopeStage::TempOpen,
|
||||
AndroidEnvelopeStage::TempWrite,
|
||||
AndroidEnvelopeStage::TempFlush,
|
||||
AndroidEnvelopeStage::TempSync,
|
||||
AndroidEnvelopeStage::Rename,
|
||||
AndroidEnvelopeStage::ParentSyncAfterRename,
|
||||
];
|
||||
fn android_bearer_is_scrubbed_before_failed_keystore_migration_returns() {
|
||||
use base64::Engine;
|
||||
|
||||
for failed_stage in stages {
|
||||
let token = format!("gho_android_fault_{failed_stage:?}");
|
||||
let dir = std::env::temp_dir()
|
||||
.join(format!("openless-android-fault-{}", uuid::Uuid::new_v4()));
|
||||
let path = dir.join("credentials.enc.json");
|
||||
write_legacy_android_envelope(&path, &token);
|
||||
let token = "gho_must_be_unrecoverable";
|
||||
let provider_secret = "sk_generic_credential_survives";
|
||||
let raw = format!(
|
||||
r#"{{"version":1,"providers":{{"llm":{{"ark":{{"apiKey":"{provider_secret}"}}}}}},"marketplace":{{"githubAccessToken":"{token}"}}}}"#,
|
||||
);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"openless-android-bearer-migration-{}",
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
let path = dir.join("credentials.enc.json");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(
|
||||
&path,
|
||||
base64::engine::general_purpose::STANDARD.encode(raw.as_bytes()),
|
||||
)
|
||||
.unwrap();
|
||||
let mut crypto = super::super::android_credentials::TestCrypto::default();
|
||||
crypto.fail_next_seal = Some(
|
||||
super::super::android_credentials::CryptoErrorKind::TemporarilyUnavailable,
|
||||
);
|
||||
|
||||
let result = load_android_credentials_from_path_with_fault(&path, &mut |stage| {
|
||||
if stage == failed_stage {
|
||||
Err(io::Error::other(format!("injected {stage:?}")))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
assert!(load_android_credentials_from_path_with_crypto(&path, &mut crypto).is_err());
|
||||
let sanitized = std::fs::read(&path).unwrap();
|
||||
let sanitized = base64::engine::general_purpose::STANDARD
|
||||
.decode(sanitized)
|
||||
.unwrap();
|
||||
let sanitized = String::from_utf8(sanitized).unwrap();
|
||||
assert!(!sanitized.contains(token));
|
||||
assert!(!sanitized.contains("githubAccessToken"));
|
||||
assert!(sanitized.contains(provider_secret));
|
||||
|
||||
assert!(result.is_err(), "{failed_stage:?} should fail");
|
||||
assert_android_secret_unrecoverable(&path, &token);
|
||||
assert!(!path.with_extension("json.tmp").exists());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn android_legacy_secret_is_unrecoverable_at_every_crash_boundary() {
|
||||
let boundaries = [
|
||||
AndroidEnvelopeStage::AfterErase,
|
||||
AndroidEnvelopeStage::AfterTempOpen,
|
||||
AndroidEnvelopeStage::AfterTempWrite,
|
||||
AndroidEnvelopeStage::AfterTempFlush,
|
||||
AndroidEnvelopeStage::AfterTempSync,
|
||||
AndroidEnvelopeStage::AfterRename,
|
||||
];
|
||||
|
||||
for crash_stage in boundaries {
|
||||
let token = format!("gho_android_crash_{crash_stage:?}");
|
||||
let dir = std::env::temp_dir()
|
||||
.join(format!("openless-android-crash-{}", uuid::Uuid::new_v4()));
|
||||
let path = dir.join("credentials.enc.json");
|
||||
write_legacy_android_envelope(&path, &token);
|
||||
|
||||
let crashed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _ = load_android_credentials_from_path_with_fault(&path, &mut |stage| {
|
||||
assert_ne!(stage, crash_stage, "injected crash at {stage:?}");
|
||||
Ok(())
|
||||
});
|
||||
}));
|
||||
|
||||
assert!(crashed.is_err(), "{crash_stage:?} should simulate a crash");
|
||||
assert_android_secret_unrecoverable(&path, &token);
|
||||
let _ = std::fs::remove_file(path.with_extension("json.tmp"));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
let loaded = load_android_credentials_from_path_with_crypto(&path, &mut crypto)
|
||||
.unwrap()
|
||||
.expect("sanitized legacy credentials should remain retryable");
|
||||
assert_eq!(lookup_marketplace_github_token(&loaded), None);
|
||||
assert!(std::fs::read_to_string(&path)
|
||||
.unwrap()
|
||||
.contains("openless-android-credentials"));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -25,6 +25,8 @@ use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
mod activity;
|
||||
#[cfg(any(target_os = "android", test))]
|
||||
mod android_credentials;
|
||||
mod correction;
|
||||
mod credentials;
|
||||
mod dictionary;
|
||||
|
||||
Reference in new issue
Block a user