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:
authored and GitHub committed 2026-07-18 18:13:34 +08:00
1 parent ce3fd8d847
commit 75918e0338
11 files changed
+2542 -261

No files matched your search

+63 -2
View File
@@ -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()
}
@@ -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);
+214 -1
View File
@@ -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;
}
loaded
}
migrated
}
};
if loaded.is_some() {
remove_migrated_android_legacy_credentials(&path)?;
}
*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,73 +1626,49 @@ 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 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");
write_legacy_android_envelope(&path, &token);
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 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]
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);
}
}
#[test]
fn android_real_getter_scrubs_legacy_disk_token_and_retries_failure() {
@@ -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;