Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 63 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -51,16 +51,27 @@ 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:
node-version: "22"
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: |
Expand All @@ -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:
Expand Down
45 changes: 45 additions & 0 deletions openless-all/app/android/kotlin/OpenLessCredentialCipher.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
184 changes: 184 additions & 0 deletions openless-all/app/android/kotlin/OpenLessCredentialVault.kt
Original file line number Diff line number Diff line change
@@ -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()
}
Loading
Loading