From 1abd75282ea5f08af0aa469dc5527842979a744a Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Thu, 8 Oct 2026 16:22:57 +0200 Subject: [PATCH] Implement Rust-owned Android Keystore secure storage (#93) --- Cargo.lock | 1 + android/app/build.gradle.kts | 1 + android/app/gradle.lockfile | 26 +- android/app/src/main/AndroidManifest.xml | 1 + .../de/rfc1437/ironstorage/MainActivity.kt | 13 +- .../de/rfc1437/ironstorage/ShellModule.kt | 21 +- .../ironstorage/core/AndroidKeystore.kt | 58 + .../ironstorage/core/KeystoreCallback.kt | 74 + .../core/NativeFixtureShellRepository.kt | 14 +- android/gradle/libs.versions.toml | 7 + android/gradle/verification-metadata.xml | 566 ++++++++ android/platform/build.gradle.kts | 6 + android/platform/gradle.lockfile | 78 +- .../src/androidTest/AndroidManifest.xml | 5 + .../platform/AndroidKeystoreTest.kt | 359 +++++ .../platform/KeystoreTestActivity.kt | 50 + .../src/androidTest/res/values/strings.xml | 4 + android/platform/src/main/AndroidManifest.xml | 4 +- .../platform/AndroidKeystoreAdapter.kt | 306 +++++ .../platform/src/main/res/values/strings.xml | 4 + .../bridge/KeystoreContractTest.kt | 119 ++ apple/Generated/ironstorage_mobile.swift | 888 ++++++++++++ apple/Generated/ironstorage_mobileFFI.h | 167 +++ crates/mobile-bridge/src/android_keystore.rs | 282 ++++ crates/mobile-bridge/src/lib.rs | 2 + crates/mobile-bridge/src/platform.rs | 6 + crates/mobile-bridge/src/private_storage.rs | 11 + crates/storage/Cargo.toml | 1 + crates/storage/src/mobile_authentication.rs | 9 +- crates/storage/src/mobile_private_storage.rs | 16 +- crates/storage/src/secret_store.rs | 9 + crates/storage/src/secret_store/android.rs | 1185 +++++++++++++++++ docs/android/keystore.md | 91 ++ docs/android/parity.md | 2 +- docs/android/private-storage.md | 8 +- tools/android-build/src/bindings.rs | 23 + 36 files changed, 4380 insertions(+), 37 deletions(-) create mode 100644 android/core/src/main/kotlin/de/rfc1437/ironstorage/core/AndroidKeystore.kt create mode 100644 android/core/src/main/kotlin/de/rfc1437/ironstorage/core/KeystoreCallback.kt create mode 100644 android/platform/src/androidTest/AndroidManifest.xml create mode 100644 android/platform/src/androidTest/kotlin/de/rfc1437/ironstorage/platform/AndroidKeystoreTest.kt create mode 100644 android/platform/src/androidTest/kotlin/de/rfc1437/ironstorage/platform/KeystoreTestActivity.kt create mode 100644 android/platform/src/androidTest/res/values/strings.xml create mode 100644 android/platform/src/main/kotlin/de/rfc1437/ironstorage/platform/AndroidKeystoreAdapter.kt create mode 100644 android/platform/src/main/res/values/strings.xml create mode 100644 android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/KeystoreContractTest.kt create mode 100644 crates/mobile-bridge/src/android_keystore.rs create mode 100644 crates/storage/src/secret_store/android.rs create mode 100644 docs/android/keystore.md diff --git a/Cargo.lock b/Cargo.lock index b45edb4..794b748 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5390,6 +5390,7 @@ checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" name = "ironstorage" version = "1.0.1" dependencies = [ + "aes-gcm 0.11.1", "apple-native-keyring-store", "arboard", "cap-std", diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f17093a..165b347 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -53,6 +53,7 @@ dependencies { implementation(project(":features")) implementation(project(":core")) implementation(project(":platform")) + implementation(libs.fragment) implementation(platform(libs.compose.bom)) implementation(libs.compose.ui) implementation(libs.compose.material3) diff --git a/android/app/gradle.lockfile b/android/app/gradle.lockfile index 74ce385..b957dbe 100644 --- a/android/app/gradle.lockfile +++ b/android/app/gradle.lockfile @@ -3,19 +3,24 @@ # This file is expected to be part of source control. # To regenerate this file, run: ./gradlew :app:dependencies --write-locks androidx.activity:activity-compose:1.13.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.activity:activity-ktx:1.13.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.activity:activity-ktx:1.13.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.activity:activity:1.13.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.annotation:annotation-experimental:1.5.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.annotation:annotation-jvm:1.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.annotation:annotation-jvm:1.9.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath androidx.annotation:annotation:1.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.annotation:annotation:1.9.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +androidx.appcompat:appcompat-resources:1.2.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.appcompat:appcompat:1.2.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.arch.core:core-common:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.arch.core:core-runtime:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.autofill:autofill:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.collection:collection-jvm:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.collection:collection-ktx:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.collection:collection:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.biometric:biometric:1.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.collection:collection-jvm:1.5.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +androidx.collection:collection-jvm:1.6.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.collection:collection-ktx:1.6.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.collection:collection:1.5.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +androidx.collection:collection:1.6.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.animation:animation-android:1.12.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.animation:animation-core-android:1.12.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.animation:animation-core:1.12.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -62,10 +67,12 @@ androidx.concurrent:concurrent-futures:1.2.0=debugAndroidTestCompileClasspath,de androidx.core:core-ktx:1.18.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.core:core-viewtree:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.core:core:1.18.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.cursoradapter:cursoradapter:1.0.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.customview:customview-poolingcontainer:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.customview:customview:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.drawerlayout:drawerlayout:1.0.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.emoji2:emoji2:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.fragment:fragment:1.5.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.fragment:fragment:1.9.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.graphics:graphics-path:1.0.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.interpolator:interpolator:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-common-java8:2.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -84,7 +91,7 @@ androidx.lifecycle:lifecycle-runtime:2.11.0=debugAndroidTestCompileClasspath,deb androidx.lifecycle:lifecycle-viewmodel-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-viewmodel-compose-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.lifecycle:lifecycle-viewmodel-ktx:2.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-ktx:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-viewmodel-savedstate:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-viewmodel:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -101,7 +108,7 @@ androidx.profileinstaller:profileinstaller:1.4.0=debugAndroidTestLintChecksClass androidx.savedstate:savedstate-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.savedstate:savedstate-compose-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.savedstate:savedstate-compose:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.savedstate:savedstate-ktx:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.savedstate:savedstate-ktx:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.savedstate:savedstate:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.startup:startup-runtime:1.1.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.test.espresso:espresso-core:3.7.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath @@ -111,8 +118,11 @@ androidx.test.services:storage:1.6.0=debugAndroidTestCompileClasspath,debugAndro androidx.test:core:1.7.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath androidx.test:monitor:1.8.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath androidx.test:runner:1.7.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.tracing:tracing-android:2.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.tracing:tracing:1.1.0=debugAndroidTestCompileClasspath -androidx.tracing:tracing:1.2.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.tracing:tracing:2.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.vectordrawable:vectordrawable-animated:1.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.vectordrawable:vectordrawable:1.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.versionedparcelable:versionedparcelable:1.1.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.viewpager:viewpager:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.window:window-core-android:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ebf3fef..0f6a938 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + + fun cancelPending() +} diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/KeystoreCallback.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/KeystoreCallback.kt new file mode 100644 index 0000000..d7b8360 --- /dev/null +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/KeystoreCallback.kt @@ -0,0 +1,74 @@ +package de.rfc1437.ironstorage.core + +import de.rfc1437.ironstorage.bridge.generated.MobileAndroidAuthorization +import de.rfc1437.ironstorage.bridge.generated.MobileAndroidCiphertext +import de.rfc1437.ironstorage.bridge.generated.MobileAndroidDeviceState +import de.rfc1437.ironstorage.bridge.generated.MobileAndroidKeySpecification +import de.rfc1437.ironstorage.bridge.generated.MobileAndroidKeystore +import de.rfc1437.ironstorage.bridge.generated.MobileAndroidKeystoreException + +internal class KeystoreCallback(private val os: AndroidKeystore) : MobileAndroidKeystore { + override fun deviceState(): MobileAndroidDeviceState = call { + val s = os.deviceState() + MobileAndroidDeviceState(s.api.toUInt(), s.locked, s.secure, s.strongBiometric, s.strongbox) + } + override fun createKey(specification: MobileAndroidKeySpecification) = call { + os.createKey( + KeystoreKeySpecification( + specification.alias, + specification.authorization.toOs(), + specification.unlockedDevice, + specification.invalidateOnEnrollment, + specification.strongbox + ) + ) + } + override fun encrypt( + alias: String, + authorization: MobileAndroidAuthorization, + aad: ByteArray, + value: ByteArray + ): MobileAndroidCiphertext = call { + try { + val result = os.encrypt(alias, authorization.toOs(), aad, value) + MobileAndroidCiphertext(result.nonce, result.bytes) + } finally { + value.fill(0) + } + } + override fun decrypt( + alias: String, + authorization: MobileAndroidAuthorization, + aad: ByteArray, + value: MobileAndroidCiphertext + ): ByteArray = call { + os.decrypt(alias, authorization.toOs(), aad, KeystoreCiphertext(value.nonce, value.bytes)) + } + override fun deleteKey(alias: String) = call { os.deleteKey(alias) } + override fun aliases(): List = call { os.aliases() } + override fun cancelPending() = os.cancelPending() +} +private fun MobileAndroidAuthorization.toOs(): KeystoreAuthorization = when (this) { + MobileAndroidAuthorization.NONE -> KeystoreAuthorization.None + MobileAndroidAuthorization.STRONG_BIOMETRIC -> KeystoreAuthorization.StrongBiometric + MobileAndroidAuthorization.USER_PRESENCE -> KeystoreAuthorization.UserPresence +} +private inline fun call(block: () -> T): T = try { + block() +} catch (error: KeystoreException) { + throw when (error.kind) { + KeystoreFailure.MissingKey -> MobileAndroidKeystoreException.MissingKey() + KeystoreFailure.Invalidated -> MobileAndroidKeystoreException.Invalidated() + KeystoreFailure.Lockout -> MobileAndroidKeystoreException.Lockout() + KeystoreFailure.Locked -> MobileAndroidKeystoreException.Locked() + KeystoreFailure.Denied -> MobileAndroidKeystoreException.Denied() + KeystoreFailure.Cancelled -> MobileAndroidKeystoreException.Cancelled() + KeystoreFailure.Corrupted -> MobileAndroidKeystoreException.Corrupted() + KeystoreFailure.UnsupportedProtection -> + MobileAndroidKeystoreException.UnsupportedProtection() + KeystoreFailure.StrongboxUnavailable -> + MobileAndroidKeystoreException.StrongboxUnavailable() + KeystoreFailure.AlreadyExists -> MobileAndroidKeystoreException.AlreadyExists() + KeystoreFailure.Unavailable -> MobileAndroidKeystoreException.Unavailable() + } +} diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeFixtureShellRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeFixtureShellRepository.kt index a5db5e6..2233136 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeFixtureShellRepository.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeFixtureShellRepository.kt @@ -27,8 +27,13 @@ class NativeFixtureShellRepository internal constructor( private val scope: CoroutineScope, private val load: suspend (ShellState) -> ShellSnapshot ) : ShellRepository { - constructor(context: Context, scope: CoroutineScope, directories: () -> PrivateDirectories) : - this(scope, nativeLoader(context, directories)) + constructor( + context: Context, + scope: CoroutineScope, + directories: () -> PrivateDirectories, + keystore: AndroidKeystore + ) : + this(scope, nativeLoader(context, directories, keystore)) private val mutableSnapshot = MutableStateFlow(ShellSnapshot(ShellState.Loading, emptyList())) override val snapshot: StateFlow = mutableSnapshot private val revision = AtomicLong() @@ -64,7 +69,8 @@ class NativeFixtureShellRepository internal constructor( private fun nativeLoader( context: Context, - directoryProvider: () -> PrivateDirectories + directoryProvider: () -> PrivateDirectories, + keystore: AndroidKeystore ): suspend (ShellState) -> ShellSnapshot { val runtime = NativeRuntime(context) var storage: MobilePrivateRuntime? = null @@ -85,6 +91,8 @@ private fun nativeLoader( ) storage = initialized initialized.activate() + val secrets = initialized.androidSecretBackend(KeystoreCallback(keystore)) + initialized.verifySecureStorage(secrets) recovery = initialized.status().recovery?.toFacade() initialized.shell() } catch (error: MobilePrivateStorageFfiException.Failed) { diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 6ffec98..a4332e8 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -22,6 +22,9 @@ cargoNdk = "4.1.2" jna = "5.19.1" annotation = "1.11.0" +biometric = "1.1.0" +fragment = "1.9.1" + [libraries] compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose" } compose-ui = { module = "androidx.compose.ui:ui" } @@ -39,11 +42,15 @@ coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", ve hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } junit = { module = "junit:junit", version.ref = "junit" } +android-test-core = { module = "androidx.test:core", version.ref = "androidTest" } android-test-runner = { module = "androidx.test:runner", version.ref = "androidTest" } android-test-espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" } +biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } +fragment = { module = "androidx.fragment:fragment", version.ref = "fragment" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } diff --git a/android/gradle/verification-metadata.xml b/android/gradle/verification-metadata.xml index 6c35cf5..df050a9 100644 --- a/android/gradle/verification-metadata.xml +++ b/android/gradle/verification-metadata.xml @@ -5,6 +5,11 @@ false + + + + + @@ -26,6 +31,14 @@ + + + + + + + + @@ -63,11 +76,32 @@ + + + + + + + + + + + + + + + + + + + + + @@ -78,6 +112,11 @@ + + + + + @@ -88,6 +127,22 @@ + + + + + + + + + + + + + + + + @@ -112,6 +167,11 @@ + + + + + @@ -120,6 +180,27 @@ + + + + + + + + + + + + + + + + + + + + + @@ -128,6 +209,14 @@ + + + + + + + + @@ -144,16 +233,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -162,6 +285,27 @@ + + + + + + + + + + + + + + + + + + + + + @@ -170,6 +314,14 @@ + + + + + + + + @@ -464,6 +616,14 @@ + + + + + + + + @@ -480,11 +640,24 @@ + + + + + + + + + + + + + @@ -495,6 +668,14 @@ + + + + + + + + @@ -516,6 +697,19 @@ + + + + + + + + + + + + + @@ -524,6 +718,14 @@ + + + + + + + + @@ -556,6 +758,14 @@ + + + + + + + + @@ -574,6 +784,11 @@ + + + + + @@ -582,6 +797,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -598,16 +837,39 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -621,6 +883,14 @@ + + + + + + + + @@ -629,6 +899,14 @@ + + + + + + + + @@ -637,6 +915,22 @@ + + + + + + + + + + + + + + + + @@ -645,6 +939,27 @@ + + + + + + + + + + + + + + + + + + + + + @@ -653,6 +968,14 @@ + + + + + + + + @@ -661,16 +984,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -692,11 +1046,32 @@ + + + + + + + + + + + + + + + + + + + + + @@ -705,6 +1080,14 @@ + + + + + + + + @@ -713,16 +1096,37 @@ + + + + + + + + + + + + + + + + + + + + + @@ -744,6 +1148,14 @@ + + + + + + + + @@ -752,6 +1164,24 @@ + + + + + + + + + + + + + + + + + + @@ -762,6 +1192,22 @@ + + + + + + + + + + + + + + + + @@ -835,6 +1281,14 @@ + + + + + + + + @@ -843,12 +1297,20 @@ + + + + + + + + @@ -879,6 +1341,14 @@ + + + + + + + + @@ -994,6 +1464,9 @@ + + + @@ -1014,11 +1487,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3020,6 +3527,11 @@ + + + + + @@ -3097,6 +3609,11 @@ + + + + + @@ -3221,6 +3738,22 @@ + + + + + + + + + + + + + + + + @@ -3231,6 +3764,16 @@ + + + + + + + + + + @@ -3246,6 +3789,21 @@ + + + + + + + + + + + + + + + @@ -3267,6 +3825,14 @@ + + + + + + + + diff --git a/android/platform/build.gradle.kts b/android/platform/build.gradle.kts index afa4ff2..da0f1f1 100644 --- a/android/platform/build.gradle.kts +++ b/android/platform/build.gradle.kts @@ -12,11 +12,13 @@ android { .toInt() ndkVersion = libs.versions.ndk.get() defaultConfig { + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" minSdk = libs.versions.minSdk .get() .toInt() } + testOptions { targetSdk = libs.versions.compileSdk.get().toInt() } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 @@ -35,5 +37,9 @@ kotlin { dependencies { implementation(project(":core")) + implementation(libs.biometric) + implementation(libs.fragment) testImplementation(libs.junit) + androidTestImplementation(libs.android.test.runner) + androidTestImplementation(libs.android.test.core) } diff --git a/android/platform/gradle.lockfile b/android/platform/gradle.lockfile index 3039288..8d3de4b 100644 --- a/android/platform/gradle.lockfile +++ b/android/platform/gradle.lockfile @@ -2,8 +2,66 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. # To regenerate this file, run: ./gradlew :platform:dependencies --write-locks +androidx.activity:activity-ktx:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.activity:activity:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.annotation:annotation-experimental:1.4.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.annotation:annotation-jvm:1.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.annotation:annotation-jvm:1.9.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath androidx.annotation:annotation:1.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.annotation:annotation:1.9.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +androidx.appcompat:appcompat-resources:1.2.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.appcompat:appcompat:1.2.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.arch.core:core-common:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.arch.core:core-runtime:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.biometric:biometric:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.collection:collection-jvm:1.4.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +androidx.collection:collection-jvm:1.6.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.collection:collection:1.4.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +androidx.collection:collection:1.6.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.concurrent:concurrent-futures-ktx:1.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.concurrent:concurrent-futures:1.1.0=debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.concurrent:concurrent-futures:1.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.core:core-ktx:1.13.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.core:core-ktx:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +androidx.core:core-viewtree:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.core:core:1.13.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.core:core:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +androidx.cursoradapter:cursoradapter:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.customview:customview:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.drawerlayout:drawerlayout:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.fragment:fragment:1.9.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.interpolator:interpolator:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-common-jvm:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-common:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-livedata-core-ktx:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-livedata-core:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-livedata:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-android:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-ktx-android:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-ktx:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-android:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-savedstate:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.loader:loader:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.profileinstaller:profileinstaller:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.savedstate:savedstate-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.savedstate:savedstate-ktx:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.savedstate:savedstate:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.startup:startup-runtime:1.1.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.test.services:storage:1.6.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.test:core:1.7.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.test:monitor:1.8.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.test:runner:1.7.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.tracing:tracing-android:2.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.tracing:tracing:1.1.0=debugAndroidTestCompileClasspath +androidx.tracing:tracing:2.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.vectordrawable:vectordrawable-animated:1.1.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.vectordrawable:vectordrawable:1.1.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.versionedparcelable:versionedparcelable:1.1.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.viewpager:viewpager:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath ch.qos.logback:logback-classic:1.3.15=ktlint ch.qos.logback:logback-core:1.3.15=ktlint com.android.tools.analytics-library:protos:32.4.1=androidLintTool @@ -42,6 +100,7 @@ com.google.code.gson:gson:2.11.0=androidLintTool com.google.errorprone:error_prone_annotations:2.36.0=androidLintTool com.google.guava:failureaccess:1.0.2=androidLintTool com.google.guava:guava:33.4.0-jre=androidLintTool +com.google.guava:listenablefuture:1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=androidLintTool com.google.j2objc:j2objc-annotations:3.0.0=androidLintTool com.google.jimfs:jimfs:1.1=androidLintTool @@ -75,7 +134,7 @@ io.github.oshai:kotlin-logging:5.1.0=ktlint,ktlintBaselineReporter,ktlintReporte jakarta.activation:jakarta.activation-api:1.2.1=androidLintTool jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=androidLintTool javax.inject:javax.inject:1=androidLintTool -junit:junit:4.13.2=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +junit:junit:4.13.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath net.java.dev.jna:jna-platform:5.6.0=androidLintTool net.java.dev.jna:jna:5.19.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath net.java.dev.jna:jna:5.6.0=androidLintTool @@ -94,7 +153,7 @@ org.checkerframework:checker-qual:3.43.0=androidLintTool org.ec4j.core:ec4j-core:1.1.1=ktlint,ktlintBaselineReporter,ktlintRuleset org.glassfish.jaxb:jaxb-runtime:2.3.2=androidLintTool org.glassfish.jaxb:txw2:2.3.2=androidLintTool -org.hamcrest:hamcrest-core:1.3=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.hamcrest:hamcrest-core:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath org.jetbrains.kotlin:kotlin-build-tools-api:2.4.0=kotlinAbiValidationCompatClasspath org.jetbrains.kotlin:kotlin-build-tools-api:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-build-tools-compat:2.4.21=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath @@ -128,19 +187,24 @@ org.jetbrains.kotlin:kotlin-stdlib:2.4.0=kotlinAbiValidationCompatClasspath org.jetbrains.kotlin:kotlin-stdlib:2.4.21=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlin:kotlin-tooling-core:2.4.21=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=androidLintTool,unified-test-platform-gradle-work-action +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=androidLintTool,debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath,unified-test-platform-gradle-work-action org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,ktlint,ktlintBaselineReporter,ktlintRuleset -org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0=androidLintTool,unified-test-platform-gradle-work-action +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0=androidLintTool,debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath,unified-test-platform-gradle-work-action org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0=androidLintTool,unified-test-platform-gradle-work-action +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0=androidLintTool,debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath,unified-test-platform-gradle-work-action +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.4.1=ktlintReporter +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlinx:kotlinx-serialization-core:1.4.1=ktlintReporter +org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.4.1=ktlintReporter org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1=ktlintReporter -org.jetbrains:annotations:13.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,ktlint,ktlintBaselineReporter,ktlintReporter,ktlintRuleset,releaseCompileClasspath -org.jetbrains:annotations:23.0.0=androidLintTool,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-gradle-work-action +org.jetbrains:annotations:13.0=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,ktlint,ktlintBaselineReporter,ktlintReporter,ktlintRuleset +org.jetbrains:annotations:23.0.0=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-gradle-work-action +org.jspecify:jspecify:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.junit.platform:junit-platform-commons:1.13.3=unified-test-platform-gradle-work-action org.junit.platform:junit-platform-engine:1.13.3=unified-test-platform-gradle-work-action org.junit.platform:junit-platform-launcher:1.13.3=unified-test-platform-gradle-work-action diff --git a/android/platform/src/androidTest/AndroidManifest.xml b/android/platform/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..0c57fdc --- /dev/null +++ b/android/platform/src/androidTest/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/platform/src/androidTest/kotlin/de/rfc1437/ironstorage/platform/AndroidKeystoreTest.kt b/android/platform/src/androidTest/kotlin/de/rfc1437/ironstorage/platform/AndroidKeystoreTest.kt new file mode 100644 index 0000000..7620e4f --- /dev/null +++ b/android/platform/src/androidTest/kotlin/de/rfc1437/ironstorage/platform/AndroidKeystoreTest.kt @@ -0,0 +1,359 @@ +package de.rfc1437.ironstorage.platform + +import android.security.keystore.KeyInfo +import androidx.test.core.app.ActivityScenario +import androidx.test.filters.SdkSuppress +import androidx.test.platform.app.InstrumentationRegistry +import de.rfc1437.ironstorage.core.KeystoreAuthorization +import de.rfc1437.ironstorage.core.KeystoreException +import de.rfc1437.ironstorage.core.KeystoreFailure +import de.rfc1437.ironstorage.core.KeystoreKeySpecification +import java.security.KeyStore +import java.util.UUID +import javax.crypto.SecretKey +import javax.crypto.SecretKeyFactory +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class AndroidKeystoreTest { + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + @Before fun removeAbandonedFixtureKeys() { + val os = AndroidKeystoreAdapter(context) + os.aliases().filter { + it.startsWith("ironstorage.issue93.fixture.") + }.forEach { os.deleteKey(it) } + } + private fun alias(): String = "ironstorage.issue93.fixture.${UUID.randomUUID()}" + private fun specification( + alias: String, + auth: KeystoreAuthorization = KeystoreAuthorization.None + ) = KeystoreKeySpecification( + alias, + auth, + true, + auth == KeystoreAuthorization.StrongBiometric, + false + ) + private fun failure(expected: KeystoreFailure, block: () -> Unit) { + try { + block() + throw AssertionError("Expected typed failure") + } catch ( + error: KeystoreException + ) { + assertEquals(expected, error.kind) + assertEquals("Keystore operation failed", error.message) + } + } + + @Test fun deviceUnlockedRoundtripReplaceDeleteAndTamper() { + val os = AndroidKeystoreAdapter(context) + assertFalse(os.deviceState().locked) + val alias = alias() + try { + os.createKey(specification(alias)) + failure(KeystoreFailure.AlreadyExists) { os.createKey(specification(alias)) } + val clear = "public-test-token".toByteArray() + val encrypted = os.encrypt(alias, KeystoreAuthorization.None, byteArrayOf(1, 2), clear) + assertTrue(clear.all { it == 0.toByte() }) + assertEquals(12, encrypted.nonce.size) + val retrieved = os.decrypt( + alias, + KeystoreAuthorization.None, + byteArrayOf(1, 2), + encrypted + ) + try { + assertArrayEquals("public-test-token".toByteArray(), retrieved) + } finally { + retrieved.fill(0) + } + failure(KeystoreFailure.Corrupted) { + os.decrypt(alias, KeystoreAuthorization.None, byteArrayOf(3), encrypted) + } + encrypted.bytes[encrypted.bytes.lastIndex] = + (encrypted.bytes.last().toInt() xor 1).toByte() + failure(KeystoreFailure.Corrupted) { + os.decrypt(alias, KeystoreAuthorization.None, byteArrayOf(1, 2), encrypted) + } + val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + val key = store.getKey(alias, null) as SecretKey + val info = SecretKeyFactory.getInstance( + key.algorithm, + "AndroidKeyStore" + ).getKeySpec(key, KeyInfo::class.java) as KeyInfo + assertEquals(256, info.keySize) + assertFalse(info.isUserAuthenticationRequired) + assertTrue(os.aliases().contains(alias)) + os.deleteKey(alias) + failure(KeystoreFailure.MissingKey) { + os.decrypt(alias, KeystoreAuthorization.None, byteArrayOf(1, 2), encrypted) + } + os.deleteKey(alias) + } finally { + os.deleteKey(alias) + } + } + + @Test fun biometricRoundtripAndCancellation() { + val os = AndroidKeystoreAdapter(context) + assertTrue( + "Enroll synthetic fingerprint before this test", + os.deviceState().strongBiometric + ) + val alias = alias() + ActivityScenario.launch(KeystoreTestActivity::class.java).use { scenario -> + lateinit var host: KeystoreTestActivity + scenario.onActivity { + host = it + os.attach(it) + } + try { + os.createKey(specification(alias, KeystoreAuthorization.StrongBiometric)) + val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + val key = store.getKey(alias, null) as SecretKey + val info = SecretKeyFactory.getInstance( + key.algorithm, + "AndroidKeyStore" + ).getKeySpec(key, KeyInfo::class.java) as KeyInfo + assertTrue(info.isUserAuthenticationRequired) + assertTrue(info.isInvalidatedByBiometricEnrollment) + assertEquals(0, info.userAuthenticationValidityDurationSeconds) + host.stage("93:biometric-encrypt") + val encrypted = os.encrypt( + alias, + KeystoreAuthorization.StrongBiometric, + byteArrayOf(4), + "public-passphrase".toByteArray() + ) + host.stage("93:biometric-decrypt") + val clear = os.decrypt( + alias, + KeystoreAuthorization.StrongBiometric, + byteArrayOf(4), + encrypted + ) + try { + assertArrayEquals("public-passphrase".toByteArray(), clear) + } finally { + clear.fill(0) + } + host.stage("93:biometric-cancel") + failure(KeystoreFailure.Cancelled) { + os.decrypt( + alias, + KeystoreAuthorization.StrongBiometric, + byteArrayOf(4), + encrypted + ) + } + host.stage("93:complete") + } catch (error: KeystoreException) { + throw AssertionError("Typed Keystore failure: ${error.kind}", error) + } finally { + os.cancelPending() + os.detach(host) + os.deleteKey(alias) + } + } + } + + @Test fun userPresenceSupportsTheConfiguredCredentialFlow() { + val os = AndroidKeystoreAdapter(context) + val alias = alias() + ActivityScenario.launch(KeystoreTestActivity::class.java).use { scenario -> + lateinit var host: KeystoreTestActivity + scenario.onActivity { + host = it + os.attach(it) + } + try { + os.createKey(specification(alias, KeystoreAuthorization.UserPresence)) + host.stage("93:presence-encrypt") + val encrypted = os.encrypt( + alias, + KeystoreAuthorization.UserPresence, + byteArrayOf(7), + "public-passphrase".toByteArray() + ) + host.stage("93:presence-decrypt") + val clear = os.decrypt( + alias, + KeystoreAuthorization.UserPresence, + byteArrayOf(7), + encrypted + ) + try { + assertArrayEquals("public-passphrase".toByteArray(), clear) + } finally { + clear.fill(0) + } + } catch (error: KeystoreException) { + throw AssertionError("Typed Keystore failure: ${error.kind}", error) + } finally { + os.cancelPending() + os.detach(host) + os.deleteKey(alias) + } + } + } + + @Test + @SdkSuppress(maxSdkVersion = 36) + fun biometricLockoutIsTyped() { + val os = AndroidKeystoreAdapter(context) + val alias = alias() + ActivityScenario.launch(KeystoreTestActivity::class.java).use { scenario -> + lateinit var host: KeystoreTestActivity + scenario.onActivity { + host = it + os.attach(it) + } + try { + os.createKey(specification(alias, KeystoreAuthorization.StrongBiometric)) + host.stage("93:biometric-lockout") + failure(KeystoreFailure.Lockout) { + os.encrypt( + alias, + KeystoreAuthorization.StrongBiometric, + byteArrayOf(8), + "public-passphrase".toByteArray() + ) + } + } finally { + os.cancelPending() + os.detach(host) + os.deleteKey(alias) + } + } + } + + @Test + @SdkSuppress(minSdkVersion = 37) + fun biometricLockoutRecoveryStillRequiresMatchingBiometric() { + val os = AndroidKeystoreAdapter(context) + val alias = alias() + ActivityScenario.launch(KeystoreTestActivity::class.java).use { scenario -> + lateinit var host: KeystoreTestActivity + scenario.onActivity { + host = it + os.attach(it) + } + try { + os.createKey(specification(alias, KeystoreAuthorization.StrongBiometric)) + host.stage("93:biometric-lockout-recovery") + failure(KeystoreFailure.Cancelled) { + os.encrypt( + alias, + KeystoreAuthorization.StrongBiometric, + byteArrayOf(8), + "public-passphrase".toByteArray() + ) + } + } finally { + os.cancelPending() + os.detach(host) + os.deleteKey(alias) + } + } + } + + @Test fun addingBiometricInvalidatesTheProtectedKey() = invalidation("93:add-enrollment") + + @Test fun removingAllBiometricsInvalidatesTheProtectedKey() = invalidation( + "93:remove-enrollment" + ) + + @Test fun removingSecureLockInvalidatesTheProtectedKey() = + invalidation("93:remove-lock", KeystoreFailure.MissingKey) + + private fun invalidation( + stage: String, + expected: KeystoreFailure = KeystoreFailure.Invalidated + ) { + val os = AndroidKeystoreAdapter(context) + assertTrue(os.deviceState().strongBiometric) + val alias = alias() + val credentialAlias = alias() + ActivityScenario.launch(KeystoreTestActivity::class.java).use { scenario -> + lateinit var host: KeystoreTestActivity + scenario.onActivity { + host = it + os.attach(it) + } + try { + os.createKey(specification(credentialAlias)) + val credential = os.encrypt( + credentialAlias, + KeystoreAuthorization.None, + byteArrayOf(10), + "public-token".toByteArray() + ) + os.createKey(specification(alias, KeystoreAuthorization.StrongBiometric)) + host.stage("93:biometric-encrypt") + val encrypted = os.encrypt( + alias, + KeystoreAuthorization.StrongBiometric, + byteArrayOf(9), + "public-passphrase".toByteArray() + ) + assertTrue( + "Complete the indicated emulator fixture change", + host.awaitStep(stage) + ) + if (expected == + KeystoreFailure.MissingKey + ) { + assertFalse(os.aliases().contains(alias)) + } + val token = os.decrypt( + credentialAlias, + KeystoreAuthorization.None, + byteArrayOf(10), + credential + ) + try { + assertArrayEquals("public-token".toByteArray(), token) + } finally { + token.fill(0) + } + failure(expected) { + os.decrypt( + alias, + KeystoreAuthorization.StrongBiometric, + byteArrayOf(9), + encrypted + ) + } + } catch (error: KeystoreException) { + throw AssertionError("Typed Keystore failure: ${error.kind}", error) + } finally { + os.cancelPending() + os.detach(host) + os.deleteKey(alias) + os.deleteKey(credentialAlias) + } + } + } + + @Test fun protectedKeyRequiresAnAuthenticationHost() { + val os = AndroidKeystoreAdapter(context) + assertTrue(os.deviceState().strongBiometric) + val alias = alias() + try { + os.createKey(specification(alias, KeystoreAuthorization.StrongBiometric)) + val value = "public-test-passphrase".toByteArray() + failure(KeystoreFailure.Unavailable) { + os.encrypt(alias, KeystoreAuthorization.StrongBiometric, byteArrayOf(1), value) + } + assertTrue(value.all { it == 0.toByte() }) + } finally { + os.deleteKey(alias) + } + } +} diff --git a/android/platform/src/androidTest/kotlin/de/rfc1437/ironstorage/platform/KeystoreTestActivity.kt b/android/platform/src/androidTest/kotlin/de/rfc1437/ironstorage/platform/KeystoreTestActivity.kt new file mode 100644 index 0000000..963f58b --- /dev/null +++ b/android/platform/src/androidTest/kotlin/de/rfc1437/ironstorage/platform/KeystoreTestActivity.kt @@ -0,0 +1,50 @@ +package de.rfc1437.ironstorage.platform + +import android.os.Bundle +import android.widget.Button +import android.widget.LinearLayout +import android.widget.TextView +import androidx.fragment.app.FragmentActivity +import de.rfc1437.ironstorage.platform.test.R +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** Instrumentation host, omitted from release builds; shows stages, never secrets. */ +class KeystoreTestActivity : FragmentActivity() { + private lateinit var label: TextView + private val step = AtomicReference() + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + label = TextView(this).apply { + setText(R.string.keystore_fixture_title) + textSize = 24f + } + val next = Button(this).apply { + setText(R.string.keystore_fixture_continue) + setOnClickListener { step.get()?.countDown() } + } + setContentView( + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(0, 80, 0, 0) + addView(label) + addView(next) + } + ) + } + fun awaitStep(value: String): Boolean { + val next = CountDownLatch(1) + step.set(next) + stage(value) + return try { + next.await(180, TimeUnit.SECONDS) + } finally { + step.set(null) + } + } + fun stage(value: String) { + android.util.Log.i("IronStorage93", value) + runOnUiThread { label.text = value } + } +} diff --git a/android/platform/src/androidTest/res/values/strings.xml b/android/platform/src/androidTest/res/values/strings.xml new file mode 100644 index 0000000..57192aa --- /dev/null +++ b/android/platform/src/androidTest/res/values/strings.xml @@ -0,0 +1,4 @@ + + Keystore fixture + Continue fixture + diff --git a/android/platform/src/main/AndroidManifest.xml b/android/platform/src/main/AndroidManifest.xml index cc947c5..51ed474 100644 --- a/android/platform/src/main/AndroidManifest.xml +++ b/android/platform/src/main/AndroidManifest.xml @@ -1 +1,3 @@ - + + + diff --git a/android/platform/src/main/kotlin/de/rfc1437/ironstorage/platform/AndroidKeystoreAdapter.kt b/android/platform/src/main/kotlin/de/rfc1437/ironstorage/platform/AndroidKeystoreAdapter.kt new file mode 100644 index 0000000..e75f2f5 --- /dev/null +++ b/android/platform/src/main/kotlin/de/rfc1437/ironstorage/platform/AndroidKeystoreAdapter.kt @@ -0,0 +1,306 @@ +package de.rfc1437.ironstorage.platform + +import android.app.KeyguardManager +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyPermanentlyInvalidatedException +import android.security.keystore.KeyProperties +import android.security.keystore.StrongBoxUnavailableException +import android.security.keystore.UserNotAuthenticatedException +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.fragment.app.FragmentActivity +import de.rfc1437.ironstorage.core.AndroidKeystore +import de.rfc1437.ironstorage.core.KeystoreAuthorization +import de.rfc1437.ironstorage.core.KeystoreCiphertext +import de.rfc1437.ironstorage.core.KeystoreDeviceState +import de.rfc1437.ironstorage.core.KeystoreException +import de.rfc1437.ironstorage.core.KeystoreFailure +import de.rfc1437.ironstorage.core.KeystoreKeySpecification +import java.lang.ref.WeakReference +import java.security.KeyStore +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import javax.crypto.AEADBadTagException +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** Android API mechanics only; ciphertext is returned to Rust for private persistence. */ +class AndroidKeystoreAdapter(context: Context) : AndroidKeystore { + private val context = context.applicationContext + private val keyguard = context.getSystemService(KeyguardManager::class.java) + private val handler = Handler(Looper.getMainLooper()) + private var activity = WeakReference(null) + private val pending = AtomicReference() + + fun attach(host: FragmentActivity) { + activity = WeakReference(host) + } + fun detach(host: FragmentActivity) { + if (activity.get() === host) { + cancelPending() + activity.clear() + } + } + override fun deviceState(): KeystoreDeviceState = KeystoreDeviceState( + Build.VERSION.SDK_INT, + keyguard.isDeviceLocked, + keyguard.isDeviceSecure, + BiometricManager.from( + context + ).canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == + BiometricManager.BIOMETRIC_SUCCESS, + Build.VERSION.SDK_INT >= 28 && + context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE) + ) + override fun createKey(specification: KeystoreKeySpecification) = operation { + val store = store() + if (store.containsAlias(specification.alias)) fail(KeystoreFailure.AlreadyExists) + val builder = KeyGenParameterSpec.Builder( + specification.alias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setKeySize(256) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setRandomizedEncryptionRequired(true) + if (specification.unlockedDevice) { + if (Build.VERSION.SDK_INT < 28) fail(KeystoreFailure.UnsupportedProtection) + builder.setUnlockedDeviceRequired(true) + } + if (specification.strongbox) { + if (Build.VERSION.SDK_INT < 28) fail(KeystoreFailure.StrongboxUnavailable) + builder.setIsStrongBoxBacked(true) + } + if (specification.authorization != KeystoreAuthorization.None) { + builder.setUserAuthenticationRequired(true) + if (Build.VERSION.SDK_INT >= 30) { + val authenticators = when (specification.authorization) { + KeystoreAuthorization.StrongBiometric -> KeyProperties.AUTH_BIOMETRIC_STRONG + KeystoreAuthorization.UserPresence -> + KeyProperties.AUTH_BIOMETRIC_STRONG or + KeyProperties.AUTH_DEVICE_CREDENTIAL + KeystoreAuthorization.None -> fail(KeystoreFailure.UnsupportedProtection) + } + builder.setUserAuthenticationParameters(0, authenticators) + } else { + if (specification.authorization != + KeystoreAuthorization.StrongBiometric + ) { + fail(KeystoreFailure.UnsupportedProtection) + } + @Suppress("DEPRECATION") + builder.setUserAuthenticationValidityDurationSeconds(-1) + } + builder.setInvalidatedByBiometricEnrollment(specification.invalidateOnEnrollment) + } + KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply { + init(builder.build()) + generateKey() + } + Unit + } + override fun encrypt( + alias: String, + authorization: KeystoreAuthorization, + aad: ByteArray, + value: ByteArray + ): KeystoreCiphertext = operation { + try { + requireUnlocked() + val cipher = cipher(alias, Cipher.ENCRYPT_MODE, null) + // Auth-bound providers can process AAD immediately. + authenticate(cipher, authorization) + cipher.updateAAD(aad) + val bytes = cipher.doFinal(value) + requireUnlocked() + KeystoreCiphertext(cipher.iv, bytes) + } finally { + value.fill(0) + } + } + override fun decrypt( + alias: String, + authorization: KeystoreAuthorization, + aad: ByteArray, + value: KeystoreCiphertext + ): ByteArray = operation { + requireUnlocked() + if (value.nonce.size != 12 || value.bytes.size < 17) fail(KeystoreFailure.Corrupted) + val cipher = cipher(alias, Cipher.DECRYPT_MODE, value.nonce) + authenticate(cipher, authorization) + cipher.updateAAD(aad) + val clear = cipher.doFinal(value.bytes) + try { + requireUnlocked() + clear + } catch (error: Exception) { + clear.fill(0) + throw error + } + } + override fun deleteKey(alias: String) = operation { store().deleteEntry(alias) } + override fun aliases(): List = operation { store().aliases().toList() } + override fun cancelPending() { + pending.getAndSet(null)?.let { request -> + request.error.set(KeystoreFailure.Cancelled) + request.done.countDown() + handler.post { + request.prompt?.cancelAuthentication() + request.prompt = null + } + } + } + private fun store(): KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + private fun cipher(alias: String, mode: Int, nonce: ByteArray?): Cipher { + val key = store().getKey(alias, null) as? SecretKey + ?: fail(KeystoreFailure.MissingKey) + return Cipher.getInstance("AES/GCM/NoPadding").apply { + if (nonce == null) { + init(mode, key) + } else { + init(mode, key, GCMParameterSpec(128, nonce)) + } + } + } + private fun requireUnlocked() { + if (keyguard.isDeviceLocked) fail(KeystoreFailure.Locked) + } + private class Pending { + val done = CountDownLatch(1) + val error = AtomicReference() + val authenticated = AtomicReference() + var prompt: BiometricPrompt? = null + } + private fun authenticate(cipher: Cipher, authorization: KeystoreAuthorization) { + if (authorization == KeystoreAuthorization.None) return + if (Looper.myLooper() == Looper.getMainLooper()) fail(KeystoreFailure.Unavailable) + val request = Pending() + if (!pending.compareAndSet(null, request)) fail(KeystoreFailure.Unavailable) + handler.post { + if (pending.get() !== request) return@post + val host = activity.get() + if (host == null || host.isFinishing || host.isDestroyed) { + request.error.set(KeystoreFailure.Unavailable) + request.done.countDown() + return@post + } + try { + val callback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded( + result: BiometricPrompt.AuthenticationResult + ) { + if (pending.get() === + request + ) { + request.authenticated.set(result.cryptoObject?.cipher) + } + request.done.countDown() + } + override fun onAuthenticationError(code: Int, message: CharSequence) { + request.error.set( + when (code) { + BiometricPrompt.ERROR_CANCELED, + BiometricPrompt.ERROR_USER_CANCELED, + BiometricPrompt.ERROR_NEGATIVE_BUTTON -> + KeystoreFailure.Cancelled + BiometricPrompt.ERROR_LOCKOUT, + BiometricPrompt.ERROR_LOCKOUT_PERMANENT -> + KeystoreFailure.Lockout + BiometricPrompt.ERROR_NO_BIOMETRICS, + BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL, + BiometricPrompt.ERROR_HW_NOT_PRESENT -> + KeystoreFailure.UnsupportedProtection + else -> KeystoreFailure.Denied + } + ) + request.done.countDown() + } + } + val prompt = BiometricPrompt(host, { handler.post(it) }, callback) + request.prompt = prompt + val information = BiometricPrompt.PromptInfo.Builder().setTitle( + context.getString(R.string.keystore_prompt_title) + ) + if (authorization == KeystoreAuthorization.StrongBiometric) { + information.setAllowedAuthenticators( + BiometricManager.Authenticators.BIOMETRIC_STRONG + ).setNegativeButtonText(context.getString(R.string.keystore_prompt_cancel)) + } else { + if (Build.VERSION.SDK_INT < 30) fail(KeystoreFailure.UnsupportedProtection) + information.setAllowedAuthenticators( + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.DEVICE_CREDENTIAL + ) + } + prompt.authenticate(information.build(), BiometricPrompt.CryptoObject(cipher)) + } catch (error: Exception) { + request.error.set( + if (error is KeystoreException) error.kind else KeystoreFailure.Unavailable + ) + request.done.countDown() + } + } + try { + if (!request.done.await(180, TimeUnit.SECONDS)) fail(KeystoreFailure.Cancelled) + request.error.get()?.let { fail(it) } + if (request.authenticated.get() !== cipher || + pending.get() !== request + ) { + fail(KeystoreFailure.Cancelled) + } + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + fail(KeystoreFailure.Cancelled) + } finally { + pending.compareAndSet(request, null) + val succeeded = request.authenticated.getAndSet(null) === cipher + handler.post { + if (!succeeded) request.prompt?.cancelAuthentication() + request.prompt = null + } + } + } +} +private fun fail(kind: KeystoreFailure): Nothing = throw KeystoreException(kind) +private inline fun operation(block: () -> T): T = try { + block() +} catch (error: Exception) { + if (error is KeystoreException) throw error + var cause: Throwable? = error + var kind = KeystoreFailure.Unavailable + repeat(8) { + val osError = cause + if (kind == KeystoreFailure.Unavailable && Build.VERSION.SDK_INT >= 33 && + osError is android.security.KeyStoreException + ) { + kind = when (osError.numericErrorCode) { + android.security.KeyStoreException.ERROR_USER_AUTHENTICATION_REQUIRED -> + KeystoreFailure.Locked + android.security.KeyStoreException.ERROR_KEY_DOES_NOT_EXIST -> + KeystoreFailure.MissingKey + android.security.KeyStoreException.ERROR_KEY_CORRUPTED -> KeystoreFailure.Corrupted + android.security.KeyStoreException.ERROR_PERMISSION_DENIED -> KeystoreFailure.Denied + android.security.KeyStoreException.ERROR_UNIMPLEMENTED -> + KeystoreFailure.UnsupportedProtection + else -> kind + } + } + when { + cause is KeyPermanentlyInvalidatedException -> kind = KeystoreFailure.Invalidated + cause is UserNotAuthenticatedException -> kind = KeystoreFailure.Locked + cause is AEADBadTagException -> kind = KeystoreFailure.Corrupted + Build.VERSION.SDK_INT >= 28 && cause is StrongBoxUnavailableException -> + kind = KeystoreFailure.StrongboxUnavailable + } + cause = cause?.cause + } + throw KeystoreException(kind) +} diff --git a/android/platform/src/main/res/values/strings.xml b/android/platform/src/main/res/values/strings.xml new file mode 100644 index 0000000..d90f38d --- /dev/null +++ b/android/platform/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + Unlock protected secret + Cancel + diff --git a/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/KeystoreContractTest.kt b/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/KeystoreContractTest.kt new file mode 100644 index 0000000..3a0c308 --- /dev/null +++ b/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/KeystoreContractTest.kt @@ -0,0 +1,119 @@ +package de.rfc1437.ironstorage.bridge + +import androidx.test.platform.app.InstrumentationRegistry +import de.rfc1437.ironstorage.bridge.generated.MobileAndroidAuthorization +import de.rfc1437.ironstorage.bridge.generated.MobileAndroidCiphertext +import de.rfc1437.ironstorage.bridge.generated.MobileAndroidDeviceState +import de.rfc1437.ironstorage.bridge.generated.MobileAndroidKeySpecification +import de.rfc1437.ironstorage.bridge.generated.MobileAndroidKeystore +import de.rfc1437.ironstorage.bridge.generated.MobileAndroidKeystoreException +import de.rfc1437.ironstorage.bridge.generated.MobilePrivateDirectories +import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageErrorKind +import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException +import de.rfc1437.ironstorage.bridge.generated.MobileSecretException +import de.rfc1437.ironstorage.bridge.generated.MobileSecretLocator +import de.rfc1437.ironstorage.bridge.generated.MobileSecretProtection +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class KeystoreContractTest { + @Test fun rustOwnsRecordsPoliciesAndForeignSecretLifetime() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + NativeRuntime(context).privateStorage( + MobilePrivateDirectories( + context.filesDir.canonicalPath, + context.noBackupFilesDir.canonicalPath, + context.cacheDir.canonicalPath + ) + ).use { runtime -> + val os = FixtureKeystore() + val backend = runtime.androidSecretBackend(os) + val locator = MobileSecretLocator.HttpsGitCredential("issue93-server", "fixture") + val protection = MobileSecretProtection.DEVICE_UNLOCKED + backend.unlock() + try { + backend.create(locator, protection, "public-token".toByteArray()) + assertThrows(MobileSecretException.AlreadyExists::class.java) { + backend.create(locator, protection, "duplicate".toByteArray()) + } + backend.replace(locator, protection, "replacement".toByteArray()) + val clear = backend.retrieve(locator, protection) + try { + assertArrayEquals("replacement".toByteArray(), clear) + assertTrue(os.returned.all { it == 0.toByte() }) + } finally { + clear.fill(0) + } + os.lockout = true + assertThrows(MobileSecretException.Lockout::class.java) { + backend.retrieve(locator, protection) + } + os.lockout = false + backend.lock() + assertThrows(MobileSecretException.Locked::class.java) { + backend.retrieve(locator, protection) + } + backend.unlock() + assertThrows(MobileSecretException.UnsupportedProtection::class.java) { + backend.create( + MobileSecretLocator.OpenPgpPassphrase("A".repeat(40)), + protection, + "public-passphrase".toByteArray() + ) + } + } finally { + backend.delete(locator, protection) + } + assertTrue(os.keys.isEmpty()) + os.corruptAliases = true + val error = assertThrows(MobilePrivateStorageFfiException.Failed::class.java) { + runtime.androidSecretBackend(os) + } + assertEquals(MobilePrivateStorageErrorKind.INVALID_SECURE_BINDING, error.kind) + } + } + + // Public fixture cipher only: production encryption is tested in the platform test APK. + private class FixtureKeystore : MobileAndroidKeystore { + val keys = mutableSetOf() + var returned = byteArrayOf() + var lockout = false + var corruptAliases = false + override fun deviceState() = MobileAndroidDeviceState(37u, false, true, true, false) + override fun createKey(specification: MobileAndroidKeySpecification) { + assertEquals(MobileAndroidAuthorization.NONE, specification.authorization) + keys.add(specification.alias) + } + override fun encrypt( + alias: String, + authorization: MobileAndroidAuthorization, + aad: ByteArray, + value: ByteArray + ): MobileAndroidCiphertext = try { + MobileAndroidCiphertext(ByteArray(12), value.copyOf(value.size + 16)) + } finally { + value.fill(0) + } + override fun decrypt( + alias: String, + authorization: MobileAndroidAuthorization, + aad: ByteArray, + value: MobileAndroidCiphertext + ): ByteArray { + if (lockout) throw MobileAndroidKeystoreException.Lockout() + returned = value.bytes.copyOf(value.bytes.size - 16) + return returned + } + override fun deleteKey(alias: String) { + keys.remove(alias) + } + override fun aliases(): List { + if (corruptAliases) throw MobileAndroidKeystoreException.Corrupted() + return keys.toList() + } + override fun cancelPending() = Unit + } +} diff --git a/apple/Generated/ironstorage_mobile.swift b/apple/Generated/ironstorage_mobile.swift index c3e3752..056188e 100644 --- a/apple/Generated/ironstorage_mobile.swift +++ b/apple/Generated/ironstorage_mobile.swift @@ -608,6 +608,433 @@ fileprivate struct FfiConverterData: FfiConverterRustBuffer { +public protocol MobileAndroidKeystore: AnyObject, Sendable { + + func deviceState() throws -> MobileAndroidDeviceState + + func createKey(specification: MobileAndroidKeySpecification) throws + + func encrypt(alias: String, authorization: MobileAndroidAuthorization, aad: Data, value: Data) throws -> MobileAndroidCiphertext + + func decrypt(alias: String, authorization: MobileAndroidAuthorization, aad: Data, value: MobileAndroidCiphertext) throws -> Data + + func deleteKey(alias: String) throws + + func aliases() throws -> [String] + + func cancelPending() + +} +open class MobileAndroidKeystoreImpl: MobileAndroidKeystore, @unchecked Sendable { + fileprivate let handle: UInt64 + + /// Used to instantiate a [FFIObject] without an actual handle, for fakes in tests, mostly. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public struct NoHandle { + public init() {} + } + + // TODO: We'd like this to be `private` but for Swifty reasons, + // we can't implement `FfiConverter` without making this `required` and we can't + // make it `required` without making it `public`. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + required public init(unsafeFromHandle handle: UInt64) { + self.handle = handle + } + + // This constructor can be used to instantiate a fake object. + // - Parameter noHandle: Placeholder value so we can have a constructor separate from the default empty one that may be implemented for classes extending [FFIObject]. + // + // - Warning: + // Any object instantiated with this constructor cannot be passed to an actual Rust-backed object. Since there isn't a backing handle the FFI lower functions will crash. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public init(noHandle: NoHandle) { + self.handle = 0 + } + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public func uniffiCloneHandle() -> UInt64 { + return try! rustCall { uniffi_ironstorage_mobile_fn_clone_mobileandroidkeystore(self.handle, $0) } + } + // No primary constructor declared for this class. + + deinit { + if handle == 0 { + // Mock objects have handle=0 don't try to free them + return + } + + try! rustCall { uniffi_ironstorage_mobile_fn_free_mobileandroidkeystore(handle, $0) } + } + + + + +open func deviceState()throws -> MobileAndroidDeviceState { + return try FfiConverterTypeMobileAndroidDeviceState_lift(try rustCallWithError(FfiConverterTypeMobileAndroidKeystoreError_lift) { + uniffiCallStatus in + uniffi_ironstorage_mobile_fn_method_mobileandroidkeystore_device_state( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + +open func createKey(specification: MobileAndroidKeySpecification)throws {try rustCallWithError(FfiConverterTypeMobileAndroidKeystoreError_lift) { + uniffiCallStatus in + uniffi_ironstorage_mobile_fn_method_mobileandroidkeystore_create_key( + self.uniffiCloneHandle(), + FfiConverterTypeMobileAndroidKeySpecification_lower(specification),uniffiCallStatus + ) +} +} + +open func encrypt(alias: String, authorization: MobileAndroidAuthorization, aad: Data, value: Data)throws -> MobileAndroidCiphertext { + return try FfiConverterTypeMobileAndroidCiphertext_lift(try rustCallWithError(FfiConverterTypeMobileAndroidKeystoreError_lift) { + uniffiCallStatus in + uniffi_ironstorage_mobile_fn_method_mobileandroidkeystore_encrypt( + self.uniffiCloneHandle(), + FfiConverterString.lower(alias), + FfiConverterTypeMobileAndroidAuthorization_lower(authorization), + FfiConverterData.lower(aad), + FfiConverterData.lower(value),uniffiCallStatus + ) +}) +} + +open func decrypt(alias: String, authorization: MobileAndroidAuthorization, aad: Data, value: MobileAndroidCiphertext)throws -> Data { + return try FfiConverterData.lift(try rustCallWithError(FfiConverterTypeMobileAndroidKeystoreError_lift) { + uniffiCallStatus in + uniffi_ironstorage_mobile_fn_method_mobileandroidkeystore_decrypt( + self.uniffiCloneHandle(), + FfiConverterString.lower(alias), + FfiConverterTypeMobileAndroidAuthorization_lower(authorization), + FfiConverterData.lower(aad), + FfiConverterTypeMobileAndroidCiphertext_lower(value),uniffiCallStatus + ) +}) +} + +open func deleteKey(alias: String)throws {try rustCallWithError(FfiConverterTypeMobileAndroidKeystoreError_lift) { + uniffiCallStatus in + uniffi_ironstorage_mobile_fn_method_mobileandroidkeystore_delete_key( + self.uniffiCloneHandle(), + FfiConverterString.lower(alias),uniffiCallStatus + ) +} +} + +open func aliases()throws -> [String] { + return try FfiConverterSequenceString.lift(try rustCallWithError(FfiConverterTypeMobileAndroidKeystoreError_lift) { + uniffiCallStatus in + uniffi_ironstorage_mobile_fn_method_mobileandroidkeystore_aliases( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + +open func cancelPending() {try! rustCall() { + uniffiCallStatus in + uniffi_ironstorage_mobile_fn_method_mobileandroidkeystore_cancel_pending( + self.uniffiCloneHandle(),uniffiCallStatus + ) +} +} + + + +} + + + +// Put the implementation in a struct so we don't pollute the top-level namespace +fileprivate struct UniffiCallbackInterfaceMobileAndroidKeystore { + + // Create the VTable using a series of closures. + // Swift automatically converts these into C callback functions. + // + // Store the vtable directly. + static let vtable: UniffiVTableCallbackInterfaceMobileAndroidKeystore = UniffiVTableCallbackInterfaceMobileAndroidKeystore( + uniffiFree: { (uniffiHandle: UInt64) -> () in + do { + try FfiConverterTypeMobileAndroidKeystore.handleMap.remove(handle: uniffiHandle) + } catch { + print("Uniffi callback interface MobileAndroidKeystore: handle missing in uniffiFree") + } + }, + uniffiClone: { (uniffiHandle: UInt64) -> UInt64 in + do { + return try FfiConverterTypeMobileAndroidKeystore.handleMap.clone(handle: uniffiHandle) + } catch { + fatalError("Uniffi callback interface MobileAndroidKeystore: handle missing in uniffiClone") + } + }, + deviceState: { ( + uniffiHandle: UInt64, + uniffiOutReturn: UnsafeMutablePointer, + uniffiCallStatus: UnsafeMutablePointer + ) in + let makeCall = { + () throws -> MobileAndroidDeviceState in + guard let uniffiObj = try? FfiConverterTypeMobileAndroidKeystore.handleMap.get(handle: uniffiHandle) else { + throw UniffiInternalError.unexpectedStaleHandle + } + return try uniffiObj.deviceState( + ) + } + + + let writeReturn = { uniffiOutReturn.pointee = FfiConverterTypeMobileAndroidDeviceState_lower($0) } + uniffiTraitInterfaceCallWithError( + callStatus: uniffiCallStatus, + makeCall: makeCall, + writeReturn: writeReturn, + lowerError: FfiConverterTypeMobileAndroidKeystoreError_lower + ) + }, + createKey: { ( + uniffiHandle: UInt64, + specification: RustBuffer, + uniffiOutReturn: UnsafeMutableRawPointer, + uniffiCallStatus: UnsafeMutablePointer + ) in + let makeCall = { + () throws -> () in + guard let uniffiObj = try? FfiConverterTypeMobileAndroidKeystore.handleMap.get(handle: uniffiHandle) else { + throw UniffiInternalError.unexpectedStaleHandle + } + return try uniffiObj.createKey( + specification: try FfiConverterTypeMobileAndroidKeySpecification_lift(specification) + ) + } + + + let writeReturn = { () } + uniffiTraitInterfaceCallWithError( + callStatus: uniffiCallStatus, + makeCall: makeCall, + writeReturn: writeReturn, + lowerError: FfiConverterTypeMobileAndroidKeystoreError_lower + ) + }, + encrypt: { ( + uniffiHandle: UInt64, + alias: RustBuffer, + authorization: RustBuffer, + aad: RustBuffer, + value: RustBuffer, + uniffiOutReturn: UnsafeMutablePointer, + uniffiCallStatus: UnsafeMutablePointer + ) in + let makeCall = { + () throws -> MobileAndroidCiphertext in + guard let uniffiObj = try? FfiConverterTypeMobileAndroidKeystore.handleMap.get(handle: uniffiHandle) else { + throw UniffiInternalError.unexpectedStaleHandle + } + return try uniffiObj.encrypt( + alias: try FfiConverterString.lift(alias), + authorization: try FfiConverterTypeMobileAndroidAuthorization_lift(authorization), + aad: try FfiConverterData.lift(aad), + value: try FfiConverterData.lift(value) + ) + } + + + let writeReturn = { uniffiOutReturn.pointee = FfiConverterTypeMobileAndroidCiphertext_lower($0) } + uniffiTraitInterfaceCallWithError( + callStatus: uniffiCallStatus, + makeCall: makeCall, + writeReturn: writeReturn, + lowerError: FfiConverterTypeMobileAndroidKeystoreError_lower + ) + }, + decrypt: { ( + uniffiHandle: UInt64, + alias: RustBuffer, + authorization: RustBuffer, + aad: RustBuffer, + value: RustBuffer, + uniffiOutReturn: UnsafeMutablePointer, + uniffiCallStatus: UnsafeMutablePointer + ) in + let makeCall = { + () throws -> Data in + guard let uniffiObj = try? FfiConverterTypeMobileAndroidKeystore.handleMap.get(handle: uniffiHandle) else { + throw UniffiInternalError.unexpectedStaleHandle + } + return try uniffiObj.decrypt( + alias: try FfiConverterString.lift(alias), + authorization: try FfiConverterTypeMobileAndroidAuthorization_lift(authorization), + aad: try FfiConverterData.lift(aad), + value: try FfiConverterTypeMobileAndroidCiphertext_lift(value) + ) + } + + + let writeReturn = { uniffiOutReturn.pointee = FfiConverterData.lower($0) } + uniffiTraitInterfaceCallWithError( + callStatus: uniffiCallStatus, + makeCall: makeCall, + writeReturn: writeReturn, + lowerError: FfiConverterTypeMobileAndroidKeystoreError_lower + ) + }, + deleteKey: { ( + uniffiHandle: UInt64, + alias: RustBuffer, + uniffiOutReturn: UnsafeMutableRawPointer, + uniffiCallStatus: UnsafeMutablePointer + ) in + let makeCall = { + () throws -> () in + guard let uniffiObj = try? FfiConverterTypeMobileAndroidKeystore.handleMap.get(handle: uniffiHandle) else { + throw UniffiInternalError.unexpectedStaleHandle + } + return try uniffiObj.deleteKey( + alias: try FfiConverterString.lift(alias) + ) + } + + + let writeReturn = { () } + uniffiTraitInterfaceCallWithError( + callStatus: uniffiCallStatus, + makeCall: makeCall, + writeReturn: writeReturn, + lowerError: FfiConverterTypeMobileAndroidKeystoreError_lower + ) + }, + aliases: { ( + uniffiHandle: UInt64, + uniffiOutReturn: UnsafeMutablePointer, + uniffiCallStatus: UnsafeMutablePointer + ) in + let makeCall = { + () throws -> [String] in + guard let uniffiObj = try? FfiConverterTypeMobileAndroidKeystore.handleMap.get(handle: uniffiHandle) else { + throw UniffiInternalError.unexpectedStaleHandle + } + return try uniffiObj.aliases( + ) + } + + + let writeReturn = { uniffiOutReturn.pointee = FfiConverterSequenceString.lower($0) } + uniffiTraitInterfaceCallWithError( + callStatus: uniffiCallStatus, + makeCall: makeCall, + writeReturn: writeReturn, + lowerError: FfiConverterTypeMobileAndroidKeystoreError_lower + ) + }, + cancelPending: { ( + uniffiHandle: UInt64, + uniffiOutReturn: UnsafeMutableRawPointer, + uniffiCallStatus: UnsafeMutablePointer + ) in + let makeCall = { + () throws -> () in + guard let uniffiObj = try? FfiConverterTypeMobileAndroidKeystore.handleMap.get(handle: uniffiHandle) else { + throw UniffiInternalError.unexpectedStaleHandle + } + return uniffiObj.cancelPending( + ) + } + + + let writeReturn = { () } + uniffiTraitInterfaceCall( + callStatus: uniffiCallStatus, + makeCall: makeCall, + writeReturn: writeReturn + ) + } + ) + + // Rust stores this pointer for future callback invocations, so it must live + // for the process lifetime (not just for the init function call). + // + // `nonisolated(unsafe)` is needed under Swift 6 strict concurrency. + // This is safe because the pointee is initialized once during static init + // and never mutated by either side of the FFI. Its fields are C function pointers. + nonisolated(unsafe) static let vtablePtr: UnsafePointer = { + let ptr = UnsafeMutablePointer.allocate(capacity: 1) + ptr.initialize(to: vtable) + return UnsafePointer(ptr) + }() +} + +private func uniffiCallbackInitMobileAndroidKeystore() { + uniffi_ironstorage_mobile_fn_init_callback_vtable_mobileandroidkeystore(UniffiCallbackInterfaceMobileAndroidKeystore.vtablePtr) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileAndroidKeystore: FfiConverter { + fileprivate static let handleMap = UniffiHandleMap() + + typealias FfiType = UInt64 + typealias SwiftType = MobileAndroidKeystore + + public static func lift(_ handle: UInt64) throws -> MobileAndroidKeystore { + if ((handle & 1) == 0) { + // Rust-generated handle, construct a new class that uses the handle to implement the + // interface + return MobileAndroidKeystoreImpl(unsafeFromHandle: handle) + } else { + // Swift-generated handle, get the object from the handle map + return try handleMap.remove(handle: handle) + } + } + + public static func lower(_ value: MobileAndroidKeystore) -> UInt64 { + if let rustImpl = value as? MobileAndroidKeystoreImpl { + // Rust-implemented object. Clone the handle and return it + return rustImpl.uniffiCloneHandle() + } else { + // Swift object, generate a new vtable handle and return that. + return handleMap.insert(obj: value) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileAndroidKeystore { + let handle: UInt64 = try readInt(&buf) + return try lift(handle) + } + + public static func write(_ value: MobileAndroidKeystore, into buf: inout [UInt8]) { + writeInt(&buf, lower(value)) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileAndroidKeystore_lift(_ handle: UInt64) throws -> MobileAndroidKeystore { + return try FfiConverterTypeMobileAndroidKeystore.lift(handle) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileAndroidKeystore_lower(_ value: MobileAndroidKeystore) -> UInt64 { + return FfiConverterTypeMobileAndroidKeystore.lower(value) +} + + + + + + public protocol MobileAuthenticationProtocol: AnyObject, Sendable { func acknowledgeWatchSnapshot(receipt: Data) throws -> MobileWatchSnapshotStatus @@ -2528,6 +2955,8 @@ public protocol MobilePrivateRuntimeProtocol: AnyObject, Sendable { func activate() throws + func androidSecretBackend(keystore: MobileAndroidKeystore) throws -> MobileSecretBackend + func authentication(backend: MobileSecretBackend, clock: MobileClock) throws -> MobileAuthentication func clearDisposableCache() throws @@ -2602,6 +3031,16 @@ open func activate()throws {try rustCallWithError(FfiConverterTypeMobilePrivat } } +open func androidSecretBackend(keystore: MobileAndroidKeystore)throws -> MobileSecretBackend { + return try FfiConverterTypeMobileSecretBackend_lift(try rustCallWithError(FfiConverterTypeMobilePrivateStorageFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_mobile_fn_method_mobileprivateruntime_android_secret_backend( + self.uniffiCloneHandle(), + FfiConverterTypeMobileAndroidKeystore_lower(keystore),uniffiCallStatus + ) +}) +} + open func authentication(backend: MobileSecretBackend, clock: MobileClock)throws -> MobileAuthentication { return try FfiConverterTypeMobileAuthentication_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in @@ -3230,6 +3669,192 @@ public func FfiConverterTypeMobileTotpOperation_lower(_ value: MobileTotpOperati +public struct MobileAndroidCiphertext: Equatable, Hashable { + public var nonce: Data + public var bytes: Data + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(nonce: Data, bytes: Data) { + self.nonce = nonce + self.bytes = bytes + } + + + + +} + +#if compiler(>=6) +extension MobileAndroidCiphertext: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileAndroidCiphertext: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileAndroidCiphertext { + return + try MobileAndroidCiphertext( + nonce: FfiConverterData.read(from: &buf), + bytes: FfiConverterData.read(from: &buf) + ) + } + + public static func write(_ value: MobileAndroidCiphertext, into buf: inout [UInt8]) { + FfiConverterData.write(value.nonce, into: &buf) + FfiConverterData.write(value.bytes, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileAndroidCiphertext_lift(_ buf: RustBuffer) throws -> MobileAndroidCiphertext { + return try FfiConverterTypeMobileAndroidCiphertext.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileAndroidCiphertext_lower(_ value: MobileAndroidCiphertext) -> RustBuffer { + return FfiConverterTypeMobileAndroidCiphertext.lower(value) +} + + +public struct MobileAndroidDeviceState: Equatable, Hashable { + public var api: UInt32 + public var locked: Bool + public var secure: Bool + public var strongBiometric: Bool + public var strongbox: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(api: UInt32, locked: Bool, secure: Bool, strongBiometric: Bool, strongbox: Bool) { + self.api = api + self.locked = locked + self.secure = secure + self.strongBiometric = strongBiometric + self.strongbox = strongbox + } + + + + +} + +#if compiler(>=6) +extension MobileAndroidDeviceState: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileAndroidDeviceState: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileAndroidDeviceState { + return + try MobileAndroidDeviceState( + api: FfiConverterUInt32.read(from: &buf), + locked: FfiConverterBool.read(from: &buf), + secure: FfiConverterBool.read(from: &buf), + strongBiometric: FfiConverterBool.read(from: &buf), + strongbox: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: MobileAndroidDeviceState, into buf: inout [UInt8]) { + FfiConverterUInt32.write(value.api, into: &buf) + FfiConverterBool.write(value.locked, into: &buf) + FfiConverterBool.write(value.secure, into: &buf) + FfiConverterBool.write(value.strongBiometric, into: &buf) + FfiConverterBool.write(value.strongbox, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileAndroidDeviceState_lift(_ buf: RustBuffer) throws -> MobileAndroidDeviceState { + return try FfiConverterTypeMobileAndroidDeviceState.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileAndroidDeviceState_lower(_ value: MobileAndroidDeviceState) -> RustBuffer { + return FfiConverterTypeMobileAndroidDeviceState.lower(value) +} + + +public struct MobileAndroidKeySpecification: Equatable, Hashable { + public var alias: String + public var authorization: MobileAndroidAuthorization + public var unlockedDevice: Bool + public var invalidateOnEnrollment: Bool + public var strongbox: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(alias: String, authorization: MobileAndroidAuthorization, unlockedDevice: Bool, invalidateOnEnrollment: Bool, strongbox: Bool) { + self.alias = alias + self.authorization = authorization + self.unlockedDevice = unlockedDevice + self.invalidateOnEnrollment = invalidateOnEnrollment + self.strongbox = strongbox + } + + + + +} + +#if compiler(>=6) +extension MobileAndroidKeySpecification: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileAndroidKeySpecification: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileAndroidKeySpecification { + return + try MobileAndroidKeySpecification( + alias: FfiConverterString.read(from: &buf), + authorization: FfiConverterTypeMobileAndroidAuthorization.read(from: &buf), + unlockedDevice: FfiConverterBool.read(from: &buf), + invalidateOnEnrollment: FfiConverterBool.read(from: &buf), + strongbox: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: MobileAndroidKeySpecification, into buf: inout [UInt8]) { + FfiConverterString.write(value.alias, into: &buf) + FfiConverterTypeMobileAndroidAuthorization.write(value.authorization, into: &buf) + FfiConverterBool.write(value.unlockedDevice, into: &buf) + FfiConverterBool.write(value.invalidateOnEnrollment, into: &buf) + FfiConverterBool.write(value.strongbox, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileAndroidKeySpecification_lift(_ buf: RustBuffer) throws -> MobileAndroidKeySpecification { + return try FfiConverterTypeMobileAndroidKeySpecification.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileAndroidKeySpecification_lower(_ value: MobileAndroidKeySpecification) -> RustBuffer { + return FfiConverterTypeMobileAndroidKeySpecification.lower(value) +} + + public struct MobileAuthenticationState: Equatable, Hashable { public var unlocked: Bool public var biometricUnlockEnabled: Bool @@ -6359,6 +6984,210 @@ public func FfiConverterTypeMobileWatchSnapshotTransfer_lower(_ value: MobileWat +public enum MobileAndroidAuthorization: Equatable, Hashable { + + case none + case strongBiometric + case userPresence + + + + + +} + +#if compiler(>=6) +extension MobileAndroidAuthorization: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileAndroidAuthorization: FfiConverterRustBuffer { + typealias SwiftType = MobileAndroidAuthorization + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileAndroidAuthorization { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .none + + case 2: return .strongBiometric + + case 3: return .userPresence + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: MobileAndroidAuthorization, into buf: inout [UInt8]) { + switch value { + + + case .none: + writeInt(&buf, Int32(1)) + + + case .strongBiometric: + writeInt(&buf, Int32(2)) + + + case .userPresence: + writeInt(&buf, Int32(3)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileAndroidAuthorization_lift(_ buf: RustBuffer) throws -> MobileAndroidAuthorization { + return try FfiConverterTypeMobileAndroidAuthorization.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileAndroidAuthorization_lower(_ value: MobileAndroidAuthorization) -> RustBuffer { + return FfiConverterTypeMobileAndroidAuthorization.lower(value) +} + + + +public +enum MobileAndroidKeystoreError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError { + + + + case MissingKey + case Invalidated + case Lockout + case Locked + case Denied + case Cancelled + case Corrupted + case UnsupportedProtection + case StrongboxUnavailable + case AlreadyExists + case Unavailable + + + + + + + public var errorDescription: String? { + String(reflecting: self) + } + +} + +#if compiler(>=6) +extension MobileAndroidKeystoreError: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileAndroidKeystoreError: FfiConverterRustBuffer { + typealias SwiftType = MobileAndroidKeystoreError + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileAndroidKeystoreError { + let variant: Int32 = try readInt(&buf) + switch variant { + + + + + case 1: return .MissingKey + case 2: return .Invalidated + case 3: return .Lockout + case 4: return .Locked + case 5: return .Denied + case 6: return .Cancelled + case 7: return .Corrupted + case 8: return .UnsupportedProtection + case 9: return .StrongboxUnavailable + case 10: return .AlreadyExists + case 11: return .Unavailable + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: MobileAndroidKeystoreError, into buf: inout [UInt8]) { + switch value { + + + + + + case .MissingKey: + writeInt(&buf, Int32(1)) + + + case .Invalidated: + writeInt(&buf, Int32(2)) + + + case .Lockout: + writeInt(&buf, Int32(3)) + + + case .Locked: + writeInt(&buf, Int32(4)) + + + case .Denied: + writeInt(&buf, Int32(5)) + + + case .Cancelled: + writeInt(&buf, Int32(6)) + + + case .Corrupted: + writeInt(&buf, Int32(7)) + + + case .UnsupportedProtection: + writeInt(&buf, Int32(8)) + + + case .StrongboxUnavailable: + writeInt(&buf, Int32(9)) + + + case .AlreadyExists: + writeInt(&buf, Int32(10)) + + + case .Unavailable: + writeInt(&buf, Int32(11)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileAndroidKeystoreError_lift(_ buf: RustBuffer) throws -> MobileAndroidKeystoreError { + return try FfiConverterTypeMobileAndroidKeystoreError.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileAndroidKeystoreError_lower(_ value: MobileAndroidKeystoreError) -> RustBuffer { + return FfiConverterTypeMobileAndroidKeystoreError.lower(value) +} + + + public enum MobileAppearance: Equatable, Hashable { case system @@ -9165,6 +9994,9 @@ enum MobileSecretError: Swift.Error, Equatable, Hashable, Foundation.LocalizedEr case Corrupted case UnsupportedProtection case Unavailable + case DeviceKeyMissing + case Invalidated + case Lockout @@ -9202,6 +10034,9 @@ public struct FfiConverterTypeMobileSecretError: FfiConverterRustBuffer { case 6: return .Corrupted case 7: return .UnsupportedProtection case 8: return .Unavailable + case 9: return .DeviceKeyMissing + case 10: return .Invalidated + case 11: return .Lockout default: throw UniffiInternalError.unexpectedEnumCase } @@ -9245,6 +10080,18 @@ public struct FfiConverterTypeMobileSecretError: FfiConverterRustBuffer { case .Unavailable: writeInt(&buf, Int32(8)) + + case .DeviceKeyMissing: + writeInt(&buf, Int32(9)) + + + case .Invalidated: + writeInt(&buf, Int32(10)) + + + case .Lockout: + writeInt(&buf, Int32(11)) + } } } @@ -10974,6 +11821,27 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_mobile_checksum_method_mobiletotpoperation_progress() != 30340) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_mobile_checksum_method_mobileandroidkeystore_device_state() != 19346) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ironstorage_mobile_checksum_method_mobileandroidkeystore_create_key() != 7937) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ironstorage_mobile_checksum_method_mobileandroidkeystore_encrypt() != 65449) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ironstorage_mobile_checksum_method_mobileandroidkeystore_decrypt() != 39727) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ironstorage_mobile_checksum_method_mobileandroidkeystore_delete_key() != 20162) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ironstorage_mobile_checksum_method_mobileandroidkeystore_aliases() != 16519) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ironstorage_mobile_checksum_method_mobileandroidkeystore_cancel_pending() != 39774) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_mobile_checksum_method_mobileclipboard_read() != 47092) { return InitializationResult.apiChecksumMismatch } @@ -11010,6 +11878,9 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_mobile_checksum_method_mobileprivateruntime_activate() != 64835) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_mobile_checksum_method_mobileprivateruntime_android_secret_backend() != 24373) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_mobile_checksum_method_mobileprivateruntime_authentication() != 21237) { return InitializationResult.apiChecksumMismatch } @@ -11029,6 +11900,7 @@ private let initializationResult: InitializationResult = { return InitializationResult.apiChecksumMismatch } + uniffiCallbackInitMobileAndroidKeystore() uniffiCallbackInitMobileClipboard() uniffiCallbackInitMobileClock() uniffiCallbackInitMobileSecretBackend() @@ -11050,6 +11922,22 @@ public func uniffiEnsureIronstorageMobileInitialized() { // swiftlint:enable all // IronStorage diagnostic redaction; wire values and checksums remain generated. +extension MobileAndroidCiphertext: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { "MobileAndroidCiphertext([REDACTED])" } + public var debugDescription: String { description } +} +extension MobileAndroidDeviceState: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { "MobileAndroidDeviceState([REDACTED])" } + public var debugDescription: String { description } +} +extension MobileAndroidKeySpecification: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { "MobileAndroidKeySpecification([REDACTED])" } + public var debugDescription: String { description } +} +extension MobileAndroidKeystoreError: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { "MobileAndroidKeystoreError([REDACTED])" } + public var debugDescription: String { description } +} extension MobileAuthenticationFfiError: CustomStringConvertible, CustomDebugStringConvertible { public var description: String { "MobileAuthenticationFfiError([REDACTED])" } public var debugDescription: String { description } diff --git a/apple/Generated/ironstorage_mobileFFI.h b/apple/Generated/ironstorage_mobileFFI.h index e543ff7..943b23e 100644 --- a/apple/Generated/ironstorage_mobileFFI.h +++ b/apple/Generated/ironstorage_mobileFFI.h @@ -242,6 +242,55 @@ typedef struct UniffiForeignFutureResultVoid { typedef void (*UniffiForeignFutureCompleteVoid)(uint64_t, UniffiForeignFutureResultVoid ); +#endif +#ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_MOBILE_ANDROID_KEYSTORE_METHOD0 +#define UNIFFI_FFIDEF_CALLBACK_INTERFACE_MOBILE_ANDROID_KEYSTORE_METHOD0 +typedef void (*UniffiCallbackInterfaceMobileAndroidKeystoreMethod0)(uint64_t, RustBuffer* _Nonnull, + RustCallStatus *_Nonnull uniffiCallStatus + ); + +#endif +#ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_MOBILE_ANDROID_KEYSTORE_METHOD1 +#define UNIFFI_FFIDEF_CALLBACK_INTERFACE_MOBILE_ANDROID_KEYSTORE_METHOD1 +typedef void (*UniffiCallbackInterfaceMobileAndroidKeystoreMethod1)(uint64_t, RustBuffer, void* _Nonnull, + RustCallStatus *_Nonnull uniffiCallStatus + ); + +#endif +#ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_MOBILE_ANDROID_KEYSTORE_METHOD2 +#define UNIFFI_FFIDEF_CALLBACK_INTERFACE_MOBILE_ANDROID_KEYSTORE_METHOD2 +typedef void (*UniffiCallbackInterfaceMobileAndroidKeystoreMethod2)(uint64_t, RustBuffer, RustBuffer, RustBuffer, RustBuffer, RustBuffer* _Nonnull, + RustCallStatus *_Nonnull uniffiCallStatus + ); + +#endif +#ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_MOBILE_ANDROID_KEYSTORE_METHOD3 +#define UNIFFI_FFIDEF_CALLBACK_INTERFACE_MOBILE_ANDROID_KEYSTORE_METHOD3 +typedef void (*UniffiCallbackInterfaceMobileAndroidKeystoreMethod3)(uint64_t, RustBuffer, RustBuffer, RustBuffer, RustBuffer, RustBuffer* _Nonnull, + RustCallStatus *_Nonnull uniffiCallStatus + ); + +#endif +#ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_MOBILE_ANDROID_KEYSTORE_METHOD4 +#define UNIFFI_FFIDEF_CALLBACK_INTERFACE_MOBILE_ANDROID_KEYSTORE_METHOD4 +typedef void (*UniffiCallbackInterfaceMobileAndroidKeystoreMethod4)(uint64_t, RustBuffer, void* _Nonnull, + RustCallStatus *_Nonnull uniffiCallStatus + ); + +#endif +#ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_MOBILE_ANDROID_KEYSTORE_METHOD5 +#define UNIFFI_FFIDEF_CALLBACK_INTERFACE_MOBILE_ANDROID_KEYSTORE_METHOD5 +typedef void (*UniffiCallbackInterfaceMobileAndroidKeystoreMethod5)(uint64_t, RustBuffer* _Nonnull, + RustCallStatus *_Nonnull uniffiCallStatus + ); + +#endif +#ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_MOBILE_ANDROID_KEYSTORE_METHOD6 +#define UNIFFI_FFIDEF_CALLBACK_INTERFACE_MOBILE_ANDROID_KEYSTORE_METHOD6 +typedef void (*UniffiCallbackInterfaceMobileAndroidKeystoreMethod6)(uint64_t, void* _Nonnull, + RustCallStatus *_Nonnull uniffiCallStatus + ); + #endif #ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_MOBILE_CLIPBOARD_METHOD0 #define UNIFFI_FFIDEF_CALLBACK_INTERFACE_MOBILE_CLIPBOARD_METHOD0 @@ -319,6 +368,21 @@ typedef void (*UniffiCallbackInterfaceMobileSecretBackendMethod5)(uint64_t, void RustCallStatus *_Nonnull uniffiCallStatus ); +#endif +#ifndef UNIFFI_FFIDEF_V_TABLE_CALLBACK_INTERFACE_MOBILE_ANDROID_KEYSTORE +#define UNIFFI_FFIDEF_V_TABLE_CALLBACK_INTERFACE_MOBILE_ANDROID_KEYSTORE +typedef struct UniffiVTableCallbackInterfaceMobileAndroidKeystore { + UniffiCallbackInterfaceFree _Nonnull uniffiFree; + UniffiCallbackInterfaceClone _Nonnull uniffiClone; + UniffiCallbackInterfaceMobileAndroidKeystoreMethod0 _Nonnull deviceState; + UniffiCallbackInterfaceMobileAndroidKeystoreMethod1 _Nonnull createKey; + UniffiCallbackInterfaceMobileAndroidKeystoreMethod2 _Nonnull encrypt; + UniffiCallbackInterfaceMobileAndroidKeystoreMethod3 _Nonnull decrypt; + UniffiCallbackInterfaceMobileAndroidKeystoreMethod4 _Nonnull deleteKey; + UniffiCallbackInterfaceMobileAndroidKeystoreMethod5 _Nonnull aliases; + UniffiCallbackInterfaceMobileAndroidKeystoreMethod6 _Nonnull cancelPending; +} UniffiVTableCallbackInterfaceMobileAndroidKeystore; + #endif #ifndef UNIFFI_FFIDEF_V_TABLE_CALLBACK_INTERFACE_MOBILE_CLIPBOARD #define UNIFFI_FFIDEF_V_TABLE_CALLBACK_INTERFACE_MOBILE_CLIPBOARD @@ -760,6 +824,56 @@ void uniffi_ironstorage_mobile_fn_method_mobiletotpoperation_cancel(uint64_t ptr RustBuffer uniffi_ironstorage_mobile_fn_method_mobiletotpoperation_progress(uint64_t ptr, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_CLONE_MOBILEANDROIDKEYSTORE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_CLONE_MOBILEANDROIDKEYSTORE +uint64_t uniffi_ironstorage_mobile_fn_clone_mobileandroidkeystore(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_FREE_MOBILEANDROIDKEYSTORE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_FREE_MOBILEANDROIDKEYSTORE +void uniffi_ironstorage_mobile_fn_free_mobileandroidkeystore(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_INIT_CALLBACK_VTABLE_MOBILEANDROIDKEYSTORE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_INIT_CALLBACK_VTABLE_MOBILEANDROIDKEYSTORE +void uniffi_ironstorage_mobile_fn_init_callback_vtable_mobileandroidkeystore(const UniffiVTableCallbackInterfaceMobileAndroidKeystore* _Nonnull vtable +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEANDROIDKEYSTORE_DEVICE_STATE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEANDROIDKEYSTORE_DEVICE_STATE +RustBuffer uniffi_ironstorage_mobile_fn_method_mobileandroidkeystore_device_state(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEANDROIDKEYSTORE_CREATE_KEY +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEANDROIDKEYSTORE_CREATE_KEY +void uniffi_ironstorage_mobile_fn_method_mobileandroidkeystore_create_key(uint64_t ptr, RustBuffer specification, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEANDROIDKEYSTORE_ENCRYPT +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEANDROIDKEYSTORE_ENCRYPT +RustBuffer uniffi_ironstorage_mobile_fn_method_mobileandroidkeystore_encrypt(uint64_t ptr, RustBuffer alias, RustBuffer authorization, RustBuffer aad, RustBuffer value, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEANDROIDKEYSTORE_DECRYPT +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEANDROIDKEYSTORE_DECRYPT +RustBuffer uniffi_ironstorage_mobile_fn_method_mobileandroidkeystore_decrypt(uint64_t ptr, RustBuffer alias, RustBuffer authorization, RustBuffer aad, RustBuffer value, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEANDROIDKEYSTORE_DELETE_KEY +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEANDROIDKEYSTORE_DELETE_KEY +void uniffi_ironstorage_mobile_fn_method_mobileandroidkeystore_delete_key(uint64_t ptr, RustBuffer alias, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEANDROIDKEYSTORE_ALIASES +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEANDROIDKEYSTORE_ALIASES +RustBuffer uniffi_ironstorage_mobile_fn_method_mobileandroidkeystore_aliases(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEANDROIDKEYSTORE_CANCEL_PENDING +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEANDROIDKEYSTORE_CANCEL_PENDING +void uniffi_ironstorage_mobile_fn_method_mobileandroidkeystore_cancel_pending(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_CLONE_MOBILECLIPBOARD #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_CLONE_MOBILECLIPBOARD uint64_t uniffi_ironstorage_mobile_fn_clone_mobileclipboard(uint64_t handle, RustCallStatus *_Nonnull out_status @@ -875,6 +989,11 @@ void uniffi_ironstorage_mobile_fn_free_mobileprivateruntime(uint64_t handle, Rus void uniffi_ironstorage_mobile_fn_method_mobileprivateruntime_activate(uint64_t ptr, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEPRIVATERUNTIME_ANDROID_SECRET_BACKEND +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEPRIVATERUNTIME_ANDROID_SECRET_BACKEND +uint64_t uniffi_ironstorage_mobile_fn_method_mobileprivateruntime_android_secret_backend(uint64_t ptr, uint64_t keystore, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEPRIVATERUNTIME_AUTHENTICATION #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEPRIVATERUNTIME_AUTHENTICATION uint64_t uniffi_ironstorage_mobile_fn_method_mobileprivateruntime_authentication(uint64_t ptr, uint64_t backend, uint64_t clock, RustCallStatus *_Nonnull out_status @@ -1803,6 +1922,48 @@ uint16_t uniffi_ironstorage_mobile_checksum_method_mobiletotpoperation_cancel(vo #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILETOTPOPERATION_PROGRESS uint16_t uniffi_ironstorage_mobile_checksum_method_mobiletotpoperation_progress(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEANDROIDKEYSTORE_DEVICE_STATE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEANDROIDKEYSTORE_DEVICE_STATE +uint16_t uniffi_ironstorage_mobile_checksum_method_mobileandroidkeystore_device_state(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEANDROIDKEYSTORE_CREATE_KEY +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEANDROIDKEYSTORE_CREATE_KEY +uint16_t uniffi_ironstorage_mobile_checksum_method_mobileandroidkeystore_create_key(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEANDROIDKEYSTORE_ENCRYPT +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEANDROIDKEYSTORE_ENCRYPT +uint16_t uniffi_ironstorage_mobile_checksum_method_mobileandroidkeystore_encrypt(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEANDROIDKEYSTORE_DECRYPT +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEANDROIDKEYSTORE_DECRYPT +uint16_t uniffi_ironstorage_mobile_checksum_method_mobileandroidkeystore_decrypt(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEANDROIDKEYSTORE_DELETE_KEY +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEANDROIDKEYSTORE_DELETE_KEY +uint16_t uniffi_ironstorage_mobile_checksum_method_mobileandroidkeystore_delete_key(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEANDROIDKEYSTORE_ALIASES +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEANDROIDKEYSTORE_ALIASES +uint16_t uniffi_ironstorage_mobile_checksum_method_mobileandroidkeystore_aliases(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEANDROIDKEYSTORE_CANCEL_PENDING +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEANDROIDKEYSTORE_CANCEL_PENDING +uint16_t uniffi_ironstorage_mobile_checksum_method_mobileandroidkeystore_cancel_pending(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILECLIPBOARD_READ @@ -1875,6 +2036,12 @@ uint16_t uniffi_ironstorage_mobile_checksum_method_mobilesecretbackend_unlock(vo #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEPRIVATERUNTIME_ACTIVATE uint16_t uniffi_ironstorage_mobile_checksum_method_mobileprivateruntime_activate(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEPRIVATERUNTIME_ANDROID_SECRET_BACKEND +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEPRIVATERUNTIME_ANDROID_SECRET_BACKEND +uint16_t uniffi_ironstorage_mobile_checksum_method_mobileprivateruntime_android_secret_backend(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEPRIVATERUNTIME_AUTHENTICATION diff --git a/crates/mobile-bridge/src/android_keystore.rs b/crates/mobile-bridge/src/android_keystore.rs new file mode 100644 index 0000000..155265a --- /dev/null +++ b/crates/mobile-bridge/src/android_keystore.rs @@ -0,0 +1,282 @@ +//! Android OS calls; all envelopes, policy and lifecycle remain in storage. +use crate::{MobileSecretBackend, MobileSecretError, MobileSecretLocator, MobileSecretProtection}; +use ironstorage::{ + repository::SecretBytes, + secret_store::{ + SecretLocator, SecretProtection, SecretStoreBackend, SecretStoreError, android as os, + }, +}; +use std::{fmt, sync::Arc}; + +#[derive(Clone, Copy, uniffi::Record)] +pub struct MobileAndroidDeviceState { + pub api: u32, + pub locked: bool, + pub secure: bool, + pub strong_biometric: bool, + pub strongbox: bool, +} +#[derive(Clone, Copy, uniffi::Enum)] +pub enum MobileAndroidAuthorization { + None, + StrongBiometric, + UserPresence, +} +#[derive(Clone, uniffi::Record)] +pub struct MobileAndroidKeySpecification { + pub alias: String, + pub authorization: MobileAndroidAuthorization, + pub unlocked_device: bool, + pub invalidate_on_enrollment: bool, + pub strongbox: bool, +} +#[derive(Clone, uniffi::Record)] +pub struct MobileAndroidCiphertext { + pub nonce: Vec, + pub bytes: Vec, +} +#[derive(Clone, Copy, Debug, uniffi::Error)] +pub enum MobileAndroidKeystoreError { + MissingKey, + Invalidated, + Lockout, + Locked, + Denied, + Cancelled, + Corrupted, + UnsupportedProtection, + StrongboxUnavailable, + AlreadyExists, + Unavailable, +} +impl fmt::Display for MobileAndroidKeystoreError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Android Keystore operation failed") + } +} +impl std::error::Error for MobileAndroidKeystoreError {} +#[uniffi::export(with_foreign)] +pub trait MobileAndroidKeystore: Send + Sync { + fn device_state(&self) -> Result; + fn create_key( + &self, + specification: MobileAndroidKeySpecification, + ) -> Result<(), MobileAndroidKeystoreError>; + fn encrypt( + &self, + alias: String, + authorization: MobileAndroidAuthorization, + aad: Vec, + value: Vec, + ) -> Result; + fn decrypt( + &self, + alias: String, + authorization: MobileAndroidAuthorization, + aad: Vec, + value: MobileAndroidCiphertext, + ) -> Result, MobileAndroidKeystoreError>; + fn delete_key(&self, alias: String) -> Result<(), MobileAndroidKeystoreError>; + fn aliases(&self) -> Result, MobileAndroidKeystoreError>; + fn cancel_pending(&self); +} +pub(crate) struct KeystoreAdapter(pub Arc); +impl From for MobileAndroidAuthorization { + fn from(value: os::AndroidAuthorization) -> Self { + match value { + os::AndroidAuthorization::None => Self::None, + os::AndroidAuthorization::StrongBiometric => Self::StrongBiometric, + os::AndroidAuthorization::UserPresence => Self::UserPresence, + } + } +} +impl From for os::AndroidKeystoreError { + fn from(value: MobileAndroidKeystoreError) -> Self { + match value { + MobileAndroidKeystoreError::MissingKey => Self::MissingKey, + MobileAndroidKeystoreError::Invalidated => Self::Invalidated, + MobileAndroidKeystoreError::Lockout => Self::Lockout, + MobileAndroidKeystoreError::Locked => Self::Locked, + MobileAndroidKeystoreError::Denied => Self::Denied, + MobileAndroidKeystoreError::Cancelled => Self::Cancelled, + MobileAndroidKeystoreError::Corrupted => Self::Corrupted, + MobileAndroidKeystoreError::UnsupportedProtection => Self::UnsupportedProtection, + MobileAndroidKeystoreError::StrongboxUnavailable => Self::StrongboxUnavailable, + MobileAndroidKeystoreError::AlreadyExists => Self::AlreadyExists, + MobileAndroidKeystoreError::Unavailable => Self::Unavailable, + } + } +} +impl os::AndroidKeystore for KeystoreAdapter { + fn device_state(&self) -> Result { + self.0 + .device_state() + .map(|s| os::AndroidDeviceState { + api: s.api, + locked: s.locked, + secure: s.secure, + strong_biometric: s.strong_biometric, + strongbox: s.strongbox, + }) + .map_err(Into::into) + } + fn create_key(&self, s: &os::AndroidKeySpecification) -> Result<(), os::AndroidKeystoreError> { + self.0 + .create_key(MobileAndroidKeySpecification { + alias: s.alias.clone(), + authorization: s.authorization.into(), + unlocked_device: s.unlocked_device, + invalidate_on_enrollment: s.invalidate_on_enrollment, + strongbox: s.strongbox, + }) + .map_err(Into::into) + } + fn encrypt( + &self, + alias: &str, + authorization: os::AndroidAuthorization, + aad: &[u8], + value: &SecretBytes, + ) -> Result { + self.0 + .encrypt( + alias.into(), + authorization.into(), + aad.to_vec(), + value.expose().to_vec(), + ) + .map(|v| os::AndroidCiphertext { + nonce: v.nonce, + bytes: v.bytes, + }) + .map_err(Into::into) + } + fn decrypt( + &self, + alias: &str, + authorization: os::AndroidAuthorization, + aad: &[u8], + value: &os::AndroidCiphertext, + ) -> Result { + self.0 + .decrypt( + alias.into(), + authorization.into(), + aad.to_vec(), + MobileAndroidCiphertext { + nonce: value.nonce.clone(), + bytes: value.bytes.clone(), + }, + ) + .map(SecretBytes::new) + .map_err(Into::into) + } + fn delete_key(&self, alias: &str) -> Result<(), os::AndroidKeystoreError> { + self.0.delete_key(alias.into()).map_err(Into::into) + } + fn aliases(&self) -> Result, os::AndroidKeystoreError> { + self.0.aliases().map_err(Into::into) + } + fn cancel_pending(&self) { + self.0.cancel_pending(); + } +} +pub(crate) struct RustSecretBackend(pub os::AndroidSecretBackend); +impl TryFrom for SecretLocator { + type Error = MobileSecretError; + fn try_from(value: MobileSecretLocator) -> Result { + Ok(match value { + MobileSecretLocator::ApplicationBinding => Self::ApplicationBinding, + MobileSecretLocator::OpenPgpPassphrase { fingerprint } => { + Self::OpenPgpPassphrase { fingerprint } + } + MobileSecretLocator::HttpsGitCredential { + server_id, + application_id, + } => Self::HttpsGitCredential { + server_id, + application_id, + }, + MobileSecretLocator::SshKeyPassphrase { fingerprint } => Self::SshKeyPassphrase { + fingerprint: ironstorage::config::SshFingerprint::parse(&fingerprint) + .map_err(|_| MobileSecretError::Corrupted)?, + }, + }) + } +} +impl From for SecretProtection { + fn from(value: MobileSecretProtection) -> Self { + match value { + MobileSecretProtection::DeviceUnlocked => Self::DeviceUnlocked, + MobileSecretProtection::RequireUserPresence => Self::RequireUserPresence, + MobileSecretProtection::BiometryCurrentSet => Self::BiometryCurrentSet, + } + } +} +impl From for MobileSecretError { + fn from(value: SecretStoreError) -> Self { + match value { + SecretStoreError::AlreadyExists => Self::AlreadyExists, + SecretStoreError::Missing => Self::Missing, + SecretStoreError::Locked => Self::Locked, + SecretStoreError::Denied => Self::Denied, + SecretStoreError::Cancelled => Self::Cancelled, + SecretStoreError::Corrupted => Self::Corrupted, + SecretStoreError::UnsupportedProtection => Self::UnsupportedProtection, + SecretStoreError::Unavailable => Self::Unavailable, + SecretStoreError::DeviceKeyMissing => Self::DeviceKeyMissing, + SecretStoreError::Invalidated => Self::Invalidated, + SecretStoreError::Lockout => Self::Lockout, + _ => Self::Corrupted, + } + } +} +impl MobileSecretBackend for RustSecretBackend { + fn create( + &self, + locator: MobileSecretLocator, + protection: MobileSecretProtection, + value: Vec, + ) -> Result<(), MobileSecretError> { + let secret = SecretBytes::new(value); + self.0 + .create(&locator.try_into()?, protection.into(), secret.expose()) + .map_err(Into::into) + } + fn replace( + &self, + locator: MobileSecretLocator, + protection: MobileSecretProtection, + value: Vec, + ) -> Result<(), MobileSecretError> { + let secret = SecretBytes::new(value); + self.0 + .replace(&locator.try_into()?, protection.into(), secret.expose()) + .map_err(Into::into) + } + fn retrieve( + &self, + locator: MobileSecretLocator, + protection: MobileSecretProtection, + ) -> Result, MobileSecretError> { + self.0 + .retrieve(&locator.try_into()?, protection.into()) + .map(|v| v.expose().to_vec()) + .map_err(Into::into) + } + fn delete( + &self, + locator: MobileSecretLocator, + protection: MobileSecretProtection, + ) -> Result<(), MobileSecretError> { + self.0 + .delete(&locator.try_into()?, protection.into()) + .map_err(Into::into) + } + fn lock(&self) -> Result<(), MobileSecretError> { + self.0.lock().map_err(Into::into) + } + fn unlock(&self) -> Result<(), MobileSecretError> { + self.0.unlock().map_err(Into::into) + } +} diff --git a/crates/mobile-bridge/src/lib.rs b/crates/mobile-bridge/src/lib.rs index d9f578b..8f7489b 100644 --- a/crates/mobile-bridge/src/lib.rs +++ b/crates/mobile-bridge/src/lib.rs @@ -79,6 +79,8 @@ use ironstorage::{ uniffi::setup_scaffolding!(); +mod android_keystore; +pub use android_keystore::*; mod platform; pub use platform::*; diff --git a/crates/mobile-bridge/src/platform.rs b/crates/mobile-bridge/src/platform.rs index 1aefe0b..487f328 100644 --- a/crates/mobile-bridge/src/platform.rs +++ b/crates/mobile-bridge/src/platform.rs @@ -130,6 +130,9 @@ pub enum MobileSecretError { Corrupted, UnsupportedProtection, Unavailable, + DeviceKeyMissing, + Invalidated, + Lockout, } impl fmt::Display for MobileSecretError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -148,6 +151,9 @@ impl From for SecretStoreError { MobileSecretError::Corrupted => Self::Corrupted, MobileSecretError::UnsupportedProtection => Self::UnsupportedProtection, MobileSecretError::Unavailable => Self::Unavailable, + MobileSecretError::DeviceKeyMissing => Self::DeviceKeyMissing, + MobileSecretError::Invalidated => Self::Invalidated, + MobileSecretError::Lockout => Self::Lockout, } } } diff --git a/crates/mobile-bridge/src/private_storage.rs b/crates/mobile-bridge/src/private_storage.rs index b001548..2c6f7f0 100644 --- a/crates/mobile-bridge/src/private_storage.rs +++ b/crates/mobile-bridge/src/private_storage.rs @@ -100,6 +100,17 @@ pub struct MobilePrivateRuntime { } #[uniffi::export] impl MobilePrivateRuntime { + pub fn android_secret_backend( + &self, + keystore: Arc, + ) -> Result, MobilePrivateStorageFfiError> { + let backend = self + .storage + .android_secret_backend(Arc::new(crate::android_keystore::KeystoreAdapter(keystore)))?; + Ok(Arc::new(crate::android_keystore::RustSecretBackend( + backend, + ))) + } pub fn activate(&self) -> Result<(), MobilePrivateStorageFfiError> { self.storage.activate().map_err(Into::into) } diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index 7c741c7..55b449a 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -92,6 +92,7 @@ zbus-secret-service-keyring-store = { workspace = true, optional = true } arboard = { workspace = true, optional = true } [dev-dependencies] +aes-gcm = "0.11.1" hex = "0.4" keepass = { workspace = true, features = ["save_kdbx4"] } nix = { version = "0.31", features = ["fs"] } diff --git a/crates/storage/src/mobile_authentication.rs b/crates/storage/src/mobile_authentication.rs index 67ab025..a83a4d7 100644 --- a/crates/storage/src/mobile_authentication.rs +++ b/crates/storage/src/mobile_authentication.rs @@ -107,11 +107,14 @@ impl MobileAuthenticationError { "IronStorage Locked", "The authentication lease expired.", ), - AuthenticationError::SecretStore(SecretStoreError::Missing) => { - Self::passphrase_required("Enter the GPG key passphrase to continue.") - } + AuthenticationError::SecretStore( + SecretStoreError::Missing + | SecretStoreError::DeviceKeyMissing + | SecretStoreError::Invalidated, + ) => Self::passphrase_required("Enter the GPG key passphrase to continue."), AuthenticationError::SecretStore( SecretStoreError::Denied + | SecretStoreError::Lockout | SecretStoreError::UnsupportedProtection | SecretStoreError::Unavailable, ) => Self::new( diff --git a/crates/storage/src/mobile_private_storage.rs b/crates/storage/src/mobile_private_storage.rs index d085df7..cef92ba 100644 --- a/crates/storage/src/mobile_private_storage.rs +++ b/crates/storage/src/mobile_private_storage.rs @@ -275,6 +275,14 @@ impl PrivateStorage { Ok(storage) } + pub fn android_secret_backend( + &self, + keystore: std::sync::Arc, + ) -> Result { + let directory = private_directory(&self.data, "secrets")?; + crate::secret_store::android::AndroidSecretBackend::open(directory, keystore) + .map_err(secret_error) + } pub fn root(&self) -> &Path { &self.root } @@ -476,8 +484,12 @@ fn secret_error(error: SecretStoreError) -> PrivateStorageError { SecretStoreError::Locked | SecretStoreError::Denied | SecretStoreError::Cancelled => { PrivateStorageError::SecureStorageLocked } - SecretStoreError::Missing => PrivateStorageError::RestoredWithoutKeystore, - SecretStoreError::Corrupted => PrivateStorageError::InvalidSecureBinding, + SecretStoreError::Missing | SecretStoreError::DeviceKeyMissing => { + PrivateStorageError::RestoredWithoutKeystore + } + SecretStoreError::Corrupted | SecretStoreError::Invalidated => { + PrivateStorageError::InvalidSecureBinding + } _ => PrivateStorageError::SecureStorageUnavailable, } } diff --git a/crates/storage/src/secret_store.rs b/crates/storage/src/secret_store.rs index 93b014a..9a921c8 100644 --- a/crates/storage/src/secret_store.rs +++ b/crates/storage/src/secret_store.rs @@ -16,6 +16,7 @@ use crate::{ repository::SecretBytes, }; +pub mod android; mod platform; pub(crate) use platform::NativeSecretBackend; @@ -283,6 +284,9 @@ impl SecretCachePolicy { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum SecretStoreError { + DeviceKeyMissing, + Invalidated, + Lockout, InvalidReference, InvalidCachePolicy, AlreadyExists, @@ -298,6 +302,11 @@ pub enum SecretStoreError { impl fmt::Display for SecretStoreError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { let message = match self { + Self::DeviceKeyMissing => { + "the device key is missing; recover the protected secret manually" + } + Self::Invalidated => "the protected key was invalidated; recover the secret manually", + Self::Lockout => "biometric authentication is locked out", Self::InvalidReference => "the secret reference is invalid", Self::InvalidCachePolicy => "the secret cache policy is invalid", Self::AlreadyExists => "the referenced secret already exists", diff --git a/crates/storage/src/secret_store/android.rs b/crates/storage/src/secret_store/android.rs new file mode 100644 index 0000000..a282754 --- /dev/null +++ b/crates/storage/src/secret_store/android.rs @@ -0,0 +1,1185 @@ +//! Rust-owned Android envelopes and lifecycle; Keystore keys/ciphers stay in OS callbacks. +use super::{SecretLocator, SecretProtection, SecretStoreBackend, SecretStoreError}; +use crate::{atomic_file::TempFile, repository::SecretBytes}; +use cap_std::fs::{Dir, OpenOptions}; +use sha2::{Digest, Sha256}; +use std::{ + collections::BTreeSet, + fmt, + io::{Read, Write}, + sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, +}; + +const PREFIX: &str = "de.rfc1437.ironstorage.sealed.v1."; +const MAGIC: &[u8] = b"IRONSTORAGE-SEALED\0\x01"; +const MAX_PAYLOAD: usize = 16 * 1024; +const MAX_RECORDS: usize = 1024; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AndroidDeviceState { + pub api: u32, + pub locked: bool, + pub secure: bool, + pub strong_biometric: bool, + pub strongbox: bool, +} +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AndroidAuthorization { + None, + StrongBiometric, + UserPresence, +} +#[derive(Clone, Eq, PartialEq)] +pub struct AndroidKeySpecification { + pub alias: String, + pub authorization: AndroidAuthorization, + pub unlocked_device: bool, + pub invalidate_on_enrollment: bool, + pub strongbox: bool, +} +impl fmt::Debug for AndroidKeySpecification { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("AndroidKeySpecification([REDACTED])") + } +} +pub struct AndroidCiphertext { + pub nonce: Vec, + pub bytes: Vec, +} +impl fmt::Debug for AndroidCiphertext { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("AndroidCiphertext([REDACTED])") + } +} +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AndroidKeystoreError { + MissingKey, + Invalidated, + Lockout, + Locked, + Denied, + Cancelled, + Corrupted, + UnsupportedProtection, + StrongboxUnavailable, + AlreadyExists, + Unavailable, +} +/// OS facts, key generation, authenticated cipher operations and cancellation only. +pub trait AndroidKeystore: Send + Sync { + fn device_state(&self) -> Result; + fn create_key( + &self, + specification: &AndroidKeySpecification, + ) -> Result<(), AndroidKeystoreError>; + fn encrypt( + &self, + alias: &str, + authorization: AndroidAuthorization, + aad: &[u8], + value: &SecretBytes, + ) -> Result; + fn decrypt( + &self, + alias: &str, + authorization: AndroidAuthorization, + aad: &[u8], + value: &AndroidCiphertext, + ) -> Result; + fn delete_key(&self, alias: &str) -> Result<(), AndroidKeystoreError>; + fn aliases(&self) -> Result, AndroidKeystoreError>; + fn cancel_pending(&self); +} +impl From for SecretStoreError { + fn from(error: AndroidKeystoreError) -> Self { + match error { + AndroidKeystoreError::MissingKey => Self::DeviceKeyMissing, + AndroidKeystoreError::Invalidated => Self::Invalidated, + AndroidKeystoreError::Lockout => Self::Lockout, + AndroidKeystoreError::Locked => Self::Locked, + AndroidKeystoreError::Denied => Self::Denied, + AndroidKeystoreError::Cancelled => Self::Cancelled, + AndroidKeystoreError::Corrupted => Self::Corrupted, + AndroidKeystoreError::UnsupportedProtection => Self::UnsupportedProtection, + AndroidKeystoreError::AlreadyExists => Self::AlreadyExists, + AndroidKeystoreError::StrongboxUnavailable | AndroidKeystoreError::Unavailable => { + Self::Unavailable + } + } + } +} +struct Envelope { + protection: SecretProtection, + alias: String, + ciphertext: AndroidCiphertext, +} + +pub struct AndroidSecretBackend { + directory: Dir, + keystore: Arc, + locked: AtomicBool, + generation: AtomicU64, +} +impl AndroidSecretBackend { + pub(crate) fn open( + directory: Dir, + keystore: Arc, + ) -> Result { + let backend = Self { + directory, + keystore, + locked: AtomicBool::new(true), + generation: AtomicU64::new(0), + }; + let _lock = backend.transaction()?; + backend.cleanup_orphan_keys()?; + drop(_lock); + Ok(backend) + } + fn transaction(&self) -> Result { + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true); + if let Ok(metadata) = self.directory.symlink_metadata("records.lock") + && (!metadata.is_file() || metadata.file_type().is_symlink()) + { + return Err(SecretStoreError::Corrupted); + } + let file = self + .directory + .open_with("records.lock", &options) + .map_err(io_error)?; + crate::mobile_private_storage::private_permissions(&file, false) + .map_err(|_| SecretStoreError::Unavailable)?; + let file = file.into_std(); + crate::file_lock::try_lock(&file).map_err(io_error)?; + Ok(file) + } + fn authorization( + &self, + protection: SecretProtection, + ) -> Result<(AndroidDeviceState, AndroidAuthorization), SecretStoreError> { + if self.locked.load(Ordering::Acquire) { + return Err(SecretStoreError::Locked); + } + let state = self + .keystore + .device_state() + .map_err(SecretStoreError::from)?; + if state.locked { + return Err(SecretStoreError::Locked); + } + let authorization = match protection { + SecretProtection::DeviceUnlocked => AndroidAuthorization::None, + SecretProtection::BiometryCurrentSet if state.secure && state.strong_biometric => { + AndroidAuthorization::StrongBiometric + } + SecretProtection::RequireUserPresence if state.secure && state.api >= 30 => { + AndroidAuthorization::UserPresence + } + _ => return Err(SecretStoreError::UnsupportedProtection), + }; + // Older Android unlocked-key bugs require a secure lock screen, or explicit recovery. + if state.api < 26 || ((28..35).contains(&state.api) && !state.secure) { + return Err(SecretStoreError::UnsupportedProtection); + } + Ok((state, authorization)) + } + fn after_operation( + &self, + generation: u64, + protection: SecretProtection, + ) -> Result<(), SecretStoreError> { + if self.generation.load(Ordering::Acquire) != generation { + return Err(SecretStoreError::Cancelled); + } + self.authorization(protection).map(|_| ()) + } + fn read(&self, id: &str) -> Result, SecretStoreError> { + let name = format!("{id}.record"); + let metadata = match self.directory.symlink_metadata(&name) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(io_error(error)), + }; + if !metadata.is_file() + || metadata.file_type().is_symlink() + || metadata.len() > (MAX_PAYLOAD + 1024) as u64 + { + return Err(SecretStoreError::Corrupted); + } + let file = self.directory.open(&name).map_err(io_error)?; + crate::mobile_private_storage::private_permissions(&file, false) + .map_err(|_| SecretStoreError::Unavailable)?; + let mut bytes = Vec::new(); + file.take((MAX_PAYLOAD + 1025) as u64) + .read_to_end(&mut bytes) + .map_err(io_error)?; + decode(id, &bytes).map(Some) + } + fn cleanup_orphan_keys(&self) -> Result<(), SecretStoreError> { + #[cfg(unix)] + let _creation = crate::file_lock::directory(&self.directory).map_err(io_error)?; + let mut live = BTreeSet::new(); + let entries = self.directory.entries().map_err(io_error)?; + for (index, entry) in entries.enumerate() { + if index > MAX_RECORDS { + return Err(SecretStoreError::Unavailable); + } + let name = entry.map_err(io_error)?.file_name(); + let name = name.to_str().ok_or(SecretStoreError::Corrupted)?; + if crate::atomic_file::is_temporary(name) { + let metadata = self.directory.symlink_metadata(name).map_err(io_error)?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(SecretStoreError::Corrupted); + } + let file = self.directory.open(name).map_err(io_error)?.into_std(); + match crate::file_lock::try_lock(&file) { + Ok(()) => self.directory.remove_file(name).map_err(io_error)?, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {} + Err(error) => return Err(io_error(error)), + } + } + if let Some(id) = name.strip_suffix(".record") { + if !valid_id(id) { + return Err(SecretStoreError::Corrupted); + } + live.insert(self.read(id)?.ok_or(SecretStoreError::Corrupted)?.alias); + } + } + let aliases = self.keystore.aliases().map_err(SecretStoreError::from)?; + if aliases.len() > 4096 { + return Err(SecretStoreError::Unavailable); + } + for alias in aliases { + if owned_alias(&alias) && !live.contains(&alias) { + self.keystore + .delete_key(&alias) + .map_err(SecretStoreError::from)?; + } + } + Ok(()) + } + fn write( + &self, + locator: &SecretLocator, + protection: SecretProtection, + value: &[u8], + replace: bool, + ) -> Result<(), SecretStoreError> { + validate_protection(locator, protection)?; + if value.is_empty() || value.len() > MAX_PAYLOAD { + return Err(SecretStoreError::InvalidReference); + } + let id = identity(locator)?; + let _lock = self.transaction()?; + let previous = self.read(&id)?; + if !replace && previous.is_some() { + return Err(SecretStoreError::AlreadyExists); + } + if replace && previous.is_none() { + return Err(SecretStoreError::Missing); + } + let generation = self.generation.load(Ordering::Acquire); + let (state, authorization) = self.authorization(protection)?; + let alias = format!("{PREFIX}{id}.{:032x}", rand::random::()); + let mut specification = AndroidKeySpecification { + alias: alias.clone(), + authorization, + unlocked_device: state.api >= 28, + invalidate_on_enrollment: protection == SecretProtection::BiometryCurrentSet, + strongbox: state.strongbox, + }; + match self.keystore.create_key(&specification) { + Err(AndroidKeystoreError::StrongboxUnavailable) if specification.strongbox => { + specification.strongbox = false; + self.keystore + .create_key(&specification) + .map_err(SecretStoreError::from)?; + } + result => result.map_err(SecretStoreError::from)?, + } + let mut installed = false; + let result = (|| { + let clear = SecretBytes::new(value.to_vec()); + let aad = additional_data(&id, protection, &alias); + let ciphertext = self + .keystore + .encrypt(&alias, authorization, &aad, &clear) + .map_err(SecretStoreError::from)?; + if ciphertext.nonce.len() != 12 || ciphertext.bytes.len() != value.len() + 16 { + return Err(SecretStoreError::Corrupted); + } + self.after_operation(generation, protection)?; + let envelope = Envelope { + protection, + alias: alias.clone(), + ciphertext, + }; + let mut temporary = TempFile::new(&self.directory).map_err(io_error)?; + crate::mobile_private_storage::private_permissions(temporary.as_file(), false) + .map_err(|_| SecretStoreError::Unavailable)?; + temporary.write_all(&encode(&envelope)).map_err(io_error)?; + temporary.as_file().sync_all().map_err(io_error)?; + temporary + .replace(format!("{id}.record")) + .map_err(io_error)?; + installed = true; + self.directory + .open(".") + .and_then(|file| file.sync_all()) + .map_err(io_error)?; + Ok(()) + })(); + if !installed { + let _ = self.keystore.delete_key(&alias); + } + result?; + if let Some(previous) = previous { + self.keystore + .delete_key(&previous.alias) + .map_err(SecretStoreError::from)?; + } + Ok(()) + } +} +impl SecretStoreBackend for AndroidSecretBackend { + fn create( + &self, + locator: &SecretLocator, + protection: SecretProtection, + value: &[u8], + ) -> Result<(), SecretStoreError> { + self.write(locator, protection, value, false) + } + fn replace( + &self, + locator: &SecretLocator, + protection: SecretProtection, + value: &[u8], + ) -> Result<(), SecretStoreError> { + self.write(locator, protection, value, true) + } + fn retrieve( + &self, + locator: &SecretLocator, + protection: SecretProtection, + ) -> Result { + validate_protection(locator, protection)?; + let id = identity(locator)?; + let _lock = self.transaction()?; + let envelope = self.read(&id)?.ok_or(SecretStoreError::Missing)?; + if envelope.protection != protection { + return Err(SecretStoreError::UnsupportedProtection); + } + let generation = self.generation.load(Ordering::Acquire); + let (_, authorization) = self.authorization(protection)?; + let clear = self + .keystore + .decrypt( + &envelope.alias, + authorization, + &additional_data(&id, protection, &envelope.alias), + &envelope.ciphertext, + ) + .map_err(SecretStoreError::from)?; + self.after_operation(generation, protection)?; + if clear.expose().is_empty() + || clear.expose().len() > MAX_PAYLOAD + || clear.expose().len() + 16 != envelope.ciphertext.bytes.len() + { + return Err(SecretStoreError::Corrupted); + } + Ok(clear) + } + fn delete( + &self, + locator: &SecretLocator, + protection: SecretProtection, + ) -> Result<(), SecretStoreError> { + validate_protection(locator, protection)?; + let id = identity(locator)?; + let _lock = self.transaction()?; + let envelope = self.read(&id)?.ok_or(SecretStoreError::Missing)?; + if envelope.protection != protection { + return Err(SecretStoreError::UnsupportedProtection); + } + self.directory + .remove_file(format!("{id}.record")) + .map_err(io_error)?; + let durability = self + .directory + .open(".") + .and_then(|file| file.sync_all()) + .map_err(io_error); + let deleted = self + .keystore + .delete_key(&envelope.alias) + .map_err(SecretStoreError::from); + durability.and(deleted) + } + fn lock(&self) -> Result<(), SecretStoreError> { + self.locked.store(true, Ordering::Release); + self.generation.fetch_add(1, Ordering::AcqRel); + self.keystore.cancel_pending(); + Ok(()) + } + fn unlock(&self) -> Result<(), SecretStoreError> { + if self + .keystore + .device_state() + .map_err(SecretStoreError::from)? + .locked + { + return Err(SecretStoreError::Locked); + } + self.generation.fetch_add(1, Ordering::AcqRel); + self.locked.store(false, Ordering::Release); + Ok(()) + } +} +fn io_error(_: std::io::Error) -> SecretStoreError { + SecretStoreError::Unavailable +} +fn validate_protection( + locator: &SecretLocator, + protection: SecretProtection, +) -> Result<(), SecretStoreError> { + let allowed = match locator { + SecretLocator::OpenPgpPassphrase { .. } => matches!( + protection, + SecretProtection::BiometryCurrentSet | SecretProtection::RequireUserPresence + ), + SecretLocator::ApplicationBinding + | SecretLocator::HttpsGitCredential { .. } + | SecretLocator::SshKeyPassphrase { .. } => protection == SecretProtection::DeviceUnlocked, + }; + if allowed { + Ok(()) + } else { + Err(SecretStoreError::UnsupportedProtection) + } +} +fn protection_tag(protection: SecretProtection) -> u8 { + match protection { + SecretProtection::DeviceUnlocked => 0, + SecretProtection::RequireUserPresence => 1, + SecretProtection::BiometryCurrentSet => 2, + } +} +fn valid_id(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) +} +fn owned_alias(value: &str) -> bool { + value + .strip_prefix(PREFIX) + .and_then(|suffix| suffix.split_once('.')) + .is_some_and(|(id, generation)| { + valid_id(id) + && generation.len() == 32 + && generation + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + }) +} +fn identity(locator: &SecretLocator) -> Result { + let normalized; + let locator = if let SecretLocator::OpenPgpPassphrase { fingerprint } = locator { + normalized = super::SecretReference::openpgp_passphrase(fingerprint.clone())?.locator(); + &normalized + } else { + locator + }; + match locator { + SecretLocator::OpenPgpPassphrase { fingerprint } => { + super::SecretReference::openpgp_passphrase(fingerprint.clone())?; + } + SecretLocator::HttpsGitCredential { + server_id, + application_id, + } => { + super::validate_identifier(server_id)?; + super::validate_identifier(application_id)?; + } + SecretLocator::ApplicationBinding | SecretLocator::SshKeyPassphrase { .. } => {} + } + let (service, user) = locator.service_and_user(); + let mut digest = Sha256::new(); + for field in [service.as_bytes(), user.as_bytes()] { + digest.update((field.len() as u64).to_be_bytes()); + digest.update(field); + } + Ok(data_encoding::HEXLOWER.encode(&digest.finalize())) +} +fn additional_data(id: &str, protection: SecretProtection, alias: &str) -> Vec { + [ + MAGIC, + id.as_bytes(), + &[protection_tag(protection)], + alias.as_bytes(), + ] + .concat() +} +fn encode(envelope: &Envelope) -> Vec { + let mut bytes = MAGIC.to_vec(); + bytes.push(protection_tag(envelope.protection)); + bytes.extend_from_slice(&(envelope.alias.len() as u16).to_be_bytes()); + bytes.extend_from_slice(envelope.alias.as_bytes()); + bytes.extend_from_slice(&envelope.ciphertext.nonce); + bytes.extend_from_slice(&envelope.ciphertext.bytes); + bytes +} +fn decode(id: &str, bytes: &[u8]) -> Result { + let bytes = bytes + .strip_prefix(MAGIC) + .ok_or(SecretStoreError::Corrupted)?; + if bytes.len() < 3 { + return Err(SecretStoreError::Corrupted); + } + let protection = match bytes[0] { + 0 => SecretProtection::DeviceUnlocked, + 1 => SecretProtection::RequireUserPresence, + 2 => SecretProtection::BiometryCurrentSet, + _ => return Err(SecretStoreError::Corrupted), + }; + let length = u16::from_be_bytes([bytes[1], bytes[2]]) as usize; + if bytes.len() < 3 + length + 12 + 17 || bytes.len() > MAX_PAYLOAD + 1024 { + return Err(SecretStoreError::Corrupted); + } + let alias = std::str::from_utf8(&bytes[3..3 + length]) + .map_err(|_| SecretStoreError::Corrupted)? + .to_owned(); + if !owned_alias(&alias) || !alias.starts_with(&format!("{PREFIX}{id}.")) { + return Err(SecretStoreError::Corrupted); + } + let ciphertext = AndroidCiphertext { + nonce: bytes[3 + length..3 + length + 12].to_vec(), + bytes: bytes[3 + length + 12..].to_vec(), + }; + if ciphertext.bytes.len() > MAX_PAYLOAD + 16 { + return Err(SecretStoreError::Corrupted); + } + Ok(Envelope { + protection, + alias, + ciphertext, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use aes_gcm::{ + Aes256Gcm, KeyInit, Nonce, + aead::{Aead, Payload}, + }; + use cap_std::ambient_authority; + use std::{ + collections::BTreeMap, + sync::{Barrier, Mutex}, + }; + struct Key { + bytes: SecretBytes, + specification: AndroidKeySpecification, + } + struct State { + device: AndroidDeviceState, + keys: BTreeMap, + failure: Option, + strongbox_failed: bool, + locked_after_cipher: bool, + } + struct Os { + state: Mutex, + gate: Mutex>>, + } + impl Os { + fn new() -> Arc { + Arc::new(Self { + state: Mutex::new(State { + device: AndroidDeviceState { + api: 37, + locked: false, + secure: true, + strong_biometric: true, + strongbox: false, + }, + keys: BTreeMap::new(), + failure: None, + strongbox_failed: false, + locked_after_cipher: false, + }), + gate: Mutex::new(None), + }) + } + fn crypt( + &self, + alias: &str, + authorization: AndroidAuthorization, + aad: &[u8], + nonce: &[u8], + bytes: &[u8], + encrypt: bool, + ) -> Result, AndroidKeystoreError> { + if let Some(gate) = self.gate.lock().unwrap().clone() { + gate.wait(); + gate.wait(); + } + let mut state = self.state.lock().unwrap(); + if let Some(error) = state.failure { + return Err(error); + } + let key = state + .keys + .get(alias) + .ok_or(AndroidKeystoreError::MissingKey)?; + if key.specification.authorization != authorization { + return Err(AndroidKeystoreError::UnsupportedProtection); + } + let cipher = Aes256Gcm::new_from_slice(key.bytes.expose()).unwrap(); + let nonce: [u8; 12] = nonce + .try_into() + .map_err(|_| AndroidKeystoreError::Corrupted)?; + let nonce = Nonce::from(nonce); + let data = Payload { msg: bytes, aad }; + let result = if encrypt { + cipher.encrypt(&nonce, data) + } else { + cipher.decrypt(&nonce, data) + } + .map_err(|_| AndroidKeystoreError::Corrupted); + if state.locked_after_cipher { + state.device.locked = true; + } + result + } + } + impl AndroidKeystore for Os { + fn device_state(&self) -> Result { + Ok(self.state.lock().unwrap().device) + } + fn create_key(&self, spec: &AndroidKeySpecification) -> Result<(), AndroidKeystoreError> { + let mut state = self.state.lock().unwrap(); + if spec.strongbox && state.strongbox_failed { + return Err(AndroidKeystoreError::StrongboxUnavailable); + } + if state.keys.contains_key(&spec.alias) { + return Err(AndroidKeystoreError::AlreadyExists); + } + state.keys.insert( + spec.alias.clone(), + Key { + bytes: SecretBytes::new(rand::random::<[u8; 32]>().to_vec()), + specification: spec.clone(), + }, + ); + Ok(()) + } + fn encrypt( + &self, + alias: &str, + authorization: AndroidAuthorization, + aad: &[u8], + value: &SecretBytes, + ) -> Result { + let nonce = rand::random::<[u8; 12]>().to_vec(); + let bytes = self.crypt(alias, authorization, aad, &nonce, value.expose(), true)?; + Ok(AndroidCiphertext { nonce, bytes }) + } + fn decrypt( + &self, + alias: &str, + authorization: AndroidAuthorization, + aad: &[u8], + value: &AndroidCiphertext, + ) -> Result { + self.crypt(alias, authorization, aad, &value.nonce, &value.bytes, false) + .map(SecretBytes::new) + } + fn delete_key(&self, alias: &str) -> Result<(), AndroidKeystoreError> { + self.state.lock().unwrap().keys.remove(alias); + Ok(()) + } + fn aliases(&self) -> Result, AndroidKeystoreError> { + Ok(self.state.lock().unwrap().keys.keys().cloned().collect()) + } + fn cancel_pending(&self) {} + } + fn setup() -> (tempfile::TempDir, Arc, AndroidSecretBackend) { + let root = tempfile::tempdir().unwrap(); + let os = Os::new(); + let backend = AndroidSecretBackend::open( + Dir::open_ambient_dir(root.path(), ambient_authority()).unwrap(), + os.clone(), + ) + .unwrap(); + backend.unlock().unwrap(); + (root, os, backend) + } + fn git() -> SecretLocator { + SecretLocator::HttpsGitCredential { + server_id: "server".into(), + application_id: "app".into(), + } + } + const DEVICE: SecretProtection = SecretProtection::DeviceUnlocked; + #[test] + fn roundtrip_duplicate_replace_delete_and_no_cleartext_on_disk() { + let (root, os, backend) = setup(); + let locator = git(); + backend + .create(&locator, DEVICE, b"not-a-settings-token") + .unwrap(); + assert_eq!( + backend.create(&locator, DEVICE, b"duplicate"), + Err(SecretStoreError::AlreadyExists) + ); + assert_eq!( + backend.retrieve(&locator, DEVICE).unwrap().expose(), + b"not-a-settings-token" + ); + let first = os.aliases().unwrap(); + backend + .replace(&locator, DEVICE, b"replacement-token") + .unwrap(); + assert_eq!(os.aliases().unwrap().len(), 1); + assert_ne!(first, os.aliases().unwrap()); + for entry in std::fs::read_dir(root.path()).unwrap() { + let bytes = std::fs::read(entry.unwrap().path()).unwrap(); + assert!(!bytes.windows(17).any(|b| b == b"replacement-token")); + } + assert_eq!( + backend.retrieve(&locator, DEVICE).unwrap().expose(), + b"replacement-token" + ); + backend.delete(&locator, DEVICE).unwrap(); + assert!(os.aliases().unwrap().is_empty()); + assert!(matches!( + backend.retrieve(&locator, DEVICE), + Err(SecretStoreError::Missing) + )); + assert_eq!( + backend.replace(&locator, DEVICE, b"missing"), + Err(SecretStoreError::Missing) + ); + assert_eq!( + backend.delete(&locator, DEVICE), + Err(SecretStoreError::Missing) + ); + } + #[test] + fn shared_secret_store_encodes_git_accounts_and_uses_current_biometry_for_passphrases() { + let (_root, os, backend) = setup(); + let store = super::super::SecretStore::new( + backend, + super::super::SecretCachePolicy::Disabled, + super::super::SecretProtectionPolicy::current_biometry_for_openpgp(), + ); + store.unlock().unwrap(); + let git = + super::super::SecretReference::https_git_credential("server", "app", "alice").unwrap(); + store + .create(&git, SecretBytes::new(b"public-token".to_vec())) + .unwrap(); + assert_eq!(store.retrieve(&git).unwrap().expose(), b"public-token"); + assert_eq!( + os.state + .lock() + .unwrap() + .keys + .values() + .next() + .unwrap() + .specification + .authorization, + AndroidAuthorization::None + ); + let pgp = super::super::SecretReference::openpgp_passphrase( + "0123456789ABCDEF0123456789ABCDEF01234567", + ) + .unwrap(); + store + .create(&pgp, SecretBytes::new(b"public-passphrase".to_vec())) + .unwrap(); + assert!( + os.state + .lock() + .unwrap() + .keys + .values() + .any(|k| k.specification.authorization == AndroidAuthorization::StrongBiometric) + ); + store.lock().unwrap(); + assert!(matches!( + store.retrieve(&git), + Err(SecretStoreError::Locked) + )); + store.unlock().unwrap(); + store.delete(&pgp).unwrap(); + assert_eq!(os.aliases().unwrap().len(), 1); + } + #[test] + fn disabling_mobile_biometry_deletes_the_actual_sealed_passphrase_and_keystore_key() { + use crate::{ + authentication::SystemAuthenticationClock, config::Config, + mobile_authentication::MobileAuthentication, + }; + let (root, os, backend) = setup(); + let fixtures = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/compatibility"); + let generated: toml::Value = + toml::from_str(&std::fs::read_to_string(fixtures.join("generated.toml")).unwrap()) + .unwrap(); + let fingerprint = generated["keys"][0]["primary_fingerprint"] + .as_str() + .unwrap(); + let source = root.path().join("config.toml"); + std::fs::write( + &source, + format!( + "vault = {:?}\nkey_material = {:?}\ndefault_key = {:?}\n", + fixtures.join("stores/basic").to_str().unwrap(), + fixtures.join("keys").to_str().unwrap(), + fingerprint + ), + ) + .unwrap(); + let authentication = MobileAuthentication::with_platform( + Config::load(Some(&source)).unwrap(), + Arc::new(backend), + Arc::new(SystemAuthenticationClock::default()), + ) + .unwrap(); + authentication + .unlock_entry( + "email/personal", + Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())), + ) + .unwrap(); + assert!( + authentication + .set_biometric_unlock(true) + .unwrap() + .biometric_unlock_enabled() + ); + assert_eq!(os.aliases().unwrap().len(), 1); + authentication.manual_lock().unwrap(); + assert!( + authentication + .unlock_entry("email/personal", None) + .unwrap() + .unlocked() + ); + assert!( + !authentication + .set_biometric_unlock(false) + .unwrap() + .unlocked() + ); + assert!(os.aliases().unwrap().is_empty()); + assert!( + !std::fs::read_to_string(source) + .unwrap() + .contains("fixture-alice-passphrase") + ); + assert!(std::fs::read_dir(root.path()).unwrap().all(|e| { + !e.unwrap() + .file_name() + .to_string_lossy() + .ends_with(".record") + })); + } + #[test] + fn failure_never_replaces_the_old_record_and_candidate_keys_are_removed() { + let (_root, os, backend) = setup(); + backend.create(&git(), DEVICE, b"original").unwrap(); + for (source, expected) in [ + (AndroidKeystoreError::Cancelled, SecretStoreError::Cancelled), + (AndroidKeystoreError::Denied, SecretStoreError::Denied), + (AndroidKeystoreError::Lockout, SecretStoreError::Lockout), + ( + AndroidKeystoreError::Invalidated, + SecretStoreError::Invalidated, + ), + ( + AndroidKeystoreError::Unavailable, + SecretStoreError::Unavailable, + ), + ] { + os.state.lock().unwrap().failure = Some(source); + assert_eq!(backend.replace(&git(), DEVICE, b"new"), Err(expected)); + assert_eq!(os.aliases().unwrap().len(), 1); + assert!(matches!(backend.retrieve(&git(), DEVICE), Err(e) if e == expected)); + } + os.state.lock().unwrap().failure = None; + assert_eq!( + backend.retrieve(&git(), DEVICE).unwrap().expose(), + b"original" + ); + } + #[test] + fn policies_are_exact_and_no_biometric_fallback_is_permitted() { + let (_root, os, backend) = setup(); + let fingerprint = "0123456789ABCDEF0123456789ABCDEF01234567"; + let pgp = SecretLocator::OpenPgpPassphrase { + fingerprint: fingerprint.into(), + }; + backend + .create(&pgp, SecretProtection::BiometryCurrentSet, b"passphrase") + .unwrap(); + let state = os.state.lock().unwrap(); + let key = state.keys.values().next().unwrap(); + assert_eq!( + key.specification.authorization, + AndroidAuthorization::StrongBiometric + ); + assert!(key.specification.invalidate_on_enrollment && key.specification.unlocked_device); + drop(state); + let lower = SecretLocator::OpenPgpPassphrase { + fingerprint: fingerprint.to_lowercase(), + }; + assert_eq!( + backend + .retrieve(&lower, SecretProtection::BiometryCurrentSet) + .unwrap() + .expose(), + b"passphrase" + ); + assert!(matches!( + backend.retrieve(&pgp, DEVICE), + Err(SecretStoreError::UnsupportedProtection) + )); + os.state.lock().unwrap().device.strong_biometric = false; + assert!(matches!( + backend.retrieve(&pgp, SecretProtection::BiometryCurrentSet), + Err(SecretStoreError::UnsupportedProtection) + )); + backend + .delete(&pgp, SecretProtection::BiometryCurrentSet) + .unwrap(); + backend + .create(&pgp, SecretProtection::RequireUserPresence, b"passphrase") + .unwrap(); + assert_eq!( + os.state + .lock() + .unwrap() + .keys + .values() + .next() + .unwrap() + .specification + .authorization, + AndroidAuthorization::UserPresence + ); + } + #[test] + fn old_android_and_lock_state_fail_closed_and_strongbox_falls_back_only_when_absent() { + let (_root, os, backend) = setup(); + for api in [28, 30, 31, 34] { + let mut state = os.state.lock().unwrap(); + state.device.api = api; + state.device.secure = false; + drop(state); + assert_eq!( + backend.create(&git(), DEVICE, b"value"), + Err(SecretStoreError::UnsupportedProtection) + ); + } + { + let mut state = os.state.lock().unwrap(); + state.device.api = 37; + state.device.strongbox = true; + state.strongbox_failed = true; + } + backend.create(&git(), DEVICE, b"value").unwrap(); + assert!( + !os.state + .lock() + .unwrap() + .keys + .values() + .next() + .unwrap() + .specification + .strongbox + ); + backend.lock().unwrap(); + assert!(matches!( + backend.retrieve(&git(), DEVICE), + Err(SecretStoreError::Locked) + )); + backend.unlock().unwrap(); + os.state.lock().unwrap().device.locked = true; + assert_eq!(backend.unlock(), Err(SecretStoreError::Locked)); + assert!(matches!( + backend.retrieve(&git(), DEVICE), + Err(SecretStoreError::Locked) + )); + } + #[test] + fn tampering_alias_swaps_restores_and_corrupt_headers_are_typed() { + let (root, os, backend) = setup(); + backend.create(&git(), DEVICE, b"value").unwrap(); + let path = root + .path() + .join(format!("{}.record", identity(&git()).unwrap())); + let original = std::fs::read(&path).unwrap(); + let mut bytes = original.clone(); + let last = bytes.len() - 1; + bytes[last] ^= 1; + std::fs::write(&path, &bytes).unwrap(); + assert!(matches!( + backend.retrieve(&git(), DEVICE), + Err(SecretStoreError::Corrupted) + )); + bytes = original.clone(); + bytes[0] ^= 1; + std::fs::write(&path, &bytes).unwrap(); + assert!(matches!( + backend.retrieve(&git(), DEVICE), + Err(SecretStoreError::Corrupted) + )); + std::fs::write(&path, &original).unwrap(); + os.state.lock().unwrap().keys.clear(); + assert!(matches!( + backend.retrieve(&git(), DEVICE), + Err(SecretStoreError::DeviceKeyMissing) + )); + let other = SecretLocator::ApplicationBinding; + std::fs::write( + root.path() + .join(format!("{}.record", identity(&other).unwrap())), + &original, + ) + .unwrap(); + assert!(matches!( + backend.retrieve(&other, DEVICE), + Err(SecretStoreError::Corrupted) + )); + } + #[test] + fn startup_cleans_only_owned_orphans_and_preserves_keys_for_corrupt_records() { + let (root, os, backend) = setup(); + backend.create(&git(), DEVICE, b"value").unwrap(); + let live = os.aliases().unwrap()[0].clone(); + let orphan = format!("{PREFIX}{}.{}", "0".repeat(64), "1".repeat(32)); + for alias in [&orphan, "other.application"] { + os.create_key(&AndroidKeySpecification { + alias: alias.into(), + authorization: AndroidAuthorization::None, + unlocked_device: true, + invalidate_on_enrollment: false, + strongbox: false, + }) + .unwrap(); + } + let temp = ".ironstorage-atomic-00000000000000000000000000000000"; + std::fs::write(root.path().join(temp), b"orphan").unwrap(); + drop(backend); + let dir = || Dir::open_ambient_dir(root.path(), ambient_authority()).unwrap(); + let backend = AndroidSecretBackend::open(dir(), os.clone()).unwrap(); + assert_eq!( + os.aliases().unwrap(), + vec![live.clone(), "other.application".into()] + ); + assert!(!root.path().join(temp).exists()); + std::fs::write( + root.path() + .join(format!("{}.record", identity(&git()).unwrap())), + b"corrupt", + ) + .unwrap(); + drop(backend); + assert!(matches!( + AndroidSecretBackend::open(dir(), os.clone()), + Err(SecretStoreError::Corrupted) + )); + assert!(os.aliases().unwrap().contains(&live)); + } + #[test] + fn device_lock_after_decryption_discards_plaintext_and_background_cancels_operations() { + let (_root, os, backend) = setup(); + backend.create(&git(), DEVICE, b"value").unwrap(); + os.state.lock().unwrap().locked_after_cipher = true; + assert!(matches!( + backend.retrieve(&git(), DEVICE), + Err(SecretStoreError::Locked) + )); + { + let mut state = os.state.lock().unwrap(); + state.locked_after_cipher = false; + state.device.locked = false; + } + let backend = Arc::new(backend); + let gate = Arc::new(Barrier::new(2)); + *os.gate.lock().unwrap() = Some(gate.clone()); + let worker = { + let backend = backend.clone(); + std::thread::spawn(move || backend.retrieve(&git(), DEVICE)) + }; + gate.wait(); + backend.lock().unwrap(); + gate.wait(); + assert!(matches!( + worker.join().unwrap(), + Err(SecretStoreError::Cancelled) + )); + } + #[test] + fn limits_and_redaction_reject_invalid_input_before_os_mutation() { + let (_root, os, backend) = setup(); + assert_eq!( + backend.create(&git(), DEVICE, b""), + Err(SecretStoreError::InvalidReference) + ); + assert_eq!( + backend.create(&git(), DEVICE, &vec![1; MAX_PAYLOAD + 1]), + Err(SecretStoreError::InvalidReference) + ); + let invalid = SecretLocator::HttpsGitCredential { + server_id: "../".into(), + application_id: "app".into(), + }; + assert_eq!( + backend.create(&invalid, DEVICE, b"value"), + Err(SecretStoreError::InvalidReference) + ); + assert!(os.aliases().unwrap().is_empty()); + let passphrase = SecretLocator::OpenPgpPassphrase { + fingerprint: "0123456789ABCDEF0123456789ABCDEF01234567".into(), + }; + assert_eq!( + backend.create(&passphrase, DEVICE, b"value"), + Err(SecretStoreError::UnsupportedProtection) + ); + assert_eq!( + backend.create(&git(), SecretProtection::BiometryCurrentSet, b"value"), + Err(SecretStoreError::UnsupportedProtection) + ); + assert_eq!( + backend.create( + &SecretLocator::ApplicationBinding, + SecretProtection::RequireUserPresence, + b"value" + ), + Err(SecretStoreError::UnsupportedProtection) + ); + assert!(os.aliases().unwrap().is_empty()); + assert_eq!( + format!( + "{:?}", + AndroidCiphertext { + nonce: b"nonce".to_vec(), + bytes: b"secret".to_vec() + } + ), + "AndroidCiphertext([REDACTED])" + ); + } +} diff --git a/docs/android/keystore.md b/docs/android/keystore.md new file mode 100644 index 0000000..2fb9b4f --- /dev/null +++ b/docs/android/keystore.md @@ -0,0 +1,91 @@ +# Android protected secrets (#93) + +`crates/storage::secret_store::android` implements the secure-secret backend. +`MobilePrivateRuntime.androidSecretBackend` connects it to a narrow UniFFI OS +callback. Core translates callback types; platform calls Android Keystore, +KeyguardManager and AndroidX BiometricPrompt. No frontend reads a password-store +repository, chooses a credential identity/protection, persists ciphertext, or +implements encryption itself. App startup verifies its existing private device +binding using this real backend before presenting the Rust shell. + +The OS holds non-exportable AES-256-GCM keys. Rust stores versioned sealed +records under `noBackupFilesDir/ironstorage/secrets`, with 0700 directories and +0600 files. Service/account identities become SHA-256 names using +length-delimited namespace fields; OpenPGP fingerprints normalize to uppercase. +Each write creates a random generation-specific alias. GCM authenticates the +record version, identity, protection class and complete alias; its nonce is the +12-byte IV generated by the OS. Authentication happens before supplying AAD to +an authorization-bound cipher. No plaintext token, account record or passphrase +is serialized in ordinary settings, the layout marker or logs. + +| Storage policy | Android key and operation | +| --- | --- | +| DeviceUnlocked (Git credentials/device binding) | No interactive authentication; Keyguard checks before and after each operation, plus `setUnlockedDeviceRequired` from API 28 | +| BiometryCurrentSet (optional enrolled GPG passphrase) | Per-operation `BIOMETRIC_STRONG` CryptoObject, enrollment invalidation enabled, secure lock required; no PIN fallback | +| RequireUserPresence | From API 30, per-operation `BIOMETRIC_STRONG \| DEVICE_CREDENTIAL` CryptoObject; the configured credential flow is explicit | + +API 26–27 use the OS locked-state checks for DeviceUnlocked and the legacy +per-operation biometric key parameter. API 28–34 require a secure screen lock +because those releases have documented unlocked-key bugs. API 35+ support +unlocked keys without a secure lock. Unsupported protection returns a typed +error; it never downgrades an enrolled passphrase to device-only protection. +Rust restricts OpenPGP locators to the two interactive protection classes and +Git/device-binding locators to DeviceUnlocked. Factory failures become storage +recovery state in Rust; Kotlin only presents that state. +Android invalidates biometric-bound keys when a biometric is added or all +biometrics are removed; removing a secure lock also invalidates authentication +keys. Removing one of several original enrolled biometrics does not grant a new +biometric access. These are the [Android key-policy guarantees](https://developer.android.com/reference/android/security/keystore/KeyGenParameterSpec.Builder), +not a claim of extra OS enrollment APIs or physical-device validation. + +StrongBox is requested only when the framework advertises it. A typed +StrongBox-unavailable generation failure retries the same specification using +ordinary Keystore; authentication and unlocked-device requirements stay exact. +Other generation/cipher failures never trigger a weaker key or a retry without +authentication. The persistent emulators exercise the non-StrongBox path; Rust +mocks exercise the advertised-but-unavailable fallback. + +A private advisory lock serializes record transactions. Create rejects an +existing record; replace rejects an absent one. Replacement seals with a new +key, syncs a private atomic file, renames it and syncs the directory before +retiring the previous key. Cipher/authentication/write failure preserves the +previous record and removes the candidate key. If durability fails after rename, +the new key is retained because the new record may already be installed. +Delete unlinks the selected sealed record and deletes its key without requiring +biometric authentication, so disabling enrollment can remove protected data. +Startup removes only well-formed owned orphan aliases and unlocked owned atomic +files, retaining keys when any record cannot be parsed. Unrelated aliases are +left alone. Records are bounded to 16 KiB and startup enumeration is bounded. + +Missing ciphertext, missing device keys, invalidated keys, corruption, +duplicate, cancellation, denial, device lock, lockout, unsupported policy and +unavailability have separate Rust-visible errors. Existing bound data with a +missing Keystore key reports restore recovery and never receives a replacement +binding. Tampering or changing the requested policy fails closed. Background +lock increments the Rust generation and cancels the pending OS prompt; a late +cipher result cannot return plaintext for an obsolete generation. + +Kotlin uses the exact authorized Cipher returned in the CryptoObject callback, +one pending request, a bounded wait and cancellation. OS input arrays are +zeroed in `finally`; generated foreign callback return arrays are erased after +serialization. Rust immediately owns cleartext in `SecretBytes`, including +incoming create/replace buffers. Errors and generated diagnostics are redacted. +The JVM, OS cipher implementation and UniFFI serialization can still make +transient copies; this is not a guarantee of complete memory erasure. There is +no EncryptedSharedPreferences, MasterKey, alternative repository format or +application subprocess. + +Tests use public fixture secrets and synthetic emulator fingers. Rust exercises +all record operations, policy mappings, failed replacement, tampering, restore, +orphan ownership, lock/generation races, shared credential encoding and actual +mobile biometric-disable deletion. Android instrumentation uses real Keystore +keys and real CryptoObject prompts; its host is confined to the test APK. +Enrollment/lock removal deliberately invalidate only disposable fixture keys. + +Android 35 delivers the direct lockout callback, verified as `Lockout`. Android +37 instead offers an OS PIN screen to recover the sensor during biometric-only +authentication. The emulator test enters that PIN and verifies the operation +still requires a matching fingerprint; cancelling then returns `Cancelled` and +no cipher result. The PIN is never added to the biometric-only key's permitted +authenticators. Native callback tests also verify Rust-visible lockout, policy +rejection, factory recovery mapping and post-serialization array erasure. diff --git a/docs/android/parity.md b/docs/android/parity.md index 5b7739d..272f513 100644 --- a/docs/android/parity.md +++ b/docs/android/parity.md @@ -65,7 +65,7 @@ milestone. This list gives ownership, not permission to skip acceptance criteria | [#90 Private paths/config/cache/backup](https://git.rfc1437.de/hugo/IronStorage/issues/90) | crates/storage plus Android path adapter | Context roots/capabilities, atomic layout/migrations, backup rules; Rust and sandbox/update tests | | [#91 Platform-neutral mobile contract](https://git.rfc1437.de/hugo/IronStorage/issues/91) | crates/storage and shared UniFFI bridge | Typed semantic actions/icons, opt-in Watch data, platform callbacks, shared Swift/Kotlin inventory tests | | [#92 Native Rust/UniFFI packaging](https://git.rfc1437.de/hugo/IronStorage/issues/92) | Shared/Android Rust bridge and Gradle rust-bridge | cargo-ndk, generated immutable Kotlin/JNA Android, three ABIs, checksums/16 KB/loading/debug symbols | -| [#93 Keystore adapter](https://git.rfc1437.de/hugo/IronStorage/issues/93) | crates/storage secure-secret orchestration and platform adapter | Framework Keystore AES-GCM; callback facts, sealed records, invalidation/recovery tests | +| [#93 Keystore adapter](https://git.rfc1437.de/hugo/IronStorage/issues/93) | Delivered: Rust sealed-record orchestration and Android OS adapter | [Keystore policy and recovery](keystore.md); native AES-GCM/CryptoObject and Rust lifecycle tests | | [#94 CI/static analysis/reproducibility](https://git.rfc1437.de/hugo/IronStorage/issues/94) | Build/CI/test infrastructure | Cargo gates, Gradle build/Lint/ktlint, JVM/Compose/instrumented tests, verification/locks/SBOM/ABI checks | | [#95 Local-first/HTTPS onboarding](https://git.rfc1437.de/hugo/IronStorage/issues/95) | storage mobile_onboarding plus Compose features | Existing Rust initialization/discovery/clone/credentials; no Kotlin transport | | [#96 Hierarchical navigation](https://git.rfc1437.de/hugo/IronStorage/issues/96) | storage mobile_passwords plus Compose features | Material 3 Adaptive list/detail, typed rows/stable IDs | diff --git a/docs/android/private-storage.md b/docs/android/private-storage.md index 1de94df..3cf549a 100644 --- a/docs/android/private-storage.md +++ b/docs/android/private-storage.md @@ -17,7 +17,7 @@ activation fail instead of consulting HOME, cwd, XDG or public storage. | version / device binding | `noBackupFilesDir/ironstorage/layout.toml` | Atomic version 1 marker; unknown versions require a compatible migration | | transactional scratch | `noBackupFilesDir/ironstorage/scratch` | Per-owner file locks; startup removes orphaned transactions and skips live owners | | disposable cache | `cacheDir/ironstorage` | Only this capability is cleared by `clearDisposableCache`; vault/key/catalog data is retained | -| protected binding and credentials | OS secure backend / no-backup ciphertext (#93) | No serialized token or passphrase in configuration or layout metadata | +| protected binding and credentials | Keystore AES-GCM keys / `noBackupFilesDir/ironstorage/secrets` (#93) | No serialized token or passphrase in configuration or layout metadata | | `filesDir` | OS root fact only | No durable password-store object is placed here | Directory opens use safe Rust `rustix` handles with `NOFOLLOW`; names are single @@ -45,9 +45,9 @@ links without following their targets and cannot descend into the vault. persists only its SHA-256 in layout metadata. A missing or mismatched protected record for an already bound layout reports `RestoredWithoutKeystore` or `InvalidSecureBinding`; it never creates a replacement that would conceal key -loss. Locked/unavailable storage has separate recovery kinds. #93 supplies the -real Android Keystore adapter; the contract already executes through the -injected backend and is tested with loss/mismatch fixtures. +loss. Locked/unavailable storage has separate recovery kinds. #93 supplies the [real Android Keystore adapter](keystore.md); startup verifies +the binding through it before presenting the shell. The contract is tested with +loss/mismatch fixtures. Errors distinguish full storage, read-only storage, access denial, missing roots, concurrent initialization, corrupt configuration/layout and unsupported diff --git a/tools/android-build/src/bindings.rs b/tools/android-build/src/bindings.rs index c3802c0..28b7aaa 100644 --- a/tools/android-build/src/bindings.rs +++ b/tools/android-build/src/bindings.rs @@ -61,6 +61,14 @@ fn harden_source(source: &str) -> Result> { } let source = source.replace(internal, "class InternalException(@Suppress(\"UNUSED_PARAMETER\") message: String) : kotlin.Exception(\"Native Rust failure ([REDACTED])\")") .replace(callback, "val err = \"Native callback failed ([REDACTED])\""); + // Callback results are operation-owned; erase plaintext after copying into the wire buffer. + let returned_secret = "val writeReturn = { value: kotlin.ByteArray -> uniffiOutReturn.setValue(FfiConverterByteArray.lower(value)) }"; + if source.contains("internal object uniffiCallbackInterfaceMobileAndroidKeystore") + && source.matches(returned_secret).count() != 2 + { + return Err("UniFFI secret callback template changed; review byte-array lifetime".into()); + } + let source = source.replace(returned_secret, "val writeReturn = { value: kotlin.ByteArray -> try { uniffiOutReturn.setValue(FfiConverterByteArray.lower(value)) } finally { value.fill(0) } }"); let records = Regex::new(r"(?ms)^([ \t]*)data class (\w+)\s*\((.*?)\)[^\{]*\{\n")?; if !records.is_match(&source) { return Err("UniFFI record template changed; review diagnostic hardening".into()); @@ -128,6 +136,21 @@ mod tests { assert!(!hardened.contains("kotlin.Exception(message)")); } + #[test] + fn erases_owned_callback_results_after_serialization_even_on_failure() { + let source = SOURCE.to_owned() + + "val writeReturn = { value: kotlin.ByteArray -> uniffiOutReturn.setValue(FfiConverterByteArray.lower(value)) }"; + let hardened = harden_source(&source).unwrap(); + assert!(hardened.contains("finally { value.fill(0) }")); + assert!(hardened.contains("FfiConverterByteArray.lower(value)")); + assert!( + harden_source( + &(SOURCE.to_owned() + + "internal object uniffiCallbackInterfaceMobileAndroidKeystore") + ) + .is_err() + ); + } #[test] fn fails_closed_if_upstream_templates_or_integrity_checks_change() { assert!(harden_source("data class Secret(val value: String)").is_err());