From 0c8366d34ffc68db9ca2aab6b27f985dd6ca49a7 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Mon, 12 Oct 2026 19:18:10 +0200 Subject: [PATCH] Implement Android Credential Manager and Autofill providers (#109) --- .gitea/scripts/android_ci.py | 2 +- android/app/build.gradle.kts | 2 + android/app/gradle.lockfile | 5 +- .../CredentialSelectionScreenTest.kt | 108 ++++ .../ironstorage/ProviderFixtureSetupTest.kt | 51 ++ .../ironstorage/ProviderIntentSecurityTest.kt | 108 ++++ .../ironstorage/ProviderFixtureAccess.kt | 16 + android/app/src/main/AndroidManifest.xml | 17 + .../ironstorage/IronStorageApplication.kt | 8 +- .../de/rfc1437/ironstorage/ShellModule.kt | 7 + .../providers/CredentialSelectionActivity.kt | 362 +++++++++++ .../providers/IronAutofillService.kt | 173 +++++ .../IronCredentialProviderService.kt | 180 ++++++ .../ironstorage/providers/ProviderRequests.kt | 169 +++++ .../app/src/main/res/values-v34/provider.xml | 1 + android/app/src/main/res/values/provider.xml | 1 + .../app/src/main/res/xml/autofill_service.xml | 1 + .../src/main/res/xml/credential_provider.xml | 3 + android/build.gradle.kts | 2 + .../core/NativeCredentialProviderTest.kt | 186 ++++++ .../ironstorage/core/CredentialRepository.kt | 61 ++ .../core/NativeAuthenticationRepository.kt | 5 + .../core/NativeCredentialRepository.kt | 56 ++ android/dependency-policy.json | 146 +++-- .../features/CredentialSelectionScreen.kt | 172 +++++ .../ironstorage/features/SettingsScreen.kt | 7 + .../features/src/main/res/values/strings.xml | 19 +- android/gradle/libs.versions.toml | 5 + android/gradle/verification-metadata.xml | 165 +++++ android/performance/gradle.lockfile | 2 + android/provider-client/build.gradle.kts | 37 ++ android/provider-client/gradle.lockfile | 205 ++++++ .../src/main/AndroidManifest.xml | 14 + .../src/main/assets/provider_form.html | 14 + .../providerclient/BrowserFixtureProvider.kt | 68 ++ .../providerclient/ProviderClientActivity.kt | 162 +++++ .../src/main/res/xml/backup_rules.xml | 11 + .../main/res/xml/data_extraction_rules.xml | 24 + android/settings.gradle.kts | 2 +- crates/mobile-bridge/src/credentials.rs | 210 ++++++ crates/mobile-bridge/src/lib.rs | 3 + crates/storage/src/lib.rs | 2 + crates/storage/src/mobile_authentication.rs | 610 +++++++++++++++++- crates/storage/src/mobile_credentials.rs | 584 +++++++++++++++++ crates/storage/src/mobile_entry.rs | 4 + docs/android/credentials.md | 130 ++++ tools/android-build/src/gates.rs | 95 +++ tools/android-build/src/main.rs | 1 + 48 files changed, 4138 insertions(+), 78 deletions(-) create mode 100644 android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/CredentialSelectionScreenTest.kt create mode 100644 android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/ProviderFixtureSetupTest.kt create mode 100644 android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/ProviderIntentSecurityTest.kt create mode 100644 android/app/src/debug/kotlin/de/rfc1437/ironstorage/ProviderFixtureAccess.kt create mode 100644 android/app/src/main/kotlin/de/rfc1437/ironstorage/providers/CredentialSelectionActivity.kt create mode 100644 android/app/src/main/kotlin/de/rfc1437/ironstorage/providers/IronAutofillService.kt create mode 100644 android/app/src/main/kotlin/de/rfc1437/ironstorage/providers/IronCredentialProviderService.kt create mode 100644 android/app/src/main/kotlin/de/rfc1437/ironstorage/providers/ProviderRequests.kt create mode 100644 android/app/src/main/res/values-v34/provider.xml create mode 100644 android/app/src/main/res/values/provider.xml create mode 100644 android/app/src/main/res/xml/autofill_service.xml create mode 100644 android/app/src/main/res/xml/credential_provider.xml create mode 100644 android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeCredentialProviderTest.kt create mode 100644 android/core/src/main/kotlin/de/rfc1437/ironstorage/core/CredentialRepository.kt create mode 100644 android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeCredentialRepository.kt create mode 100644 android/features/src/main/kotlin/de/rfc1437/ironstorage/features/CredentialSelectionScreen.kt create mode 100644 android/provider-client/build.gradle.kts create mode 100644 android/provider-client/gradle.lockfile create mode 100644 android/provider-client/src/main/AndroidManifest.xml create mode 100644 android/provider-client/src/main/assets/provider_form.html create mode 100644 android/provider-client/src/main/kotlin/de/rfc1437/ironstorage/providerclient/BrowserFixtureProvider.kt create mode 100644 android/provider-client/src/main/kotlin/de/rfc1437/ironstorage/providerclient/ProviderClientActivity.kt create mode 100644 android/provider-client/src/main/res/xml/backup_rules.xml create mode 100644 android/provider-client/src/main/res/xml/data_extraction_rules.xml create mode 100644 crates/mobile-bridge/src/credentials.rs create mode 100644 crates/storage/src/mobile_credentials.rs create mode 100644 docs/android/credentials.md diff --git a/.gitea/scripts/android_ci.py b/.gitea/scripts/android_ci.py index 72c48fa..1ae2dc4 100644 --- a/.gitea/scripts/android_ci.py +++ b/.gitea/scripts/android_ci.py @@ -20,7 +20,7 @@ import unittest ROOT = Path(__file__).resolve().parents[2] ABIS = ("arm64-v8a", "armeabi-v7a") -MODULES = ("app", "core", "features", "platform", "rust-bridge") +MODULES = ("app", "core", "features", "platform", "rust-bridge", "provider-client") SDK = Path(os.environ.get("ANDROID_HOME", os.environ.get("ANDROID_SDK_ROOT", ""))) CARGO = os.environ.get("CARGO", "cargo") diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 443597e..27288ca 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -70,6 +70,8 @@ kotlin { } dependencies { + implementation(libs.credentials) + implementation(libs.autofill) implementation(project(":features")) implementation(project(":core")) implementation(project(":platform")) diff --git a/android/app/gradle.lockfile b/android/app/gradle.lockfile index 07bc964..5e1959a 100644 --- a/android/app/gradle.lockfile +++ b/android/app/gradle.lockfile @@ -14,8 +14,8 @@ androidx.appcompat:appcompat-resources:1.2.0=benchmarkLintChecksClasspath,benchm androidx.appcompat:appcompat:1.2.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.arch.core:core-common:2.2.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.arch.core:core-runtime:2.2.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.autofill:autofill:1.0.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.biometric:biometric:1.1.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.autofill:autofill:1.3.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.biometric:biometric:1.1.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.camera.featurecombinationquery:featurecombinationquery:1.6.2=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.camera.viewfinder:viewfinder-core:1.5.1=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.camera:camera-camera2-pipe:1.6.2=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -84,6 +84,7 @@ androidx.core:core-backported-fixes:1.0.0=benchmarkLintChecksClasspath,benchmark androidx.core:core-ktx:1.18.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.core:core-viewtree:1.0.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.core:core:1.18.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.credentials:credentials:1.6.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.cursoradapter:cursoradapter:1.0.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.customview:customview-poolingcontainer:1.0.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.customview:customview:1.0.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/CredentialSelectionScreenTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/CredentialSelectionScreenTest.kt new file mode 100644 index 0000000..c3e6b4e --- /dev/null +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/CredentialSelectionScreenTest.kt @@ -0,0 +1,108 @@ +package de.rfc1437.ironstorage + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import de.rfc1437.ironstorage.core.CredentialCandidate +import de.rfc1437.ironstorage.core.ProviderRequest +import de.rfc1437.ironstorage.features.CredentialSelectionScreen +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class CredentialSelectionScreenTest { + @get:Rule val compose = createAndroidComposeRule() + private var selected by mutableStateOf(null) + private var destination by mutableStateOf("") + private var consent by mutableStateOf(false) + private var overwrite by mutableStateOf(false) + private var query by mutableStateOf("") + private var passphrase by mutableStateOf("") + private var confirmed = 0 + private fun display(saving: Boolean) { + compose.activity.runOnUiThread { + compose.activity.setProtectedTestContent { + MaterialTheme { + CredentialSelectionScreen( + ProviderRequest( + "public-test-token", + "example.client", + "revision", + listOf( + CredentialCandidate("example.client/alice", true) + ) + ), + saving, + selected, + query, + destination, + passphrase, + consent, + overwrite, + false, + null, + onSelect = { + selected = it + consent = false + }, + onQuery = { query = it }, + onDestination = { + destination = it + consent = false + overwrite = false + }, + onPassphrase = { passphrase = it }, + onConsent = { consent = it }, + onOverwrite = { overwrite = it }, + onConfirm = { confirmed++ }, + onCancel = { + consent = false + passphrase = "" + } + ) + } + } + } + } + + @Test fun fillRequiresSelectionAndNamedCallerConsent() { + display(false) + compose.onNodeWithText("Unlock and fill").assertIsNotEnabled() + compose.onNodeWithText("example.client/alice").performClick() + compose.onNodeWithText("Unlock and fill").assertIsNotEnabled() + compose.onNodeWithText( + "Allow example.client to receive this entry's username and password" + ).performClick() + compose.onNodeWithText("Unlock and fill").assertIsEnabled().performClick() + assertEquals(1, confirmed) + compose.onNodeWithText("Choose another entry").performClick() + assertEquals(false, consent) + compose.onNodeWithText("Unlock and fill").assertIsNotEnabled() + } + + @Test fun saveRequiresDestinationAndChangingItClearsConsents() { + display(true) + compose.onNodeWithText("Confirm save").assertIsNotEnabled() + compose.onNodeWithText("Destination entry path").performTextInput("chosen-entry") + compose.onNodeWithText("Allow updating this destination if it exists").performClick() + compose.onNodeWithText( + "Save the supplied credential at the chosen destination" + ).performClick() + compose.onNodeWithText("Confirm save").assertIsEnabled() + compose.onNodeWithText("Destination entry path").performTextInput("-other") + compose.runOnIdle { + assertEquals(false, consent) + assertEquals(false, overwrite) + } + compose.onNodeWithText("Confirm save").assertIsNotEnabled() + compose.onNodeWithText("Cancel").performClick() + assertEquals(0, confirmed) + } +} diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/ProviderFixtureSetupTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/ProviderFixtureSetupTest.kt new file mode 100644 index 0000000..a95ce22 --- /dev/null +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/ProviderFixtureSetupTest.kt @@ -0,0 +1,51 @@ +package de.rfc1437.ironstorage + +import androidx.test.platform.app.InstrumentationRegistry +import dagger.hilt.EntryPoints +import de.rfc1437.ironstorage.core.CredentialCaller +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.junit.Assume.assumeTrue +import org.junit.Test + +class ProviderFixtureSetupTest { + /** Opt-in only. Never replaces a configured store or key on the retained emulator. */ + @Test fun seedEmptyEmulatorForComputerUse(): Unit = runBlocking { + assumeTrue(InstrumentationRegistry.getArguments().getString("providerFixture") == "true") + val context = InstrumentationRegistry.getInstrumentation().targetContext + val access = EntryPoints.get(context.applicationContext, ProviderFixtureAccess::class.java) + val passphrase = "public-provider-fixture-passphrase" + val setup = access.onboarding() + withContext(Dispatchers.Main.immediate) { setup.refresh() } + val before = withTimeout(30000) { setup.setup.first { !it.busy } } + check(!before.configured && !before.existingClone && before.keys.isEmpty()) + withContext(Dispatchers.Main.immediate) { + setup.generate("Provider emulator fixture", passphrase) + } + val generated = withTimeout(30000) { + setup.setup.first { + !it.busy && + it.selectedKey.isNotEmpty() + } + } + withContext(Dispatchers.Main.immediate) { + setup.createLocal(generated.selectedKey, passphrase) + } + withTimeout(30000) { setup.setup.first { !it.busy && it.configured } } + // Exercise exactly the explicit storage save contract used by both OS services. + val credentials = access.credentials() + val caller = CredentialCaller("de.rfc1437.ironstorage.test", listOf("AA".repeat(32)), null) + val request = credentials.begin(caller) + credentials.stage( + request.token, + caller, + "fixture-alice", + "public-provider-fixture-password" + ) + credentials.save(request.token, caller, "provider.example", true, false, passphrase) + withContext(Dispatchers.Main.immediate) { access.authentication().lock() } + } +} diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/ProviderIntentSecurityTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/ProviderIntentSecurityTest.kt new file mode 100644 index 0000000..3c2bf41 --- /dev/null +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/ProviderIntentSecurityTest.kt @@ -0,0 +1,108 @@ +package de.rfc1437.ironstorage + +import android.content.ComponentCallbacks2 +import android.content.Intent +import android.os.Build +import androidx.test.platform.app.InstrumentationRegistry +import dagger.hilt.EntryPoints +import de.rfc1437.ironstorage.core.CredentialCaller +import de.rfc1437.ironstorage.providers.ProviderContext +import de.rfc1437.ironstorage.providers.ProviderRequests +import de.rfc1437.ironstorage.providers.installedCaller +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test + +class ProviderIntentSecurityTest { + @Test + fun retainedSystemSaveContainsSubmittedFields(): Unit = runBlocking { + assumeTrue( + InstrumentationRegistry.getArguments().getString("providerSavedFixtureValidation") == + "true" + ) + val context = InstrumentationRegistry.getInstrumentation().targetContext + val caller = context.installedCaller("de.rfc1437.ironstorage.providerclient") + val access = EntryPoints.get(context.applicationContext, ProviderFixtureAccess::class.java) + withContext(Dispatchers.Main.immediate) { access.authentication().lock() } + for (path in listOf("sacrd", "afsave")) { + val request = access.credentials().begin(caller) + try { + val value = access.credentials().fill( + request.token, + caller, + path, + true, + "public-provider-fixture-passphrase", + emptyList() + ) + assertEquals("fixture-bob", value.username) + assertEquals("public-provider-new-password", value.password) + } finally { + access.credentials().cancel(request.token) + withContext(Dispatchers.Main.immediate) { access.authentication().lock() } + } + } + } + + @Suppress("DEPRECATION") + @Test + fun retainedFixtureCallerCanPrepareMetadata(): Unit = runBlocking { + assumeTrue( + InstrumentationRegistry.getArguments().getString("providerFixtureValidation") == "true" + ) + val context = InstrumentationRegistry.getInstrumentation().targetContext + val caller = context.installedCaller("de.rfc1437.ironstorage.providerclient") + val access = EntryPoints.get(context.applicationContext, ProviderFixtureAccess::class.java) + withContext(Dispatchers.Main.immediate) { access.authentication().lock() } + val request = access.credentials().begin(caller) + try { + assertTrue(request.candidates.any { it.path == "provider.example" }) + withContext(Dispatchers.Main.immediate) { + val app = context.applicationContext as IronStorageApplication + app.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) + app.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) + } + assertTrue(access.credentials().request(request.token, caller).candidates.isNotEmpty()) + withContext(Dispatchers.Main.immediate) { + (context.applicationContext as IronStorageApplication).onTrimMemory( + ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW + ) + } + assertTrue( + runCatching { + access.credentials().request(request.token, caller) + }.isFailure + ) + } finally { + access.credentials().cancel(request.token) + } + } + + @Test fun inlineAttributionCannotInvokeThePrivateAuthenticationActivity() { + assumeTrue(Build.VERSION.SDK_INT >= 31) + val context = InstrumentationRegistry.getInstrumentation().targetContext + assertNull(ProviderRequests.get(Intent())) + val token = "public-pending-intent-fixture" + val caller = CredentialCaller("example.client", listOf("AA".repeat(32)), null) + try { + val authentication = ProviderRequests.put( + context, + ProviderContext(token, caller, "autofill"), + "fixture" + ) + val attribution = ProviderRequests.attribution(context) + assertTrue(attribution.isImmutable) + assertFalse(authentication.isImmutable) + assertNotEquals(authentication, attribution) + } finally { + ProviderRequests.remove(token) + } + } +} diff --git a/android/app/src/debug/kotlin/de/rfc1437/ironstorage/ProviderFixtureAccess.kt b/android/app/src/debug/kotlin/de/rfc1437/ironstorage/ProviderFixtureAccess.kt new file mode 100644 index 0000000..0bc002f --- /dev/null +++ b/android/app/src/debug/kotlin/de/rfc1437/ironstorage/ProviderFixtureAccess.kt @@ -0,0 +1,16 @@ +package de.rfc1437.ironstorage + +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import de.rfc1437.ironstorage.core.NativeAuthenticationRepository +import de.rfc1437.ironstorage.core.NativeCredentialRepository +import de.rfc1437.ironstorage.core.OnboardingRepository + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface ProviderFixtureAccess { + fun onboarding(): OnboardingRepository + fun authentication(): NativeAuthenticationRepository + fun credentials(): NativeCredentialRepository +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0ccb4e4..0500fc7 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,9 +3,14 @@ + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/de/rfc1437/ironstorage/IronStorageApplication.kt b/android/app/src/main/kotlin/de/rfc1437/ironstorage/IronStorageApplication.kt index 49e2913..19f0dd1 100644 --- a/android/app/src/main/kotlin/de/rfc1437/ironstorage/IronStorageApplication.kt +++ b/android/app/src/main/kotlin/de/rfc1437/ironstorage/IronStorageApplication.kt @@ -1,6 +1,7 @@ package de.rfc1437.ironstorage import android.app.Application +import android.content.ComponentCallbacks2 import dagger.hilt.android.HiltAndroidApp import de.rfc1437.ironstorage.core.AuthenticationLifecycle import de.rfc1437.ironstorage.core.AuthenticationRepository @@ -16,7 +17,12 @@ class IronStorageApplication : Application() { @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") override fun onTrimMemory(level: Int) { - authentication.lifecycle(AuthenticationLifecycle.MemoryPressure) + // UI/cache transitions also occur while the system picker holds a non-secret request. + if (level < ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN || + level >= ComponentCallbacks2.TRIM_MEMORY_MODERATE + ) { + authentication.lifecycle(AuthenticationLifecycle.MemoryPressure) + } super.onTrimMemory(level) } diff --git a/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt b/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt index 2702913..4bdfad6 100644 --- a/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt +++ b/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt @@ -38,6 +38,13 @@ import kotlinx.coroutines.SupervisorJob @Module @InstallIn(SingletonComponent::class) object ShellModule { + @Provides + @Singleton + fun credentials( + authentication: NativeAuthenticationRepository + ): de.rfc1437.ironstorage.core.NativeCredentialRepository = + de.rfc1437.ironstorage.core.NativeCredentialRepository(authentication) + @Provides @Singleton fun androidSettingsPlatform( diff --git a/android/app/src/main/kotlin/de/rfc1437/ironstorage/providers/CredentialSelectionActivity.kt b/android/app/src/main/kotlin/de/rfc1437/ironstorage/providers/CredentialSelectionActivity.kt new file mode 100644 index 0000000..3a40dc3 --- /dev/null +++ b/android/app/src/main/kotlin/de/rfc1437/ironstorage/providers/CredentialSelectionActivity.kt @@ -0,0 +1,362 @@ +package de.rfc1437.ironstorage.providers + +import android.app.KeyguardManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import android.os.Bundle +import android.service.autofill.Dataset +import android.view.View +import android.view.WindowManager +import android.view.autofill.AutofillManager +import android.view.autofill.AutofillManager.EXTRA_AUTHENTICATION_RESULT_EPHEMERAL_DATASET +import android.view.autofill.AutofillValue +import android.widget.RemoteViews +import androidx.activity.compose.setContent +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.credentials.CreatePasswordRequest +import androidx.credentials.CreatePasswordResponse +import androidx.credentials.GetCredentialResponse +import androidx.credentials.GetPasswordOption +import androidx.credentials.PasswordCredential +import androidx.credentials.provider.PendingIntentHandler +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import de.rfc1437.ironstorage.core.AuthenticationLifecycle +import de.rfc1437.ironstorage.core.AuthenticationRepository +import de.rfc1437.ironstorage.core.NativeCredentialRepository +import de.rfc1437.ironstorage.core.ProviderRequest +import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter +import javax.inject.Inject +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout + +@AndroidEntryPoint +class CredentialSelectionActivity : FragmentActivity() { + @Inject lateinit var credentials: NativeCredentialRepository + + @Inject lateinit var authentication: AuthenticationRepository + + @Inject lateinit var keystore: AndroidKeystoreAdapter + private var context: ProviderContext? = null + private var request by mutableStateOf(null) + private var selected by mutableStateOf(null) + private var query by mutableStateOf("") + private var destination by mutableStateOf("") + private var passphrase by mutableStateOf("") + private var consent by mutableStateOf(false) + private var overwrite by mutableStateOf(false) + private var busy by mutableStateOf(false) + private var failure by mutableStateOf(null) + private var delivered = false + private var searchSequence = 0 + private var cleanup: AutoCloseable? = null + private val screenState = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Intent.ACTION_SCREEN_OFF) cancel() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + window.decorView.filterTouchesWhenObscured = true + cleanup = authentication.presentation.register { + passphrase = "" + if (!delivered) finish() + } + val screenFilter = IntentFilter(Intent.ACTION_SCREEN_OFF) + if (Build.VERSION.SDK_INT >= + 33 + ) { + registerReceiver(screenState, screenFilter, RECEIVER_NOT_EXPORTED) + } else { + @Suppress("UnspecifiedRegisterReceiverFlag") + registerReceiver(screenState, screenFilter) + } + if (Build.VERSION.SDK_INT >= 33) setRecentsScreenshotEnabled(false) + window.decorView.importantForAutofill = View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS + if (Build.VERSION.SDK_INT >= + 30 + ) { + window.decorView.importantForContentCapture = + View.IMPORTANT_FOR_CONTENT_CAPTURE_NO_EXCLUDE_DESCENDANTS + } + setResult(RESULT_CANCELED) + keystore.attach(this) + context = ProviderRequests.get(intent) + selected = context?.selected + if (context == null || savedInstanceState != null || + getSystemService(KeyguardManager::class.java).isDeviceLocked + ) { + finish() + return + } + lifecycleScope.launch { + try { + val pending = requireNotNull(context) + when (pending.mode) { + "credential" -> { + if (Build.VERSION.SDK_INT < 34) error("Unsupported credential platform") + val supplied = + requireNotNull( + PendingIntentHandler.retrieveProviderGetCredentialRequest(intent) + ) + require( + credentialCaller(supplied.callingAppInfo, credentials) == pending.caller + ) + val options = supplied.credentialOptions + .filterIsInstance() + require( + options.size == 1 && + options.single().allowedUserIds.toList().sorted() == + pending.allowedUsernames.sorted() + ) + } + "create" -> { + if (Build.VERSION.SDK_INT < 34) error("Unsupported credential platform") + val supplied = + requireNotNull( + PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent) + ) + require( + credentialCaller(supplied.callingAppInfo, credentials) == pending.caller + ) + val password = supplied.callingRequest as CreatePasswordRequest + credentials.stage( + pending.token, + pending.caller, + password.id, + password.password + ) + } + "autofill" -> { + @Suppress("DEPRECATION") + val structure = + requireNotNull( + intent.getParcelableExtra( + AutofillManager.EXTRA_ASSIST_STRUCTURE + ) + ) + val facts = autofillFacts(structure, credentials) + require( + facts.caller == pending.caller && facts.username == pending.username && + facts.password == pending.password + ) + } + "save" -> require(installedCaller(pending.caller.packageName) == pending.caller) + else -> error("Unsupported request") + } + // System requests can contain secrets. Retain only the bound metadata. + intent = Intent().setAction(intent.action).setData(intent.data) + request = credentials.request(pending.token, pending.caller) + } catch (_: Exception) { + cancel() + return@launch + } + while (!isFinishing) { + delay(1_000) + if (!busy) { + try { + credentials.request(pendingToken(), requireNotNull(context).caller, query) + } catch ( + _: Exception + ) { + cancel() + return@launch + } + } + } + } + setContent { + de.rfc1437.ironstorage.features.ProtectedContent(authentication.presentation) { + de.rfc1437.ironstorage.features.ApplicationTheme(authentication) { + de.rfc1437.ironstorage.features.CredentialSelectionScreen( + request, context?.mode in listOf("create", "save"), selected, query, + destination, passphrase, consent, overwrite, busy, failure, + onSelect = { + selected = it + consent = false + }, + onQuery = { + query = it + search() + }, + onDestination = { + destination = it + consent = false + overwrite = false + }, + onPassphrase = { passphrase = it }, + onConsent = { consent = it }, + onOverwrite = { overwrite = it }, + onConfirm = ::confirm, + onCancel = ::cancel + ) + } + } + } + } + private fun pendingToken() = requireNotNull(context).token + private fun search() { + val sequence = ++searchSequence + lifecycleScope.launch { + delay(150) + if (sequence != searchSequence || busy) return@launch + try { + val result = credentials.request( + pendingToken(), + requireNotNull(context).caller, + query + ) + if (sequence == searchSequence) request = result + } catch (_: Exception) { + if (sequence == searchSequence && !busy) cancel() + } + } + } + private fun confirm() { + if (busy) return + searchSequence++ + busy = true + failure = null + val suppliedPassphrase = passphrase.takeIf { it.isNotEmpty() } + passphrase = "" + lifecycleScope.launch { + try { + val pending = requireNotNull(context) + check(!getSystemService(KeyguardManager::class.java).isDeviceLocked) + val caller = installedCaller(pending.caller.packageName) + .copy(origin = pending.caller.origin) + val output = Intent() + withTimeout(120_000) { + when (pending.mode) { + "create", "save" -> { + credentials.save( + pending.token, + caller, + destination, + consent, + overwrite, + suppliedPassphrase + ) + if (pending.mode == + "create" + ) { + PendingIntentHandler.setCreateCredentialResponse( + output, + CreatePasswordResponse() + ) + } + } + else -> { + val value = credentials.fill( + pending.token, + caller, + requireNotNull(selected), + consent, + suppliedPassphrase, + pending.allowedUsernames + ) + if (pending.mode == "credential") { + PendingIntentHandler.setGetCredentialResponse( + output, + GetCredentialResponse( + PasswordCredential(value.username, value.password) + ) + ) + } else { + val display = RemoteViews( + packageName, + android.R.layout.simple_list_item_1 + ).apply { + setTextViewText(android.R.id.text1, requireNotNull(selected)) + } + + @Suppress("DEPRECATION") + val dataset = Dataset.Builder(display) + .setValue( + requireNotNull(pending.username), + AutofillValue.forText(value.username) + ) + .setValue( + requireNotNull(pending.password), + AutofillValue.forText(value.password) + ).build() + output.putExtra( + AutofillManager.EXTRA_AUTHENTICATION_RESULT, + dataset + ) + if (Build.VERSION.SDK_INT >= + 31 + ) { + output.putExtra( + EXTRA_AUTHENTICATION_RESULT_EPHEMERAL_DATASET, + true + ) + } + } + } + } + } + delivered = true + ProviderRequests.remove(pending.token) + setResult(RESULT_OK, output) + finish() + } catch (_: Exception) { + failure = authentication.state.value.failure?.let { value -> + "${value.title}\n${value.detail}" + } ?: "The request could not be completed. Cancel and request again." + } finally { + busy = false + } + } + } + private fun cancel() { + authentication.cancel() + passphrase = "" + setResult(RESULT_CANCELED) + finish() + } + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (!hasFocus && !keystore.authenticationPending && !delivered) { + passphrase = "" + authentication.lifecycle(AuthenticationLifecycle.Background) + finish() + } + } + override fun onUserLeaveHint() { + if (!keystore.authenticationPending && !delivered) cancel() + super.onUserLeaveHint() + } + override fun onStop() { + if (!keystore.authenticationPending && !delivered) cancel() + super.onStop() + } + override fun onPause() { + passphrase = "" + if (!keystore.authenticationPending) { + authentication.lifecycle(AuthenticationLifecycle.Background) + if (!delivered) finish() + } + super.onPause() + } + override fun onDestroy() { + cleanup?.close() + unregisterReceiver(screenState) + passphrase = "" + context?.let { pending -> + ProviderRequests.remove(pending.token) + } + authentication.lock() + keystore.detach(this) + super.onDestroy() + } +} diff --git a/android/app/src/main/kotlin/de/rfc1437/ironstorage/providers/IronAutofillService.kt b/android/app/src/main/kotlin/de/rfc1437/ironstorage/providers/IronAutofillService.kt new file mode 100644 index 0000000..a8196cc --- /dev/null +++ b/android/app/src/main/kotlin/de/rfc1437/ironstorage/providers/IronAutofillService.kt @@ -0,0 +1,173 @@ +package de.rfc1437.ironstorage.providers + +import android.app.assist.AssistStructure +import android.os.Build +import android.os.CancellationSignal +import android.service.autofill.AutofillService +import android.service.autofill.Dataset +import android.service.autofill.FillCallback +import android.service.autofill.FillRequest +import android.service.autofill.FillResponse +import android.service.autofill.InlinePresentation +import android.service.autofill.SaveCallback +import android.service.autofill.SaveInfo +import android.service.autofill.SaveRequest +import android.widget.RemoteViews +import androidx.autofill.inline.UiVersions +import androidx.autofill.inline.v1.InlineSuggestionUi +import dagger.hilt.android.AndroidEntryPoint +import de.rfc1437.ironstorage.core.NativeCredentialRepository +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout + +@AndroidEntryPoint +class IronAutofillService : AutofillService() { + @Inject lateinit var credentials: NativeCredentialRepository + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + @Suppress("DEPRECATION") + override fun onFillRequest( + request: FillRequest, + cancellationSignal: CancellationSignal, + callback: FillCallback + ) { + var token: String? = null + val job = scope.launch { + try { + val facts = autofillFacts(request.fillContexts.last().structure, credentials) + val result = withTimeout(5_000) { credentials.begin(facts.caller) } + token = result.token + if (cancellationSignal.isCanceled) { + credentials.cancel(result.token) + return@launch + } + val pending = ProviderRequests.put( + this@IronAutofillService, + ProviderContext( + result.token, + facts.caller, + "autofill", + username = facts.username, + password = facts.password + ), + "autofill" + ) + val presentation = RemoteViews( + packageName, + android.R.layout.simple_list_item_1 + ).apply { + setTextViewText(android.R.id.text1, "Unlock and search IronStorage") + } + + @Suppress("DEPRECATION") + val dataset = Dataset.Builder(presentation) + .setValue(facts.username, null) + .setValue(facts.password, null) + .setAuthentication(pending.intentSender) + if (Build.VERSION.SDK_INT >= 30) { + val spec = request.inlineSuggestionsRequest + ?.inlinePresentationSpecs?.firstOrNull() + if (spec != null && + UiVersions.getVersions(spec.style).contains(UiVersions.INLINE_UI_VERSION_1) + ) { + val content: UiVersions.Content = InlineSuggestionUi.newContentBuilder( + ProviderRequests.attribution(this@IronAutofillService) + ).setTitle("IronStorage").setSubtitle("Unlock and search").build() + dataset.setInlinePresentation( + InlinePresentation(content.slice, spec, false) + ) + } + } + val response = FillResponse.Builder().addDataset(dataset.build()) + .setSaveInfo( + SaveInfo.Builder( + SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD, + arrayOf(facts.username, facts.password) + ).build() + ) + .build() + callback.onSuccess(response) + } catch (_: Exception) { + if (!cancellationSignal.isCanceled) callback.onSuccess(null) + } + } + cancellationSignal.setOnCancelListener { + job.cancel() + token?.let { value -> + ProviderRequests.remove(value) + scope.launch { credentials.cancel(value) } + } + } + } + + override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) { + scope.launch { + try { + val structure = request.fillContexts.last().structure + val facts = autofillFacts(structure, credentials) + fun value(id: android.view.autofill.AutofillId): String { + fun find(node: AssistStructure.ViewNode): String? { + if (node.autofillId == + id + ) { + return node.autofillValue?.takeIf { + it.isText + }?.textValue?.toString() + } + for (index in 0 until node.childCount) { + find(node.getChildAt(index))?.let { + return it + } + } + return null + } + for (index in 0 until structure.windowNodeCount) { + find( + structure.getWindowNodeAt(index).rootViewNode + )?.let { + return it + } + } + error("The system did not supply the credential field.") + } + val result = withTimeout(5_000) { + credentials.begin(facts.caller).also { + credentials.stage( + it.token, + facts.caller, + value(facts.username), + value(facts.password) + ) + } + } + val pending = ProviderRequests.put( + this@IronAutofillService, + ProviderContext(result.token, facts.caller, "save"), + "save" + ) + if (Build.VERSION.SDK_INT >= 28) { + callback.onSuccess(pending.intentSender) + } else { + pending.send() + callback.onSuccess() + } + } catch ( + _: Exception + ) { + callback.onFailure( + "IronStorage could not offer this save. Open the entry editor to save manually." + ) + } + } + } + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } +} diff --git a/android/app/src/main/kotlin/de/rfc1437/ironstorage/providers/IronCredentialProviderService.kt b/android/app/src/main/kotlin/de/rfc1437/ironstorage/providers/IronCredentialProviderService.kt new file mode 100644 index 0000000..0704e4f --- /dev/null +++ b/android/app/src/main/kotlin/de/rfc1437/ironstorage/providers/IronCredentialProviderService.kt @@ -0,0 +1,180 @@ +package de.rfc1437.ironstorage.providers + +import android.os.CancellationSignal +import android.os.OutcomeReceiver +import androidx.annotation.RequiresApi +import androidx.credentials.exceptions.ClearCredentialException +import androidx.credentials.exceptions.CreateCredentialException +import androidx.credentials.exceptions.CreateCredentialUnknownException +import androidx.credentials.exceptions.GetCredentialException +import androidx.credentials.exceptions.GetCredentialUnknownException +import androidx.credentials.provider.BeginCreateCredentialRequest +import androidx.credentials.provider.BeginCreateCredentialResponse +import androidx.credentials.provider.BeginCreatePasswordCredentialRequest +import androidx.credentials.provider.BeginGetCredentialRequest +import androidx.credentials.provider.BeginGetCredentialResponse +import androidx.credentials.provider.BeginGetPasswordOption +import androidx.credentials.provider.CreateEntry +import androidx.credentials.provider.CredentialProviderService +import androidx.credentials.provider.PasswordCredentialEntry +import androidx.credentials.provider.ProviderClearCredentialStateRequest +import dagger.hilt.android.AndroidEntryPoint +import de.rfc1437.ironstorage.core.AuthenticationRepository +import de.rfc1437.ironstorage.core.NativeCredentialRepository +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout + +@RequiresApi(34) +@AndroidEntryPoint +class IronCredentialProviderService : CredentialProviderService() { + @Inject lateinit var credentials: NativeCredentialRepository + + @Inject lateinit var authentication: AuthenticationRepository + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + override fun onBeginGetCredentialRequest( + request: BeginGetCredentialRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver + ) { + var token: String? = null + val job = scope.launch { + try { + val caller = credentialCaller(requireNotNull(request.callingAppInfo), credentials) + val options = request.beginGetCredentialOptions + .filterIsInstance() + require(options.isNotEmpty() && options.size <= 16) + val result = withTimeout(5_000) { credentials.begin(caller) } + token = result.token + if (cancellationSignal.isCanceled) { + credentials.cancel(result.token) + return@launch + } + val budget = (20 / options.size - 1).coerceAtLeast(0) + val entries = options.flatMapIndexed { optionIndex, option -> + val candidates = result.candidates.take(budget).mapIndexed { index, candidate -> + PasswordCredentialEntry( + this@IronCredentialProviderService, + candidate.path, + ProviderRequests.put( + this@IronCredentialProviderService, + ProviderContext( + result.token, + caller, + "credential", + candidate.path, + allowedUsernames = option.allowedUserIds.toList() + ), + "$optionIndex/$index" + ), + option, + displayName = candidate.path, + isAutoSelectAllowed = false + ) + } + // Manual search retains the selected OS option and its account constraint. + val search = PasswordCredentialEntry( + this@IronCredentialProviderService, + "Search IronStorage", + ProviderRequests.put( + this@IronCredentialProviderService, + ProviderContext( + result.token, + caller, + "credential", + allowedUsernames = option.allowedUserIds.toList() + ), + "$optionIndex/search" + ), + option, + isAutoSelectAllowed = false + ) + candidates + search + } + callback.onResult(BeginGetCredentialResponse(credentialEntries = entries)) + } catch (_: Exception) { + if (!cancellationSignal.isCanceled) { + callback.onError( + GetCredentialUnknownException("IronStorage could not prepare this request.") + ) + } + } + } + cancellationSignal.setOnCancelListener { + job.cancel() + token?.let { value -> + ProviderRequests.remove(value) + scope.launch { credentials.cancel(value) } + } + } + } + + override fun onBeginCreateCredentialRequest( + request: BeginCreateCredentialRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver + ) { + var token: String? = null + val job = scope.launch { + try { + require(request is BeginCreatePasswordCredentialRequest) + val caller = credentialCaller(requireNotNull(request.callingAppInfo), credentials) + val result = withTimeout(5_000) { credentials.begin(caller) } + token = result.token + if (cancellationSignal.isCanceled) { + credentials.cancel(result.token) + return@launch + } + callback.onResult( + BeginCreateCredentialResponse( + listOf( + CreateEntry( + "IronStorage", + ProviderRequests.put( + this@IronCredentialProviderService, + ProviderContext(result.token, caller, "create"), + "create" + ), + description = "Choose a destination and confirm saving" + ) + ) + ) + ) + } catch (_: Exception) { + if (!cancellationSignal.isCanceled) { + callback.onError( + CreateCredentialUnknownException( + "IronStorage could not prepare this save request." + ) + ) + } + } + } + cancellationSignal.setOnCancelListener { + job.cancel() + token?.let { value -> + ProviderRequests.remove(value) + scope.launch { credentials.cancel(value) } + } + } + } + + override fun onClearCredentialStateRequest( + request: ProviderClearCredentialStateRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver + ) { + ProviderRequests.clear() + authentication.lock() + callback.onResult(null) + } + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } +} diff --git a/android/app/src/main/kotlin/de/rfc1437/ironstorage/providers/ProviderRequests.kt b/android/app/src/main/kotlin/de/rfc1437/ironstorage/providers/ProviderRequests.kt new file mode 100644 index 0000000..cd81558 --- /dev/null +++ b/android/app/src/main/kotlin/de/rfc1437/ironstorage/providers/ProviderRequests.kt @@ -0,0 +1,169 @@ +package de.rfc1437.ironstorage.providers + +import android.app.PendingIntent +import android.app.assist.AssistStructure +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.view.autofill.AutofillId +import androidx.annotation.RequiresApi +import androidx.credentials.provider.CallingAppInfo +import de.rfc1437.ironstorage.MainActivity +import de.rfc1437.ironstorage.core.CredentialAttribute +import de.rfc1437.ironstorage.core.CredentialCaller +import de.rfc1437.ironstorage.core.CredentialField +import de.rfc1437.ironstorage.core.NativeCredentialRepository +import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap + +internal data class ProviderContext( + val token: String, + val caller: CredentialCaller, + val mode: String, + val selected: String? = null, + val username: AutofillId? = null, + val password: AutofillId? = null, + val allowedUsernames: List = emptyList(), + val created: Long = android.os.SystemClock.elapsedRealtime() +) + +/** Contains only request metadata. Lost process state always requires a new system request. */ +internal object ProviderRequests { + private val requests = ConcurrentHashMap() + fun attribution(context: Context): PendingIntent = PendingIntent.getActivity( + context, + 0, + Intent(context, MainActivity::class.java) + .setAction("de.rfc1437.ironstorage.OPEN_PROVIDER"), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + fun put(context: Context, request: ProviderContext, suffix: String): PendingIntent { + val now = android.os.SystemClock.elapsedRealtime() + requests.entries.removeIf { now - it.value.created >= 120_000 } + require(requests.size < 512) + val key = "${request.token}/$suffix" + requests[key] = request + val intent = Intent(context, CredentialSelectionActivity::class.java) + .setAction("de.rfc1437.ironstorage.SELECT_CREDENTIAL") + .setData( + Uri.Builder().scheme( + "ironstorage-provider" + ).authority("request").appendEncodedPath(key).build() + ) + return PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + } + fun get(intent: Intent): ProviderContext? = + intent.data?.path?.removePrefix("/")?.let(requests::get) + fun remove(token: String) { + requests.entries.removeIf { it.value.token == token } + } + fun clear() { + requests.clear() + } +} + +internal fun Context.installedCaller(packageName: String): CredentialCaller { + @Suppress("DEPRECATION") + val info = packageManager.getPackageInfo( + packageName, + if (Build.VERSION.SDK_INT >= + 28 + ) { + PackageManager.GET_SIGNING_CERTIFICATES + } else { + PackageManager.GET_SIGNATURES + } + ) + + @Suppress("DEPRECATION") + val signatures = if (Build.VERSION.SDK_INT >= + 28 + ) { + info.signingInfo?.apkContentsSigners + } else { + info.signatures + } + val fingerprints = requireNotNull(signatures).map { signature -> + MessageDigest.getInstance("SHA-256").digest(signature.toByteArray()).joinToString("") { + "%02X".format(it) + } + } + return CredentialCaller(packageName, fingerprints, null) +} + +@RequiresApi(34) +internal suspend fun Context.credentialCaller( + info: CallingAppInfo, + credentials: NativeCredentialRepository +): CredentialCaller { + val installed = installedCaller(info.packageName) + val supplied = info.signingInfoCompat.apkContentsSigners.map { signature -> + MessageDigest.getInstance("SHA-256").digest(signature.toByteArray()).joinToString("") { + "%02X".format(it) + } + } + require(installed.certificates.sorted() == supplied.sorted()) + val origin = try { + info.getOrigin(credentials.privilegedCallers()) + } catch ( + _: IllegalStateException + ) { + null + } + return installed.copy(origin = origin) +} + +internal data class AutofillFacts( + val caller: CredentialCaller, + val username: AutofillId, + val password: AutofillId +) + +internal suspend fun Context.autofillFacts( + structure: AssistStructure, + credentials: NativeCredentialRepository +): AutofillFacts { + val caller = installedCaller(structure.activityComponent.packageName) + val ids = mutableListOf() + val fields = mutableListOf() + var visited = 0 + fun visit(node: AssistStructure.ViewNode, depth: Int) { + require(depth < 64 && ++visited <= 2048) + node.autofillId?.let { id -> + require(fields.size < 512) + val existing = ids.indexOf(id) + val index = if (existing < 0) { + ids += id + (ids.size - 1).toUInt() + } else { + existing.toUInt() + } + fields += CredentialField( + index, + node.autofillHints?.toList() ?: emptyList(), + node.htmlInfo?.attributes?.map { CredentialAttribute(it.first, it.second) } + ?: emptyList(), + node.inputType.toUInt(), + node.isFocused, + node.autofillType == android.view.View.AUTOFILL_TYPE_TEXT + ) + } + for (index in 0 until node.childCount) visit(node.getChildAt(index), depth + 1) + } + require(structure.windowNodeCount <= 16) + for (index in 0 until structure.windowNodeCount) { + visit( + structure.getWindowNodeAt(index).rootViewNode, + 0 + ) + } + val form = credentials.form(fields) + return AutofillFacts(caller, ids[form.username.toInt()], ids[form.password.toInt()]) +} diff --git a/android/app/src/main/res/values-v34/provider.xml b/android/app/src/main/res/values-v34/provider.xml new file mode 100644 index 0000000..0b012bb --- /dev/null +++ b/android/app/src/main/res/values-v34/provider.xml @@ -0,0 +1 @@ +true diff --git a/android/app/src/main/res/values/provider.xml b/android/app/src/main/res/values/provider.xml new file mode 100644 index 0000000..6e808e6 --- /dev/null +++ b/android/app/src/main/res/values/provider.xml @@ -0,0 +1 @@ +false diff --git a/android/app/src/main/res/xml/autofill_service.xml b/android/app/src/main/res/xml/autofill_service.xml new file mode 100644 index 0000000..dc016e8 --- /dev/null +++ b/android/app/src/main/res/xml/autofill_service.xml @@ -0,0 +1 @@ + diff --git a/android/app/src/main/res/xml/credential_provider.xml b/android/app/src/main/res/xml/credential_provider.xml new file mode 100644 index 0000000..761f72d --- /dev/null +++ b/android/app/src/main/res/xml/credential_provider.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 42fb536..c768ca3 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -17,6 +17,7 @@ plugins { } val allowed = mapOf( + ":provider-client" to emptySet(), ":performance" to setOf(":app"), ":app" to setOf(":features", ":core", ":platform"), ":features" to setOf(":core"), @@ -133,6 +134,7 @@ tasks.register("dependencyInventory") { } tasks.register("androidVerification") { + dependsOn(":provider-client:assembleDebug") dependsOn("checkArchitecture", "dependencyInventory", "auditNativePackages") dependsOn(subprojects.map { "${it.path}:ktlintCheck" }) dependsOn(subprojects.filter { it.name != "performance" }.map { "${it.path}:lintDebug" }) diff --git a/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeCredentialProviderTest.kt b/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeCredentialProviderTest.kt new file mode 100644 index 0000000..244afad --- /dev/null +++ b/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeCredentialProviderTest.kt @@ -0,0 +1,186 @@ +package de.rfc1437.ironstorage.core + +import androidx.test.platform.app.InstrumentationRegistry +import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind +import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput +import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Actual native GPG and repository transactions through the production provider facade. */ +class NativeCredentialProviderTest { + @Test fun lockedFillAndSavesRejectStaleRequests(): Unit = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val name = NativeEntryViewerTest.rootName + val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map { + File(it, name).apply { mkdirs() } + } + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + val session = + NativePrivateSession(context, { + PrivateDirectories( + roots[0].canonicalPath, + roots[1].canonicalPath, + roots[2].canonicalPath + ) + }, NativeEntryViewerTest.MemoryKeystore()) + val authentication = NativeAuthenticationRepository(session, scope, Dispatchers.IO) + val credentials = NativeCredentialRepository(authentication) + val caller = + CredentialCaller( + "example.nativeclient", + listOf("AA".repeat(32)), + "https://untrusted.example/" + ) + val passphrase = "public-credential-fixture-passphrase" + suspend fun rejects(block: suspend () -> Unit) { + var rejected = false + try { + block() + } catch (_: Exception) { + rejected = true + } + assertTrue(rejected) + } + try { + withContext(Dispatchers.IO) { + session.initialize() + val setup = session.storage().androidOnboarding(session.backend()) + val key = + requireNotNull(setup.generate("Credential fixture", passphrase).selectedKey) + setup.setupLocal(key, passphrase) + } + authentication.authenticated { auth -> + val editor = auth.beginCreateEntry("", "example.nativeclient", passphrase) + val page = auth.addEntryEditorField( + editor.id, + MobileEntryEditorFieldKind.FIELD, + "username", + "alice" + ) + auth.saveEntryEditor( + editor.id, + page.fields.map { + MobileEntryEditorInput( + it.id, + it.name, + if (it.name == + "username" + ) { + "alice" + } else { + "fixture-password" + } + ) + } + ) + } + withContext(Dispatchers.Main.immediate) { authentication.lock() } + val request = credentials.begin(caller) + assertEquals("example.nativeclient", request.title) + assertFalse(authentication.state.value.unlocked) + assertTrue(request.candidates.single().matched) + assertEquals("example.nativeclient", request.candidates.single().path) + rejects { + credentials.fill( + request.token, + caller, + "example.nativeclient", + false, + passphrase, + emptyList() + ) + } + rejects { + credentials.fill( + request.token, + caller, + "example.nativeclient", + true, + null, + emptyList() + ) + } + val value = credentials.fill( + request.token, + caller, + "example.nativeclient", + true, + passphrase, + emptyList() + ) + assertEquals("alice", value.username) + assertEquals("fixture-password", value.password) + assertEquals("CredentialValue([REDACTED])", value.toString()) + rejects { + credentials.fill( + request.token, + caller, + "example.nativeclient", + true, + null, + emptyList() + ) + } + val cancelled = credentials.begin(caller) + credentials.cancel(cancelled.token) + rejects { credentials.request(cancelled.token, caller) } + val stale = credentials.begin(caller) + authentication.authenticated { + it.replaceEntryField("example.nativeclient", 0u, "changed-password") + } + rejects { + credentials.fill( + stale.token, + caller, + "example.nativeclient", + true, + null, + emptyList() + ) + } + val saving = credentials.begin(caller) + credentials.stage(saving.token, caller, "bob", "new-password") + rejects { + credentials.save(saving.token, caller, "example.nativeclient", true, false, null) + } + credentials.save(saving.token, caller, "example.nativeclient", true, true, null) + val updated = credentials.begin(caller) + assertEquals( + "bob", + credentials.fill( + updated.token, + caller, + "example.nativeclient", + true, + null, + emptyList() + ).username + ) + val locked = credentials.begin(caller) + withContext(Dispatchers.Main.immediate) { authentication.lock() } + rejects { + credentials.fill( + locked.token, + caller, + "example.nativeclient", + true, + passphrase, + emptyList() + ) + } + } finally { + withContext(Dispatchers.Main.immediate) { authentication.lock() } + scope.cancel() + withContext(Dispatchers.IO) { roots.forEach { it.deleteRecursively() } } + } + } +} diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/CredentialRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/CredentialRepository.kt new file mode 100644 index 0000000..6b784db --- /dev/null +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/CredentialRepository.kt @@ -0,0 +1,61 @@ +package de.rfc1437.ironstorage.core + +import de.rfc1437.ironstorage.bridge.generated.MobileCredentialAttribute +import de.rfc1437.ironstorage.bridge.generated.MobileCredentialCaller +import de.rfc1437.ironstorage.bridge.generated.MobileCredentialField +import de.rfc1437.ironstorage.bridge.generated.MobileProviderRequest +import de.rfc1437.ironstorage.bridge.generated.mobileCredentialForm +import de.rfc1437.ironstorage.bridge.generated.mobileCredentialPrivilegedCallers + +data class CredentialCaller( + val packageName: String, + val certificates: List, + val origin: String? +) +data class CredentialAttribute(val name: String, val value: String) +data class CredentialField( + val index: UInt, + val hints: List, + val htmlAttributes: List, + val inputType: UInt, + val focused: Boolean, + val editable: Boolean +) +data class CredentialForm(val username: UInt, val password: UInt) +data class CredentialCandidate(val path: String, val matched: Boolean) +data class ProviderRequest( + val token: String, + val title: String, + val revision: String, + val candidates: List +) +class CredentialValue(val username: String, val password: String) { + override fun toString() = "CredentialValue([REDACTED])" +} +internal fun credentialForm(fields: List): CredentialForm { + val result = mobileCredentialForm( + fields.map { field -> + MobileCredentialField( + field.index, + field.hints, + field.htmlAttributes.map { + MobileCredentialAttribute(it.name, it.value) + }, + field.inputType, + field.focused, + field.editable + ) + } + ) + return CredentialForm(result.username, result.password) +} +internal fun privilegedCredentialCallers(): String = mobileCredentialPrivilegedCallers() +internal fun CredentialCaller.native() = MobileCredentialCaller(packageName, certificates, origin) +internal fun MobileProviderRequest.facade() = ProviderRequest( + token, + title, + revision, + candidates.map { + CredentialCandidate(it.path, it.matched) + } +) diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeAuthenticationRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeAuthenticationRepository.kt index ac7c491..e30a169 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeAuthenticationRepository.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeAuthenticationRepository.kt @@ -46,6 +46,11 @@ class NativeAuthenticationRepository( scope.launch { while (true) { delay(250) + try { + withContext(io) { current?.expireCredentialRequests() } + } catch (_: MobileAuthenticationFfiException) { + lock() + } if (mutable.value.unlocked && !mutable.value.busy) { val ticket = sequence.get() try { diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeCredentialRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeCredentialRepository.kt new file mode 100644 index 0000000..60799d7 --- /dev/null +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeCredentialRepository.kt @@ -0,0 +1,56 @@ +package de.rfc1437.ironstorage.core + +/** Providers use the same Rust owner and lease as the entry viewer. */ +class NativeCredentialRepository(private val authentication: NativeAuthenticationRepository) { + suspend fun form(fields: List): CredentialForm = + authentication.authenticated { credentialForm(fields) } + suspend fun privilegedCallers(): String = authentication.authenticated { + privilegedCredentialCallers() + } + suspend fun begin(caller: CredentialCaller): ProviderRequest = + authentication.authenticated { it.beginCredentialRequest(caller.native()).facade() } + + suspend fun request(token: String, caller: CredentialCaller, query: String = "") = + authentication.authenticated { + it.credentialRequest(token, caller.native(), query).facade() + } + + suspend fun fill( + token: String, + caller: CredentialCaller, + path: String, + consent: Boolean, + passphrase: String?, + allowedUsernames: List + ): CredentialValue = authentication.authenticated { + it.fillCredential( + token, + caller.native(), + path, + consent, + passphrase, + allowedUsernames + ).let { value -> + CredentialValue(value.username, value.password) + } + } + + suspend fun stage(token: String, caller: CredentialCaller, username: String, password: String) = + authentication.authenticated { + it.stageCredentialSave(token, caller.native(), username, password) + } + suspend fun save( + token: String, + caller: CredentialCaller, + path: String, + consent: Boolean, + overwrite: Boolean, + passphrase: String? + ) = authentication.authenticated { + it.saveStagedCredential(token, caller.native(), path, consent, overwrite, passphrase) + } + + suspend fun cancel(token: String) = authentication.authenticated { + it.cancelCredentialRequest(token) + } +} diff --git a/android/dependency-policy.json b/android/dependency-policy.json index 65395e7..1354362 100644 --- a/android/dependency-policy.json +++ b/android/dependency-policy.json @@ -1,6 +1,5 @@ { "android": { - "rustls:rustls-platform-verifier:0.1.1": "MIT OR Apache-2.0", "androidx.activity:activity-compose:1.12.0": "Apache-2.0", "androidx.activity:activity-compose:1.13.0": "Apache-2.0", "androidx.activity:activity-ktx:1.12.0": "Apache-2.0", @@ -12,21 +11,41 @@ "androidx.annotation:annotation-experimental:1.4.1": "Apache-2.0", "androidx.annotation:annotation-experimental:1.5.1": "Apache-2.0", "androidx.annotation:annotation-jvm:1.11.0": "Apache-2.0", + "androidx.annotation:annotation-jvm:1.8.1": "Apache-2.0", "androidx.appcompat:appcompat-resources:1.2.0": "Apache-2.0", "androidx.appcompat:appcompat:1.2.0": "Apache-2.0", + "androidx.arch.core:core-common:2.1.0": "Apache-2.0", "androidx.arch.core:core-common:2.2.0": "Apache-2.0", + "androidx.arch.core:core-runtime:2.1.0": "Apache-2.0", "androidx.arch.core:core-runtime:2.2.0": "Apache-2.0", "androidx.autofill:autofill:1.0.0": "Apache-2.0", + "androidx.autofill:autofill:1.3.0": "Apache-2.0", + "androidx.benchmark:benchmark-common:1.5.0": "Apache-2.0", + "androidx.benchmark:benchmark-macro-junit4:1.5.0": "Apache-2.0", + "androidx.benchmark:benchmark-macro:1.5.0": "Apache-2.0", + "androidx.benchmark:benchmark-traceprocessor-android:1.5.0": "Apache-2.0", "androidx.biometric:biometric:1.1.0": "Apache-2.0", + "androidx.camera.featurecombinationquery:featurecombinationquery:1.6.2": "Apache-2.0", + "androidx.camera.viewfinder:viewfinder-core:1.5.1": "Apache-2.0", + "androidx.camera:camera-camera2-pipe:1.6.2": "Apache-2.0", + "androidx.camera:camera-camera2:1.6.2": "Apache-2.0", + "androidx.camera:camera-core:1.6.2": "Apache-2.0 AND BSD-3-Clause", + "androidx.camera:camera-lifecycle:1.6.2": "Apache-2.0", + "androidx.camera:camera-video:1.6.2": "Apache-2.0", + "androidx.camera:camera-view:1.6.2": "Apache-2.0", + "androidx.cardview:cardview:1.0.0": "Apache-2.0", "androidx.collection:collection-jvm:1.5.0": "Apache-2.0", "androidx.collection:collection-jvm:1.6.0": "Apache-2.0", "androidx.collection:collection-ktx:1.5.0": "Apache-2.0", "androidx.collection:collection-ktx:1.6.0": "Apache-2.0", + "androidx.collection:collection:1.0.0": "Apache-2.0", "androidx.compose.animation:animation-android:1.12.1": "Apache-2.0", "androidx.compose.animation:animation-core-android:1.12.1": "Apache-2.0", "androidx.compose.foundation:foundation-android:1.12.1": "Apache-2.0", "androidx.compose.foundation:foundation-layout-android:1.12.1": "Apache-2.0", "androidx.compose.material3.adaptive:adaptive-android:1.3.0": "Apache-2.0", + "androidx.compose.material3.adaptive:adaptive-layout-android:1.3.0": "Apache-2.0", + "androidx.compose.material3.adaptive:adaptive-navigation-android:1.3.0": "Apache-2.0", "androidx.compose.material3:material3-android:1.4.0": "Apache-2.0", "androidx.compose.material:material-ripple-android:1.12.1": "Apache-2.0", "androidx.compose.runtime:runtime-android:1.12.1": "Apache-2.0", @@ -37,25 +56,34 @@ "androidx.compose.ui:ui-geometry-android:1.12.1": "Apache-2.0", "androidx.compose.ui:ui-graphics-android:1.12.1": "Apache-2.0", "androidx.compose.ui:ui-test-android:1.12.1": "Apache-2.0", + "androidx.compose.ui:ui-test-junit4-accessibility-android:1.12.1": "Apache-2.0", "androidx.compose.ui:ui-test-junit4-android:1.12.1": "Apache-2.0", "androidx.compose.ui:ui-text-android:1.12.1": "Apache-2.0", "androidx.compose.ui:ui-unit-android:1.12.1": "Apache-2.0", "androidx.compose.ui:ui-util-android:1.12.1": "Apache-2.0", + "androidx.concurrent:concurrent-futures-ktx:1.1.0": "Apache-2.0", "androidx.concurrent:concurrent-futures-ktx:1.2.0": "Apache-2.0", "androidx.concurrent:concurrent-futures:1.1.0": "Apache-2.0", "androidx.concurrent:concurrent-futures:1.2.0": "Apache-2.0", + "androidx.coordinatorlayout:coordinatorlayout:1.1.0": "Apache-2.0", + "androidx.core:core-backported-fixes:1.0.0": "Apache-2.0", "androidx.core:core-ktx:1.13.1": "Apache-2.0", "androidx.core:core-ktx:1.16.0": "Apache-2.0", "androidx.core:core-ktx:1.18.0": "Apache-2.0", + "androidx.core:core-ktx:1.19.1": "Apache-2.0", "androidx.core:core-viewtree:1.0.0": "Apache-2.0", "androidx.core:core:1.13.1": "Apache-2.0", "androidx.core:core:1.16.0": "Apache-2.0", "androidx.core:core:1.18.0": "Apache-2.0", + "androidx.core:core:1.19.1": "Apache-2.0", + "androidx.core:core:1.9.0": "Apache-2.0", + "androidx.credentials:credentials:1.6.0": "Apache-2.0", "androidx.cursoradapter:cursoradapter:1.0.0": "Apache-2.0", "androidx.customview:customview-poolingcontainer:1.0.0": "Apache-2.0", "androidx.customview:customview:1.0.0": "Apache-2.0", "androidx.drawerlayout:drawerlayout:1.0.0": "Apache-2.0", "androidx.emoji2:emoji2:1.4.0": "Apache-2.0", + "androidx.exifinterface:exifinterface:1.4.2": "Apache-2.0", "androidx.fragment:fragment:1.5.1": "Apache-2.0", "androidx.fragment:fragment:1.9.1": "Apache-2.0", "androidx.graphics:graphics-path:1.0.1": "Apache-2.0", @@ -63,6 +91,7 @@ "androidx.lifecycle:lifecycle-common-java8:2.11.0": "Apache-2.0", "androidx.lifecycle:lifecycle-common-jvm:2.10.0": "Apache-2.0", "androidx.lifecycle:lifecycle-common-jvm:2.11.0": "Apache-2.0", + "androidx.lifecycle:lifecycle-common:2.3.1": "Apache-2.0", "androidx.lifecycle:lifecycle-livedata-core-ktx:2.10.0": "Apache-2.0", "androidx.lifecycle:lifecycle-livedata-core-ktx:2.11.0": "Apache-2.0", "androidx.lifecycle:lifecycle-livedata-core:2.10.0": "Apache-2.0", @@ -75,6 +104,7 @@ "androidx.lifecycle:lifecycle-runtime-compose-android:2.11.0": "Apache-2.0", "androidx.lifecycle:lifecycle-runtime-ktx-android:2.10.0": "Apache-2.0", "androidx.lifecycle:lifecycle-runtime-ktx-android:2.11.0": "Apache-2.0", + "androidx.lifecycle:lifecycle-runtime:2.3.1": "Apache-2.0", "androidx.lifecycle:lifecycle-viewmodel-android:2.10.0": "Apache-2.0", "androidx.lifecycle:lifecycle-viewmodel-android:2.11.0": "Apache-2.0", "androidx.lifecycle:lifecycle-viewmodel-compose-android:2.11.0": "Apache-2.0", @@ -85,11 +115,16 @@ "androidx.lifecycle:lifecycle-viewmodel:2.10.0": "Apache-2.0", "androidx.lifecycle:lifecycle-viewmodel:2.11.0": "Apache-2.0", "androidx.loader:loader:1.0.0": "Apache-2.0", + "androidx.media3:media3-common:1.9.0": "Apache-2.0", + "androidx.media3:media3-container:1.9.0": "Apache-2.0", + "androidx.media3:media3-muxer:1.9.0": "Apache-2.0", "androidx.navigation3:navigation3-runtime-android:1.2.0": "Apache-2.0", "androidx.navigation3:navigation3-ui-android:1.2.0": "Apache-2.0", "androidx.navigationevent:navigationevent-android:1.1.1": "Apache-2.0", "androidx.navigationevent:navigationevent-compose-android:1.1.1": "Apache-2.0", "androidx.profileinstaller:profileinstaller:1.4.0": "Apache-2.0", + "androidx.profileinstaller:profileinstaller:1.4.1": "Apache-2.0", + "androidx.recyclerview:recyclerview:1.1.0": "Apache-2.0", "androidx.savedstate:savedstate-android:1.4.0": "Apache-2.0", "androidx.savedstate:savedstate-compose-android:1.4.0": "Apache-2.0", "androidx.savedstate:savedstate-ktx:1.4.0": "Apache-2.0", @@ -97,110 +132,79 @@ "androidx.test.espresso:espresso-core:3.7.0": "Apache-2.0", "androidx.test.espresso:espresso-idling-resource:3.7.0": "Apache-2.0", "androidx.test.ext:junit:1.1.5": "Apache-2.0", + "androidx.test.ext:junit:1.2.1": "Apache-2.0", "androidx.test.services:storage:1.6.0": "Apache-2.0", + "androidx.test.uiautomator:uiautomator-shell-android:2.4.0": "Apache-2.0", + "androidx.test.uiautomator:uiautomator:2.4.0": "Apache-2.0", + "androidx.test:annotation:1.0.1": "Apache-2.0", "androidx.test:core:1.7.0": "Apache-2.0", "androidx.test:monitor:1.8.0": "Apache-2.0", + "androidx.test:rules:1.4.0": "Apache-2.0", + "androidx.test:rules:1.5.0": "Apache-2.0", "androidx.test:runner:1.7.0": "Apache-2.0", "androidx.tracing:tracing-android:2.0.0": "Apache-2.0", + "androidx.tracing:tracing-ktx:1.1.0": "Apache-2.0", + "androidx.tracing:tracing-ktx:2.0.0": "Apache-2.0", + "androidx.tracing:tracing-perfetto-binary:1.0.1": "Apache-2.0", + "androidx.tracing:tracing-perfetto-handshake:1.0.1": "Apache-2.0", + "androidx.tracing:tracing-perfetto:1.0.1": "Apache-2.0", "androidx.tracing:tracing:1.1.0": "Apache-2.0", "androidx.tracing:tracing:1.2.0": "Apache-2.0", + "androidx.transition:transition:1.2.0": "Apache-2.0", "androidx.vectordrawable:vectordrawable-animated:1.1.0": "Apache-2.0", "androidx.vectordrawable:vectordrawable:1.1.0": "Apache-2.0", "androidx.versionedparcelable:versionedparcelable:1.1.1": "Apache-2.0", + "androidx.viewpager2:viewpager2:1.0.0": "Apache-2.0", "androidx.viewpager:viewpager:1.0.0": "Apache-2.0", "androidx.window:window-core-android:1.5.0": "Apache-2.0", "androidx.window:window:1.5.0": "Apache-2.0", + "com.google.android.apps.common.testing.accessibility.framework:accessibility-test-framework:4.1.1": "Apache-2.0", + "com.google.android.material:material:1.2.0-rc01": "Apache-2.0", + "com.google.auto.value:auto-value-annotations:1.6.3": "Apache-2.0", "com.google.code.findbugs:jsr305:3.0.2": "Apache-2.0", "com.google.dagger:dagger-lint-aar:2.60.1": "Apache-2.0", + "com.google.dagger:dagger:2.59": "Apache-2.0", "com.google.dagger:dagger:2.60.1": "Apache-2.0", "com.google.dagger:hilt-android:2.60.1": "Apache-2.0", "com.google.dagger:hilt-core:2.60.1": "Apache-2.0", + "com.google.errorprone:error_prone_annotations:2.28.0": "Apache-2.0", "com.google.errorprone:error_prone_annotations:2.30.0": "Apache-2.0", + "com.google.guava:failureaccess:1.0.1": "Apache-2.0", + "com.google.guava:failureaccess:1.0.2": "Apache-2.0", + "com.google.guava:guava:31.0.1-android": "Apache-2.0", + "com.google.guava:guava:33.3.1-android": "Apache-2.0", "com.google.guava:listenablefuture:1.0": "Apache-2.0", + "com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava": "Apache-2.0", + "com.google.j2objc:j2objc-annotations:1.3": "Apache-2.0", + "com.google.j2objc:j2objc-annotations:3.0.0": "Apache-2.0", + "com.google.protobuf:protobuf-javalite:3.19.1": "BSD-3-Clause", + "com.squareup.moshi:moshi:1.13.0": "Apache-2.0", + "com.squareup.okio:okio-jvm:3.17.0": "Apache-2.0", + "com.squareup.wire:wire-runtime-jvm:6.4.0": "Apache-2.0", "jakarta.inject:jakarta.inject-api:2.0.1": "Apache-2.0", "javax.inject:javax.inject:1": "Apache-2.0", "junit:junit:4.13.2": "EPL-1.0", "net.java.dev.jna:jna:5.19.1": "Apache-2.0 OR LGPL-2.1-or-later", + "org.checkerframework:checker-compat-qual:2.5.5": "MIT", + "org.checkerframework:checker-qual:3.22.1": "MIT", + "org.checkerframework:checker-qual:3.43.0": "MIT", "org.hamcrest:hamcrest-core:1.3": "BSD-3-Clause", "org.hamcrest:hamcrest-library:1.3": "BSD-3-Clause", + "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0": "Apache-2.0", + "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0": "Apache-2.0", "org.jetbrains.kotlin:kotlin-stdlib:2.4.21": "Apache-2.0", + "org.jetbrains.kotlinx:atomicfu-jvm:0.28.0": "Apache-2.0", "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0": "Apache-2.0", + "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0": "Apache-2.0", "org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.11.0": "Apache-2.0", + "org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0": "Apache-2.0", "org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.11.0": "Apache-2.0", "org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3": "Apache-2.0", "org.jetbrains:annotations:13.0": "Apache-2.0", "org.jetbrains:annotations:23.0.0": "Apache-2.0", - "org.jspecify:jspecify:1.0.0": "Apache-2.0", - "androidx.compose.material3.adaptive:adaptive-layout-android:1.3.0": "Apache-2.0", - "androidx.compose.material3.adaptive:adaptive-navigation-android:1.3.0": "Apache-2.0", - "androidx.lifecycle:lifecycle-common:2.3.1": "Apache-2.0", - "androidx.cardview:cardview:1.0.0": "Apache-2.0", - "androidx.compose.ui:ui-test-junit4-accessibility-android:1.12.1": "Apache-2.0", - "androidx.coordinatorlayout:coordinatorlayout:1.1.0": "Apache-2.0", - "androidx.recyclerview:recyclerview:1.1.0": "Apache-2.0", - "androidx.test:rules:1.4.0": "Apache-2.0", - "androidx.transition:transition:1.2.0": "Apache-2.0", - "androidx.viewpager2:viewpager2:1.0.0": "Apache-2.0", - "com.google.android.apps.common.testing.accessibility.framework:accessibility-test-framework:4.1.1": "Apache-2.0", - "com.google.android.material:material:1.2.0-rc01": "Apache-2.0", - "com.google.guava:failureaccess:1.0.1": "Apache-2.0", - "com.google.guava:guava:31.0.1-android": "Apache-2.0", - "com.google.j2objc:j2objc-annotations:1.3": "Apache-2.0", - "com.google.protobuf:protobuf-javalite:3.19.1": "BSD-3-Clause", - "org.checkerframework:checker-compat-qual:2.5.5": "MIT", - "org.checkerframework:checker-qual:3.22.1": "MIT", "org.jsoup:jsoup:1.15.1": "MIT", - "androidx.annotation:annotation-jvm:1.8.1": "Apache-2.0", - "androidx.arch.core:core-common:2.1.0": "Apache-2.0", - "androidx.arch.core:core-runtime:2.1.0": "Apache-2.0", - "androidx.benchmark:benchmark-common:1.5.0": "Apache-2.0", - "androidx.benchmark:benchmark-macro:1.5.0": "Apache-2.0", - "androidx.benchmark:benchmark-macro-junit4:1.5.0": "Apache-2.0", - "androidx.benchmark:benchmark-traceprocessor-android:1.5.0": "Apache-2.0", - "androidx.collection:collection:1.0.0": "Apache-2.0", - "androidx.core:core:1.9.0": "Apache-2.0", - "androidx.lifecycle:lifecycle-runtime:2.3.1": "Apache-2.0", - "androidx.profileinstaller:profileinstaller:1.4.1": "Apache-2.0", - "androidx.test:annotation:1.0.1": "Apache-2.0", - "androidx.test:rules:1.5.0": "Apache-2.0", - "androidx.test.ext:junit:1.2.1": "Apache-2.0", - "androidx.test.uiautomator:uiautomator:2.4.0": "Apache-2.0", - "androidx.test.uiautomator:uiautomator-shell-android:2.4.0": "Apache-2.0", - "androidx.tracing:tracing-ktx:1.1.0": "Apache-2.0", - "androidx.tracing:tracing-perfetto:1.0.1": "Apache-2.0", - "androidx.tracing:tracing-perfetto-binary:1.0.1": "Apache-2.0", - "androidx.tracing:tracing-perfetto-handshake:1.0.1": "Apache-2.0", - "com.squareup.moshi:moshi:1.13.0": "Apache-2.0", - "com.squareup.okio:okio-jvm:3.17.0": "Apache-2.0", - "com.squareup.wire:wire-runtime-jvm:6.4.0": "Apache-2.0", - "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0": "Apache-2.0", - "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0": "Apache-2.0", - "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0": "Apache-2.0", - "org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0": "Apache-2.0", - "androidx.camera.featurecombinationquery:featurecombinationquery:1.6.2": "Apache-2.0", - "androidx.camera.viewfinder:viewfinder-core:1.5.1": "Apache-2.0", - "androidx.camera:camera-camera2-pipe:1.6.2": "Apache-2.0", - "androidx.camera:camera-camera2:1.6.2": "Apache-2.0", - "androidx.camera:camera-core:1.6.2": "Apache-2.0 AND BSD-3-Clause", - "androidx.camera:camera-lifecycle:1.6.2": "Apache-2.0", - "androidx.camera:camera-video:1.6.2": "Apache-2.0", - "androidx.camera:camera-view:1.6.2": "Apache-2.0", - "androidx.concurrent:concurrent-futures-ktx:1.1.0": "Apache-2.0", - "androidx.core:core-backported-fixes:1.0.0": "Apache-2.0", - "androidx.exifinterface:exifinterface:1.4.2": "Apache-2.0", - "androidx.media3:media3-common:1.9.0": "Apache-2.0", - "androidx.media3:media3-container:1.9.0": "Apache-2.0", - "androidx.media3:media3-muxer:1.9.0": "Apache-2.0", - "androidx.tracing:tracing-ktx:2.0.0": "Apache-2.0", - "com.google.auto.value:auto-value-annotations:1.6.3": "Apache-2.0", - "com.google.dagger:dagger:2.59": "Apache-2.0", - "com.google.errorprone:error_prone_annotations:2.28.0": "Apache-2.0", - "com.google.guava:failureaccess:1.0.2": "Apache-2.0", - "com.google.guava:guava:33.3.1-android": "Apache-2.0", - "com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava": "Apache-2.0", - "com.google.j2objc:j2objc-annotations:3.0.0": "Apache-2.0", - "org.checkerframework:checker-qual:3.43.0": "MIT", - "org.jetbrains.kotlinx:atomicfu-jvm:0.28.0": "Apache-2.0" + "org.jspecify:jspecify:1.0.0": "Apache-2.0", + "rustls:rustls-platform-verifier:0.1.1": "MIT OR Apache-2.0" }, "rust_licenses": [ "(Apache-2.0 OR MIT) AND BSD-3-Clause", diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/CredentialSelectionScreen.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/CredentialSelectionScreen.kt new file mode 100644 index 0000000..7651db4 --- /dev/null +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/CredentialSelectionScreen.kt @@ -0,0 +1,172 @@ +package de.rfc1437.ironstorage.features + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import de.rfc1437.ironstorage.core.ProviderRequest + +@Composable +fun CredentialSelectionScreen( + request: ProviderRequest?, + saving: Boolean, + selected: String?, + query: String, + destination: String, + passphrase: String, + consent: Boolean, + overwrite: Boolean, + busy: Boolean, + failure: String?, + onSelect: (String?) -> Unit, + onQuery: (String) -> Unit, + onDestination: (String) -> Unit, + onPassphrase: (String) -> Unit, + onConsent: (Boolean) -> Unit, + onOverwrite: (Boolean) -> Unit, + onConfirm: () -> Unit, + onCancel: () -> Unit +) { + LazyColumn( + Modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { + Text( + stringResource( + if (saving) R.string.provider_save_title else R.string.provider_fill_title + ), + style = MaterialTheme.typography.headlineSmall + ) + Text(request?.title ?: stringResource(R.string.provider_checking)) + } + if (saving) { + item { + OutlinedTextField( + destination, + onDestination, + label = { Text(stringResource(R.string.provider_destination)) }, + enabled = !busy, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + ProviderConsent( + overwrite, + !busy, + stringResource(R.string.provider_overwrite), + onOverwrite + ) + } + } else if (selected == null) { + item { + OutlinedTextField( + query, + onQuery, + label = { Text(stringResource(R.string.provider_search)) }, + enabled = !busy, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + } + items(request?.candidates ?: emptyList(), key = { it.path }) { candidate -> + TextButton(onClick = { + onSelect(candidate.path) + }, enabled = !busy, modifier = Modifier.fillMaxWidth()) { + Text(candidate.path) + } + } + } else { + item { + Text(stringResource(R.string.provider_selected, selected)) + TextButton(onClick = { onSelect(null) }, enabled = !busy) { + Text(stringResource(R.string.provider_choose_another)) + } + } + } + if (saving || selected != null) { + item { + OutlinedTextField( + passphrase, + onPassphrase, + label = { Text(stringResource(R.string.provider_passphrase)) }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + autoCorrectEnabled = false + ), + enabled = !busy, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + val recipient = request?.title ?: stringResource(R.string.provider_this_application) + val confirmation = if (saving) { + stringResource( + R.string.provider_confirm_save + ) + } else { + stringResource(R.string.provider_confirm_fill, recipient) + } + ProviderConsent(consent, !busy, confirmation, onConsent) + } + } + item { + failure?.let { Text(it, color = MaterialTheme.colorScheme.error) } + Button( + onClick = onConfirm, + enabled = !busy && consent && request != null && + (if (saving) destination.isNotBlank() else selected != null) + ) { + Text( + stringResource( + if (busy) { + R.string.provider_working + } else if (saving) { + R.string.provider_save + } else { + R.string.provider_fill + } + ) + ) + } + TextButton(onClick = onCancel) { Text(stringResource(R.string.auth_cancel)) } + } + } +} + +@Composable +private fun ProviderConsent( + checked: Boolean, + enabled: Boolean, + label: String, + onChange: (Boolean) -> Unit +) { + Row( + Modifier.fillMaxWidth().toggleable( + value = checked, + enabled = enabled, + role = Role.Checkbox, + onValueChange = onChange + ) + ) { + Checkbox(checked, null, enabled = enabled) + Text(label, Modifier.padding(top = 12.dp)) + } +} diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/SettingsScreen.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/SettingsScreen.kt index ca7afca..ebe29f4 100644 --- a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/SettingsScreen.kt +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/SettingsScreen.kt @@ -229,6 +229,13 @@ fun SettingsScreen( }) { Text(stringResource(R.string.settings_rebuild)) } } "providers" -> { + Text(stringResource(R.string.settings_chrome_guidance)) + TextButton(onClick = { + uri.openUri( + "https://support.google.com/chrome/answer/142893?co=GENIE.Platform%3DAndroid&hl=en" + ) + }) { Text(stringResource(R.string.settings_chrome_help)) } + page?.let { ProviderSetting( stringResource(R.string.settings_autofill), diff --git a/android/features/src/main/res/values/strings.xml b/android/features/src/main/res/values/strings.xml index f1e114c..40dd0db 100644 --- a/android/features/src/main/res/values/strings.xml +++ b/android/features/src/main/res/values/strings.xml @@ -156,7 +156,7 @@ Git commit author name and email Authentication, inactivity timeout, biometrics and appearance TOTP discovery cache - Autofill and Credential Manager + Autofill and Credential Manager providers Recovery and offline use Version, licenses, privacy and support No application token stored @@ -266,4 +266,21 @@ %1$s password entry %1$s password entries + After selecting IronStorage in Android settings, open Chrome Settings → Autofill and passwords → Autofill settings. Select Autofill using another service and restart Chrome. + Chrome autofill help + Fill from IronStorage + Save in IronStorage + Checking the requesting application… + Destination entry path + Allow updating this destination if it exists + Search entries + Selected: %1$s + Choose another entry + GPG passphrase (leave empty for biometric unlock) + this application + Save the supplied credential at the chosen destination + Allow %1$s to receive this entry\'s username and password + Working… + Confirm save + Unlock and fill diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index de12ebc..17a3858 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,4 +1,6 @@ [versions] +credentials = "1.6.0" +autofill = "1.3.0" camera = "1.6.2" agp = "9.4.1" kotlin = "2.4.21" @@ -29,6 +31,9 @@ biometric = "1.1.0" fragment = "1.9.1" [libraries] +core-ktx = { module = "androidx.core:core-ktx", version = "1.19.1" } +autofill = { module = "androidx.autofill:autofill", version.ref = "autofill" } +credentials = { module = "androidx.credentials:credentials", version.ref = "credentials" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } camera-view = { module = "androidx.camera:camera-view", version.ref = "camera" } diff --git a/android/gradle/verification-metadata.xml b/android/gradle/verification-metadata.xml index 186d05d..ef83350 100644 --- a/android/gradle/verification-metadata.xml +++ b/android/gradle/verification-metadata.xml @@ -6,6 +6,9 @@ + + + @@ -89,6 +92,11 @@ + + + + + @@ -151,6 +159,14 @@ + + + + + + + + @@ -204,6 +220,11 @@ + + + + + @@ -244,6 +265,14 @@ + + + + + + + + @@ -955,6 +984,9 @@ + + + @@ -975,7 +1007,18 @@ + + + + + + + + + + + @@ -1024,6 +1067,14 @@ + + + + + + + + @@ -1040,6 +1091,14 @@ + + + + + + + + @@ -1066,6 +1125,14 @@ + + + + + + + + @@ -1141,6 +1208,9 @@ + + + @@ -1198,6 +1268,11 @@ + + + + + @@ -1214,6 +1289,9 @@ + + + @@ -1239,6 +1317,14 @@ + + + + + + + + @@ -1273,6 +1359,14 @@ + + + + + + + + @@ -1307,6 +1401,14 @@ + + + + + + + + @@ -1329,6 +1431,9 @@ + + + @@ -1346,6 +1451,14 @@ + + + + + + + + @@ -1394,6 +1507,9 @@ + + + @@ -1414,6 +1530,14 @@ + + + + + + + + @@ -1525,6 +1649,9 @@ + + + @@ -1542,6 +1669,14 @@ + + + + + + + + @@ -1636,6 +1771,14 @@ + + + + + + + + @@ -1649,6 +1792,14 @@ + + + + + + + + @@ -1809,6 +1960,14 @@ + + + + + + + + @@ -1847,6 +2006,9 @@ + + + @@ -2208,6 +2370,9 @@ + + + diff --git a/android/performance/gradle.lockfile b/android/performance/gradle.lockfile index bb1ddd7..9ad82e9 100644 --- a/android/performance/gradle.lockfile +++ b/android/performance/gradle.lockfile @@ -16,6 +16,7 @@ androidx.arch.core:core-common:2.1.0=benchmarkLintChecksClasspath,benchmarkRunti androidx.arch.core:core-common:2.2.0=benchmarkTestedApks,debugTestedApks androidx.arch.core:core-runtime:2.1.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath androidx.arch.core:core-runtime:2.2.0=benchmarkTestedApks,debugTestedApks +androidx.autofill:autofill:1.3.0=benchmarkTestedApks,debugTestedApks androidx.benchmark:benchmark-common:1.5.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath androidx.benchmark:benchmark-macro-junit4:1.5.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath androidx.benchmark:benchmark-macro:1.5.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath @@ -81,6 +82,7 @@ androidx.core:core-ktx:1.18.0=benchmarkTestedApks,debugTestedApks androidx.core:core-viewtree:1.0.0=benchmarkTestedApks,debugTestedApks androidx.core:core:1.18.0=benchmarkTestedApks,debugTestedApks androidx.core:core:1.9.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath +androidx.credentials:credentials:1.6.0=benchmarkTestedApks,debugTestedApks androidx.cursoradapter:cursoradapter:1.0.0=benchmarkTestedApks,debugTestedApks androidx.customview:customview:1.0.0=benchmarkTestedApks,debugTestedApks androidx.drawerlayout:drawerlayout:1.0.0=benchmarkTestedApks,debugTestedApks diff --git a/android/provider-client/build.gradle.kts b/android/provider-client/build.gradle.kts new file mode 100644 index 0000000..6bca77a --- /dev/null +++ b/android/provider-client/build.gradle.kts @@ -0,0 +1,37 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { alias(libs.plugins.android.application) } +android { + namespace = "de.rfc1437.ironstorage.providerclient" + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { + applicationId = "de.rfc1437.ironstorage.providerclient" + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.compileSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + } + androidResources { noCompress += "html" } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + lint { + abortOnError = true + warningsAsErrors = true + } +} +kotlin { + jvmToolchain(25) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + allWarningsAsErrors.set(true) + } +} +dependencies { + implementation(libs.credentials) + implementation(libs.core.ktx) + implementation(libs.fragment) + implementation(libs.annotation) + implementation(libs.coroutines.android) +} diff --git a/android/provider-client/gradle.lockfile b/android/provider-client/gradle.lockfile new file mode 100644 index 0000000..66a7a8e --- /dev/null +++ b/android/provider-client/gradle.lockfile @@ -0,0 +1,205 @@ +# This is a Gradle generated file for dependency locking. +# 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 :provider-client:dependencies --write-locks +androidx.activity:activity-ktx:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.activity:activity:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.annotation:annotation-experimental:1.4.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.annotation:annotation-jvm:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.annotation:annotation:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +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,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.arch.core:core-runtime:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.biometric:biometric:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,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,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.collection:collection:1.4.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +androidx.collection:collection:1.6.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.concurrent:concurrent-futures:1.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.core:core-ktx:1.19.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.core:core-viewtree:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.core:core:1.19.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.credentials:credentials:1.6.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,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: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.fragment:fragment:1.9.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.interpolator:interpolator:1.0.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-common-jvm:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-common:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-livedata-core-ktx:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-livedata-core:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-livedata:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-android:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-ktx-android:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-ktx:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-android:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-savedstate:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.loader:loader:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.profileinstaller:profileinstaller:1.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.savedstate:savedstate-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.savedstate:savedstate-ktx:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.savedstate:savedstate:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.startup:startup-runtime:1.1.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.tracing:tracing-android:2.0.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.tracing:tracing:2.0.0=debugAndroidTestLintChecksClasspath,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,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 +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 +com.android.tools.analytics-library:shared:32.4.1=androidLintTool +com.android.tools.analytics-library:tracker:32.4.1=androidLintTool +com.android.tools.androidtest:android-test-engine-result-listener:1.0.1=unified-test-platform-gradle-work-action +com.android.tools.androidtest:android-test-engine:1.0.1=unified-test-platform-gradle-work-action +com.android.tools.androidtest:test-result-event-proto:1.0.1=unified-test-platform-gradle-work-action +com.android.tools.androidtest:test-result-proto:1.0.1=unified-test-platform-gradle-work-action +com.android.tools.build:aapt2-proto:9.4.1-15978811=androidLintTool +com.android.tools.build:builder-model:9.4.1=androidLintTool +com.android.tools.build:manifest-merger:32.4.1=androidLintTool +com.android.tools.ddms:ddmlib:32.4.1=androidLintTool +com.android.tools.external.com-intellij:intellij-core:32.4.1=androidLintTool +com.android.tools.external.com-intellij:kotlin-compiler:32.4.1=androidLintTool +com.android.tools.external.org-jetbrains:uast:32.4.1=androidLintTool +com.android.tools.layoutlib:layoutlib-api:32.4.1=androidLintTool +com.android.tools.lint:lint-api:32.4.1=androidLintTool +com.android.tools.lint:lint-checks:32.4.1=androidLintTool +com.android.tools.lint:lint-gradle:32.4.1=androidLintTool +com.android.tools.lint:lint-model:32.4.1=androidLintTool +com.android.tools.lint:lint-typedef-remover:32.4.1=androidLintTool +com.android.tools.lint:lint:32.4.1=androidLintTool +com.android.tools.utp:gradle-work-action:32.4.1=unified-test-platform-gradle-work-action +com.android.tools:annotations:32.4.1=androidLintTool +com.android.tools:common:32.4.1=androidLintTool +com.android.tools:dvlib:32.4.1=androidLintTool +com.android.tools:play-sdk-proto:32.4.1=androidLintTool +com.android.tools:repository:32.4.1=androidLintTool +com.android.tools:sdk-common:32.4.1=androidLintTool +com.android.tools:sdklib:32.4.1=androidLintTool +com.github.ajalt.clikt:clikt-jvm:5.0.3=ktlint +com.github.ajalt.clikt:clikt:5.0.3=ktlint +com.google.code.findbugs:jsr305:3.0.2=androidLintTool +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,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,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 +com.google.protobuf:protobuf-java:3.25.5=androidLintTool,unified-test-platform-gradle-work-action +com.pinterest.ktlint:ktlint-cli-reporter-baseline:1.7.1=ktlint,ktlintBaselineReporter +com.pinterest.ktlint:ktlint-cli-reporter-checkstyle:1.7.1=ktlint +com.pinterest.ktlint:ktlint-cli-reporter-core:1.7.1=ktlint,ktlintBaselineReporter +com.pinterest.ktlint:ktlint-cli-reporter-format:1.7.1=ktlint +com.pinterest.ktlint:ktlint-cli-reporter-html:1.7.1=ktlint +com.pinterest.ktlint:ktlint-cli-reporter-json:1.7.1=ktlint +com.pinterest.ktlint:ktlint-cli-reporter-plain-summary:1.7.1=ktlint +com.pinterest.ktlint:ktlint-cli-reporter-plain:1.7.1=ktlint +com.pinterest.ktlint:ktlint-cli-reporter-sarif:1.7.1=ktlint +com.pinterest.ktlint:ktlint-cli-ruleset-core:1.7.1=ktlint,ktlintRuleset +com.pinterest.ktlint:ktlint-cli:1.7.1=ktlint +com.pinterest.ktlint:ktlint-logger:1.7.1=ktlint,ktlintBaselineReporter,ktlintRuleset +com.pinterest.ktlint:ktlint-rule-engine-core:1.7.1=ktlint,ktlintBaselineReporter,ktlintRuleset +com.pinterest.ktlint:ktlint-rule-engine:1.7.1=ktlint +com.pinterest.ktlint:ktlint-ruleset-standard:1.7.1=ktlint,ktlintRuleset +com.sun.istack:istack-commons-runtime:3.0.8=androidLintTool +com.sun.xml.fastinfoset:FastInfoset:1.2.16=androidLintTool +commons-codec:commons-codec:1.17.1=androidLintTool +commons-io:commons-io:2.16.1=androidLintTool +commons-logging:commons-logging:1.2=androidLintTool +dev.drewhamilton.poko:poko-annotations-jvm:0.19.1=ktlint,ktlintBaselineReporter,ktlintRuleset +dev.drewhamilton.poko:poko-annotations:0.19.1=ktlint,ktlintBaselineReporter,ktlintRuleset +io.github.detekt.sarif4k:sarif4k-jvm:0.6.0=ktlint,ktlintReporter +io.github.detekt.sarif4k:sarif4k:0.6.0=ktlint,ktlintReporter +io.github.oshai:kotlin-logging-jvm:7.0.7=ktlint,ktlintBaselineReporter,ktlintReporter,ktlintRuleset +io.github.oshai:kotlin-logging:5.1.0=ktlint,ktlintBaselineReporter,ktlintReporter +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 +net.java.dev.jna:jna-platform:5.6.0=androidLintTool +net.java.dev.jna:jna:5.6.0=androidLintTool +net.sf.kxml:kxml2:2.3.0=androidLintTool +org.apache.commons:commons-compress:1.27.1=androidLintTool +org.apache.commons:commons-lang3:3.16.0=androidLintTool +org.apache.groovy:groovy-bom:4.0.29=androidLintTool +org.apache.groovy:groovy:4.0.29=androidLintTool +org.apache.httpcomponents:httpclient:4.5.6=androidLintTool +org.apache.httpcomponents:httpcore:4.4.16=androidLintTool +org.apache.httpcomponents:httpmime:4.5.6=androidLintTool +org.bouncycastle:bcpkix-jdk18on:1.80.2=androidLintTool +org.bouncycastle:bcprov-jdk18on:1.80.2=androidLintTool +org.bouncycastle:bcutil-jdk18on:1.80.2=androidLintTool +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.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 +org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.0=kotlinAbiValidationCompatClasspath +org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.0=kotlinAbiValidationCompatClasspath +org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-compiler-embeddable:2.2.0=ktlint,ktlintBaselineReporter,ktlintRuleset +org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.0=kotlinAbiValidationCompatClasspath +org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-compiler-runner:2.4.0=kotlinAbiValidationCompatClasspath +org.jetbrains.kotlin:kotlin-compiler-runner:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-client:2.4.0=kotlinAbiValidationCompatClasspath +org.jetbrains.kotlin:kotlin-daemon-client:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-embeddable:2.2.0=ktlint,ktlintBaselineReporter,ktlintRuleset +org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.0=kotlinAbiValidationCompatClasspath +org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,ktlint,ktlintBaselineReporter,ktlintRuleset +org.jetbrains.kotlin:kotlin-reflect:2.2.10=androidLintTool +org.jetbrains.kotlin:kotlin-script-runtime:2.2.0=ktlint,ktlintBaselineReporter,ktlintRuleset +org.jetbrains.kotlin:kotlin-script-runtime:2.4.0=kotlinAbiValidationCompatClasspath +org.jetbrains.kotlin:kotlin-script-runtime:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-stdlib-common:2.2.0=ktlintReporter +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0=ktlintReporter +org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.10=androidLintTool,unified-test-platform-gradle-work-action +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0=ktlintReporter +org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.10=androidLintTool,unified-test-platform-gradle-work-action +org.jetbrains.kotlin:kotlin-stdlib:2.2.0=ktlint,ktlintBaselineReporter,ktlintReporter,ktlintRuleset +org.jetbrains.kotlin:kotlin-stdlib:2.2.10=androidLintTool,unified-test-platform-gradle-work-action +org.jetbrains.kotlin:kotlin-stdlib:2.4.0=kotlinAbiValidationCompatClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.4.21=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,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=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=androidLintTool,unified-test-platform-gradle-work-action +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,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:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0=androidLintTool,unified-test-platform-gradle-work-action +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,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,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,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=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,ktlint,ktlintBaselineReporter,ktlintReporter,ktlintRuleset +org.jetbrains:annotations:23.0.0=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-gradle-work-action +org.jspecify:jspecify:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,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 +org.junit:junit-bom:5.13.3=unified-test-platform-gradle-work-action +org.jvnet.staxex:stax-ex:1.8.1=androidLintTool +org.opentest4j:opentest4j:1.3.0=unified-test-platform-gradle-work-action +org.ow2.asm:asm-analysis:9.9=androidLintTool +org.ow2.asm:asm-commons:9.9=androidLintTool +org.ow2.asm:asm-tree:9.9=androidLintTool +org.ow2.asm:asm:9.9=androidLintTool +org.slf4j:slf4j-api:2.0.7=ktlint +empty=androidApis,androidJdkImage,androidTestUtil,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAndroidTestRuntimeClasspath,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugReverseMetadataValues,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata,releaseReverseMetadataValues diff --git a/android/provider-client/src/main/AndroidManifest.xml b/android/provider-client/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c5f0ce3 --- /dev/null +++ b/android/provider-client/src/main/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/android/provider-client/src/main/assets/provider_form.html b/android/provider-client/src/main/assets/provider_form.html new file mode 100644 index 0000000..5955c47 --- /dev/null +++ b/android/provider-client/src/main/assets/provider_form.html @@ -0,0 +1,14 @@ + + +IronStorage browser fixture + +

Browser credential fixture

+

This local page uses public synthetic credentials. It sends no network requests.

+
+ + + + + +

Waiting for an explicit fill.

+ diff --git a/android/provider-client/src/main/kotlin/de/rfc1437/ironstorage/providerclient/BrowserFixtureProvider.kt b/android/provider-client/src/main/kotlin/de/rfc1437/ironstorage/providerclient/BrowserFixtureProvider.kt new file mode 100644 index 0000000..ce8f867 --- /dev/null +++ b/android/provider-client/src/main/kotlin/de/rfc1437/ironstorage/providerclient/BrowserFixtureProvider.kt @@ -0,0 +1,68 @@ +package de.rfc1437.ironstorage.providerclient + +import android.content.ContentProvider +import android.content.ContentValues +import android.content.res.AssetFileDescriptor +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.provider.OpenableColumns +import java.io.File + +/** A single public, read-only fixture asset; there is no application-data access. */ +class BrowserFixtureProvider : ContentProvider() { + private lateinit var page: File + override fun onCreate(): Boolean { + val app = requireNotNull(context) + page = File(app.cacheDir, "public_provider_form.html") + app.assets.open("provider_form.html").use { source -> + page.outputStream().use { target -> source.copyTo(target) } + } + return true + } + private fun checkPage(uri: Uri) { + require(uri.path == "/credential_form.html") + } + override fun getType(uri: Uri): String { + checkPage(uri) + return "text/html" + } + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String? + ): Cursor { + checkPage(uri) + return MatrixCursor(arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)).apply { + addRow( + arrayOf( + "credential_form.html", + page.length() + ) + ) + } + } + override fun openAssetFile(uri: Uri, mode: String): AssetFileDescriptor { + checkPage(uri) + require(mode == "r") + return AssetFileDescriptor(openFile(uri, mode), 0, page.length()) + } + override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor { + checkPage(uri) + require(mode == "r") + return ParcelFileDescriptor.open(page, ParcelFileDescriptor.MODE_READ_ONLY) + } + override fun insert(uri: Uri, values: ContentValues?): Uri = + throw UnsupportedOperationException("Read-only fixture") + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = + throw UnsupportedOperationException("Read-only fixture") + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array? + ): Int = throw UnsupportedOperationException("Read-only fixture") +} diff --git a/android/provider-client/src/main/kotlin/de/rfc1437/ironstorage/providerclient/ProviderClientActivity.kt b/android/provider-client/src/main/kotlin/de/rfc1437/ironstorage/providerclient/ProviderClientActivity.kt new file mode 100644 index 0000000..a31ca5a --- /dev/null +++ b/android/provider-client/src/main/kotlin/de/rfc1437/ironstorage/providerclient/ProviderClientActivity.kt @@ -0,0 +1,162 @@ +package de.rfc1437.ironstorage.providerclient + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.text.InputType +import android.view.View +import android.view.autofill.AutofillManager +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.net.toUri +import androidx.credentials.CreatePasswordRequest +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetPasswordOption +import androidx.credentials.PasswordCredential +import androidx.credentials.exceptions.NoCredentialException +import androidx.fragment.app.FragmentActivity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +/** A separate test APK represents an ordinary requesting application. Secrets are synthetic. */ +@SuppressLint("SetTextI18n") +class ProviderClientActivity : FragmentActivity() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val form = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(32, 60, 32, 32) + } + val status = TextView(this).apply { text = "Provider client: synthetic credentials only" } + val username = EditText(this).apply { + id = View.generateViewId() + hint = "Username" + inputType = InputType.TYPE_CLASS_TEXT + setAutofillHints(View.AUTOFILL_HINT_USERNAME) + } + val password = EditText(this).apply { + id = View.generateViewId() + hint = "Password" + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + setAutofillHints(View.AUTOFILL_HINT_PASSWORD) + } + form.addView(status) + form.addView(username) + form.addView(password) + fun button(title: String, action: () -> Unit) { + form.addView( + Button(this).apply { + text = + title + setOnClickListener { action() } + } + ) + } + button("Open local Chrome fixture") { + val uri = "content://$packageName.fixture/credential_form.html".toUri() + startActivity( + Intent( + Intent.ACTION_VIEW + ).setDataAndType( + uri, + "text/html" + ).setPackage("com.android.chrome").addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + ) + } + button("Request Autofill") { + val manager = getSystemService(AutofillManager::class.java) + manager.cancel() + username.text.clear() + password.text.clear() + manager.requestAutofill(username) + } + button("Open loopback Chrome fixture") { + startActivity( + Intent(Intent.ACTION_VIEW, "http://localhost:8765/provider_form.html".toUri()) + .setPackage("com.android.chrome") + ) + } + button("Verify filled form") { + status.text = + if (username.text.toString() == "fixture-alice" && + password.text.toString() == "public-provider-fixture-password" + ) { + "Autofill returned the expected synthetic credential" + } else { + "Autofill has not returned the expected fixture" + } + } + button("Request Credential Manager") { + if (Build.VERSION.SDK_INT < + 34 + ) { + status.text = "Credential Manager provider requires API 34" + return@button + } + scope.launch { + try { + val response = CredentialManager.create( + this@ProviderClientActivity + ).getCredential( + this@ProviderClientActivity, + GetCredentialRequest(listOf(GetPasswordOption())) + ) + val value = response.credential as PasswordCredential + status.text = + if (value.id == "fixture-alice" && + value.password == "public-provider-fixture-password" + ) { + "Credential Manager returned the expected synthetic credential" + } else { + "Credential Manager returned an unexpected fixture" + } + } catch (_: NoCredentialException) { + status.text = "No enabled provider offered a password credential" + } catch (_: Exception) { + status.text = + "Credential Manager request cancelled or rejected" + } + } + } + button("Commit form for save offer") { + getSystemService(AutofillManager::class.java).commit() + } + button("Populate synthetic save fields") { + username.setText("fixture-bob") + password.setText("public-provider-new-password") + } + button("Request Credential Manager save") { + if (Build.VERSION.SDK_INT < 34) { + status.text = "Credential Manager provider requires API 34" + return@button + } + scope.launch { + try { + CredentialManager.create( + this@ProviderClientActivity + ).createCredential( + this@ProviderClientActivity, + CreatePasswordRequest("fixture-bob", "public-provider-new-password") + ) + status.text = "Credential Manager save confirmed" + } catch (_: Exception) { + status.text = + "Credential Manager save cancelled or rejected" + } + } + } + setContentView(form) + } + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } +} diff --git a/android/provider-client/src/main/res/xml/backup_rules.xml b/android/provider-client/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..c85e278 --- /dev/null +++ b/android/provider-client/src/main/res/xml/backup_rules.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/android/provider-client/src/main/res/xml/data_extraction_rules.xml b/android/provider-client/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..9ddbf21 --- /dev/null +++ b/android/provider-client/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 3da49a8..314effa 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -35,4 +35,4 @@ dependencyResolutionManagement { } } rootProject.name = "IronStorage" -include(":performance", ":app", ":features", ":core", ":platform", ":rust-bridge") +include(":provider-client", ":performance", ":app", ":features", ":core", ":platform", ":rust-bridge") diff --git a/crates/mobile-bridge/src/credentials.rs b/crates/mobile-bridge/src/credentials.rs new file mode 100644 index 0000000..f9ebf0e --- /dev/null +++ b/crates/mobile-bridge/src/credentials.rs @@ -0,0 +1,210 @@ +//! Android provider bindings; all credential decisions remain in storage. +use crate::{MobileAuthentication, MobileAuthenticationFfiError}; +use ironstorage::mobile_credentials as storage; + +#[derive(Clone, Debug, uniffi::Record)] +pub struct MobileCredentialCaller { + pub package: String, + pub certificates: Vec, + pub origin: Option, +} +impl From for storage::CredentialCaller { + fn from(value: MobileCredentialCaller) -> Self { + Self { + package: value.package, + certificates: value.certificates, + origin: value.origin, + } + } +} +#[derive(Clone, Debug, uniffi::Record)] +pub struct MobileCredentialAttribute { + pub name: String, + pub value: String, +} +#[derive(Clone, Debug, uniffi::Record)] +pub struct MobileCredentialField { + pub index: u32, + pub hints: Vec, + pub html_attributes: Vec, + pub input_type: u32, + pub focused: bool, + pub editable: bool, +} +#[derive(Clone, Debug, uniffi::Record)] +pub struct MobileCredentialForm { + pub username: u32, + pub password: u32, +} +#[derive(Clone, Debug, uniffi::Record)] +pub struct MobileCredentialCandidate { + pub path: String, + pub matched: bool, +} +#[derive(Clone, Debug, uniffi::Record)] +pub struct MobileProviderRequest { + pub token: String, + pub title: String, + pub revision: String, + pub candidates: Vec, +} +impl From for MobileProviderRequest { + fn from(value: storage::CredentialRequest) -> Self { + Self { + token: value.token, + title: value.identity.title, + revision: value.revision, + candidates: value + .candidates + .into_iter() + .map(|candidate| MobileCredentialCandidate { + path: candidate.path, + matched: candidate.matched, + }) + .collect(), + } + } +} +#[derive(uniffi::Record)] +pub struct MobileCredentialValue { + pub username: String, + pub password: String, +} +impl std::fmt::Debug for MobileCredentialValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("MobileCredentialValue([REDACTED])") + } +} +fn rejected(detail: &str) -> MobileAuthenticationFfiError { + MobileAuthenticationFfiError::Failed { + kind: crate::MobileAuthenticationErrorKind::Entry, + title: "Credential Request Rejected".into(), + detail: detail.into(), + } +} +#[uniffi::export] +pub fn mobile_credential_form( + fields: Vec, +) -> Result { + let fields: Vec<_> = fields + .into_iter() + .map(|field| storage::CredentialField { + index: field.index, + hints: field.hints, + html_attributes: field + .html_attributes + .into_iter() + .map(|attribute| (attribute.name, attribute.value)) + .collect(), + input_type: field.input_type, + focused: field.focused, + editable: field.editable, + }) + .collect(); + let form = storage::credential_form(&fields).map_err(rejected)?; + Ok(MobileCredentialForm { + username: form.username, + password: form.password, + }) +} +#[uniffi::export] +impl MobileAuthentication { + pub fn expire_credential_requests(&self) -> Result<(), MobileAuthenticationFfiError> { + self.authentication + .expire_credential_requests() + .map_err(Into::into) + } + pub fn begin_credential_request( + &self, + caller: MobileCredentialCaller, + ) -> Result { + Ok(self + .authentication + .begin_credential_request(caller.into())? + .into()) + } + pub fn credential_request( + &self, + token: String, + caller: MobileCredentialCaller, + query: String, + ) -> Result { + Ok(self + .authentication + .search_credential_request(&token, &caller.into(), &query)? + .into()) + } + pub fn cancel_credential_request( + &self, + token: String, + ) -> Result<(), MobileAuthenticationFfiError> { + self.authentication + .cancel_credential_request(&token) + .map_err(Into::into) + } + pub fn stage_credential_save( + &self, + token: String, + caller: MobileCredentialCaller, + username: String, + password: String, + ) -> Result<(), MobileAuthenticationFfiError> { + self.authentication + .stage_credential_save( + &token, + &caller.into(), + ironstorage::repository::SecretBytes::new(username.into_bytes()), + ironstorage::repository::SecretBytes::new(password.into_bytes()), + ) + .map_err(Into::into) + } + pub fn save_staged_credential( + &self, + token: String, + caller: MobileCredentialCaller, + path: String, + consent: bool, + overwrite: bool, + passphrase: Option, + ) -> Result<(), MobileAuthenticationFfiError> { + self.authentication + .save_staged_credential( + &token, + &caller.into(), + &path, + consent, + overwrite, + passphrase.map(|text| ironstorage::repository::SecretBytes::new(text.into_bytes())), + ) + .map_err(Into::into) + } + pub fn fill_credential( + &self, + token: String, + caller: MobileCredentialCaller, + path: String, + consent: bool, + passphrase: Option, + allowed_usernames: Vec, + ) -> Result { + let value = self.authentication.fill_credential( + &token, + &caller.into(), + &path, + consent, + passphrase.map(|text| ironstorage::repository::SecretBytes::new(text.into_bytes())), + &allowed_usernames, + )?; + Ok(MobileCredentialValue { + username: String::from_utf8(value.username.expose().to_vec()) + .map_err(|_| rejected("Invalid username text."))?, + password: String::from_utf8(value.password.expose().to_vec()) + .map_err(|_| rejected("Invalid password text."))?, + }) + } +} + +#[uniffi::export] +pub fn mobile_credential_privileged_callers() -> String { + storage::privileged_callers_json() +} diff --git a/crates/mobile-bridge/src/lib.rs b/crates/mobile-bridge/src/lib.rs index 8464e5a..b946fac 100644 --- a/crates/mobile-bridge/src/lib.rs +++ b/crates/mobile-bridge/src/lib.rs @@ -3021,3 +3021,6 @@ impl MobileAuthentication { .map_err(Into::into) } } + +mod credentials; +pub use credentials::*; diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 2478935..55db8cf 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -32,6 +32,8 @@ pub mod mobile; #[cfg(feature = "full")] pub mod mobile_authentication; #[cfg(feature = "full")] +pub mod mobile_credentials; +#[cfg(feature = "full")] pub mod mobile_entry; #[cfg(feature = "full")] pub mod mobile_home; diff --git a/crates/storage/src/mobile_authentication.rs b/crates/storage/src/mobile_authentication.rs index cb20235..5ef32c7 100644 --- a/crates/storage/src/mobile_authentication.rs +++ b/crates/storage/src/mobile_authentication.rs @@ -387,6 +387,8 @@ pub struct MobileAuthentication { backend: Arc, status: Mutex, totp_discovery: Mutex<()>, + credential_requests: Mutex, + credential_clock: Arc, } impl MobileAuthentication { @@ -444,7 +446,7 @@ impl MobileAuthentication { Arc::clone(&backend), SecretProtectionPolicy::current_biometry_for_openpgp(), config.authentication_timeout(), - clock, + Arc::clone(&clock), ); let watch_snapshot = WatchSnapshotSender::load(config.source().with_file_name("watch-snapshot.toml")); @@ -469,9 +471,344 @@ impl MobileAuthentication { session, backend, totp_discovery: Mutex::new(()), + credential_requests: Mutex::default(), + credential_clock: clock, }) } + pub fn expire_credential_requests(&self) -> Result<(), MobileAuthenticationError> { + self.credential_requests + .lock() + .map_err(|_| locked_error())? + .expire(self.credential_clock.now()); + Ok(()) + } + pub fn begin_credential_request( + &self, + caller: crate::mobile_credentials::CredentialCaller, + ) -> Result { + let revision = self.repository.mutation_revision().map_err(entry_error)?; + let paths = self + .repository + .snapshot() + .map_err(entry_error)? + .entries() + .map(|entry| entry.path().to_string()) + .collect(); + self.credential_requests + .lock() + .map_err(|_| locked_error())? + .begin(&caller, revision, paths, self.credential_clock.now()) + .map_err(credential_error) + } + + pub fn credential_request( + &self, + token: &str, + caller: &crate::mobile_credentials::CredentialCaller, + ) -> Result { + let revision = self.repository.mutation_revision().map_err(entry_error)?; + self.credential_requests + .lock() + .map_err(|_| locked_error())? + .validate(token, caller, &revision, self.credential_clock.now()) + .map_err(credential_error) + } + + pub fn search_credential_request( + &self, + token: &str, + caller: &crate::mobile_credentials::CredentialCaller, + query: &str, + ) -> Result { + if query.len() > 256 || query.chars().any(char::is_control) { + return Err(credential_error("The search text is invalid.")); + } + let mut request = self.credential_request(token, caller)?; + let query = query.trim().to_lowercase(); + request + .candidates + .retain(|candidate| candidate.path.to_lowercase().contains(&query)); + request.candidates.truncate(100); + Ok(request) + } + + pub fn cancel_credential_request(&self, token: &str) -> Result<(), MobileAuthenticationError> { + self.credential_requests + .lock() + .map_err(|_| locked_error())? + .cancel(token); + Ok(()) + } + + pub fn fill_credential( + &self, + token: &str, + caller: &crate::mobile_credentials::CredentialCaller, + path: &str, + consent: bool, + passphrase: Option, + allowed_usernames: &[String], + ) -> Result { + let request = self.credential_request(token, caller)?; + if !consent + || !request + .candidates + .iter() + .any(|candidate| candidate.path == path) + { + return Err(credential_error( + "Select an entry and explicitly allow this caller to receive its username and password.", + )); + } + self.ensure_repository_idle()?; + self.unlock_entry(path, passphrase)?; + let document = self.open_active_document(path)?; + let value = crate::mobile_credentials::credential_value(&document, allowed_usernames) + .map_err(credential_error)?; + let revision = self.repository.mutation_revision().map_err(entry_error)?; + let mut requests = self + .credential_requests + .lock() + .map_err(|_| locked_error())?; + requests + .validate(token, caller, &revision, self.credential_clock.now()) + .map_err(credential_error)?; + self.ensure_active()?; + requests.cancel(token); + Ok(value) + } + + pub fn stage_credential_save( + &self, + token: &str, + caller: &crate::mobile_credentials::CredentialCaller, + username: SecretBytes, + password: SecretBytes, + ) -> Result<(), MobileAuthenticationError> { + self.credential_request(token, caller)?; + for value in [&username, &password] { + let text = std::str::from_utf8(value.expose()) + .map_err(|_| credential_error("Invalid credential text."))?; + if text.is_empty() || text.len() > 16_384 || text.chars().any(char::is_control) { + return Err(credential_error("Invalid credential field.")); + } + } + let mut body = Vec::with_capacity(4 + username.expose().len() + password.expose().len()); + body.extend_from_slice(&(username.expose().len() as u32).to_be_bytes()); + body.extend_from_slice(username.expose()); + body.extend_from_slice(password.expose()); + // The chosen destination may use a different private key than the default. + let recipients = self + .keys + .infos() + .filter(|key| key.has_secret() && key.can_encrypt()) + .map(|key| self.keys.resolve(key.fingerprint().as_str())) + .collect::, _>>() + .map_err(key_error)?; + let sealed = self + .keys + .encrypt(SecretBytes::new(body), &recipients) + .map_err(key_error)?; + self.credential_requests + .lock() + .map_err(|_| locked_error())? + .stage(token, sealed, self.credential_clock.now()) + .map_err(credential_error) + } + + pub fn save_staged_credential( + &self, + token: &str, + caller: &crate::mobile_credentials::CredentialCaller, + path: &str, + consent: bool, + overwrite: bool, + passphrase: Option, + ) -> Result<(), MobileAuthenticationError> { + if !consent { + return Err(credential_error( + "Confirm the destination before unlocking this save request.", + )); + } + self.credential_request(token, caller)?; + let sealed = self + .credential_requests + .lock() + .map_err(|_| locked_error())? + .staged(token, self.credential_clock.now()) + .map_err(credential_error)?; + let target = EntryPath::parse(path).map_err(entry_error)?; + let recipients = RecipientPolicyManager::new(&self.repository, &self.keys) + .resolve_for_entry(&target, None) + .map_err(|error| entry_detail("Recipients Are Unavailable", error))?; + let probe = self + .keys + .encrypt( + SecretBytes::new(b"IronStorage credential destination authentication".to_vec()), + recipients.recipients(), + ) + .map_err(key_error)?; + self.unlock_ciphertext(&probe, passphrase)?; + let (handle, key) = { + let status = self.status()?; + let active = status.active.as_ref().ok_or_else(locked_error)?; + (active.handle.clone(), active.key.clone()) + }; + let mut provider = KeyOnlyProvider::new(handle, &key); + let body = self + .keys + .decrypt(&sealed, &mut provider) + .map_err(key_error)?; + let header: [u8; 4] = body + .expose() + .get(..4) + .ok_or_else(|| credential_error("Invalid staged credential."))? + .try_into() + .map_err(|_| credential_error("Invalid staged credential."))?; + let length = u32::from_be_bytes(header) as usize; + let username = SecretBytes::new( + body.expose() + .get(4..4 + length) + .ok_or_else(|| credential_error("Invalid staged credential."))? + .to_vec(), + ); + let password = SecretBytes::new( + body.expose() + .get(4 + length..) + .ok_or_else(|| credential_error("Invalid staged credential."))? + .to_vec(), + ); + self.save_credential( + token, + caller, + crate::mobile_credentials::CredentialSaveDestination { + path, + consent, + overwrite, + }, + username, + password, + None, + ) + } + + fn save_credential( + &self, + token: &str, + caller: &crate::mobile_credentials::CredentialCaller, + destination: crate::mobile_credentials::CredentialSaveDestination<'_>, + username: SecretBytes, + password: SecretBytes, + passphrase: Option, + ) -> Result<(), MobileAuthenticationError> { + let crate::mobile_credentials::CredentialSaveDestination { + path, + consent, + overwrite, + } = destination; + self.credential_request(token, caller)?; + if !consent { + return Err(credential_error( + "Confirm the destination before saving this credential.", + )); + } + let target = EntryPath::parse(path).map_err(entry_error)?; + self.ensure_repository_idle()?; + { + let status = self.status()?; + if status + .editors + .values() + .any(|draft| draft.document().path() == &target) + || status + .editor_checkpoints + .values() + .any(|checkpoint| checkpoint.document.path() == &target) + { + return Err(credential_error( + "Close or discard this entry's existing editor before updating it.", + )); + } + } + let exists = VaultWriter::new(&self.repository, &self.keys) + .entry_exists(path) + .map_err(|error| entry_detail("Destination Is Unavailable", error))?; + if exists && !overwrite { + return Err(credential_error( + "This destination exists. Explicitly confirm updating it.", + )); + } + let editor = if exists { + self.unlock_entry(path, passphrase)?; + self.begin_entry_editor(path)? + } else { + let name = target + .as_path() + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| credential_error("Invalid destination."))?; + self.begin_create_entry( + &target.parent_directory().as_path().to_string_lossy(), + name, + passphrase, + )? + }; + let editor_id = editor.id(); + let result = (|| { + let mut document = { + let mut status = self.status()?; + status + .editors + .remove(&editor_id) + .ok_or_else(editor_missing)? + .into_document() + }; + let original_plaintext = document.serialize(); + crate::mobile_credentials::set_credential(&mut document, username, password) + .map_err(credential_error)?; + self.credential_request(token, caller)?; + self.ensure_active()?; + if exists && document.serialize().expose() == original_plaintext.expose() { + let current = self.repository.read_entry(&target).map_err(entry_error)?; + if Some(¤t) != document.original_ciphertext() { + return Err(credential_error( + "The destination changed during this save.", + )); + } + self.credential_request(token, caller)?; + self.ensure_active()?; + return Ok(()); + } + self.reserve_entry_mutation()?; + let result = (|| { + let mut commit = AutomaticEntryCommitter::for_entry( + &self.repository, + path, + self.git_identity()?, + ) + .map_err(|error| entry_detail("Credential Could Not Be Saved", error))?; + let mut guarded = CredentialCommitter { + authentication: self, + token, + commit: &mut commit, + }; + EntryDocumentService::new(&self.repository, &self.keys) + .save_recoverable(&document, None, &mut guarded) + .map_err(document_error)?; + self.reconcile_saved_document(&document); + Ok(()) + })(); + self.release_entry_mutation(); + result + })(); + let _ = self.discard_entry_editor(editor_id); + if result.is_ok() { + self.cancel_credential_request(token)?; + } + result + } + /// Validate an entry unlock and retain only the key passphrase for the shared lease. pub fn unlock_entry( &self, @@ -2202,6 +2539,10 @@ impl MobileAuthentication { } pub fn manual_lock(&self) -> Result<(), MobileAuthenticationError> { + self.credential_requests + .lock() + .map_err(|_| locked_error())? + .clear(); self.session .manual_lock() .map_err(MobileAuthenticationError::authentication)?; @@ -2236,6 +2577,10 @@ impl MobileAuthentication { } pub fn cancel(&self) -> Result<(), MobileAuthenticationError> { + self.credential_requests + .lock() + .map_err(|_| locked_error())? + .clear(); self.session .cancel() .map_err(MobileAuthenticationError::authentication)?; @@ -2792,6 +3137,41 @@ fn entry_detail(title: &str, error: impl fmt::Display) -> MobileAuthenticationEr ) } +fn credential_error(detail: &str) -> MobileAuthenticationError { + MobileAuthenticationError::new( + MobileAuthenticationErrorKind::Entry, + "Credential Request Rejected", + detail, + ) +} + +struct CredentialCommitter<'a> { + authentication: &'a MobileAuthentication, + token: &'a str, + commit: &'a mut AutomaticEntryCommitter, +} +impl crate::write::EntryCommitter for CredentialCommitter<'_> { + fn commit( + &mut self, + change: &crate::write::EntryCommit, + ) -> Result<(), crate::write::EntryCommitError> { + self.authentication.ensure_active().map_err(|_| { + crate::write::EntryCommitError::new("Credential authentication expired before saving.") + })?; + let requests = self + .authentication + .credential_requests + .lock() + .map_err(|_| crate::write::EntryCommitError::new("Credential request cancelled."))?; + if !requests.active(self.token, self.authentication.credential_clock.now()) { + return Err(crate::write::EntryCommitError::new( + "Credential request cancelled or expired.", + )); + } + self.commit.commit(change) + } +} + #[cfg(test)] mod tests { use super::{ @@ -2844,6 +3224,234 @@ mod tests { (root, authentication, clock) } + #[test] + fn credential_selection_authentication_cancellation_revision_and_timeout() { + use crate::{mobile_credentials::CredentialCaller, repository::SecretBytes}; + let (_root, auth, clock) = + otp_fixture("username: alice\nurl: https://example.com/\nnotes: keep me"); + let caller = CredentialCaller { + package: "example.client".into(), + certificates: vec!["AA".repeat(32)], + origin: Some("https://spoofed.example/".into()), + }; + let request = auth.begin_credential_request(caller.clone()).unwrap(); + assert_eq!(request.identity.title, "example.client"); + assert!( + request + .candidates + .iter() + .any(|candidate| candidate.path == "otp") + ); + assert!(!auth.state().unwrap().unlocked()); + assert!( + auth.fill_credential(&request.token, &caller, "otp", false, None, &[]) + .is_err() + ); + assert!( + auth.fill_credential(&request.token, &caller, "otp", true, None, &[]) + .is_err() + ); + let value = auth + .fill_credential( + &request.token, + &caller, + "otp", + true, + Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())), + &[], + ) + .unwrap(); + assert_eq!(value.username.expose(), b"alice"); + assert_eq!(value.password.expose(), b"fixture-password"); + assert!( + auth.fill_credential(&request.token, &caller, "otp", true, None, &[]) + .is_err() + ); + let request = auth.begin_credential_request(caller.clone()).unwrap(); + assert!( + auth.fill_credential(&request.token, &caller, "otp", true, None, &["bob".into()]) + .is_err() + ); + auth.manual_lock().unwrap(); + assert!(auth.credential_request(&request.token, &caller).is_err()); + let request = auth.begin_credential_request(caller.clone()).unwrap(); + clock.0.store(120, std::sync::atomic::Ordering::Relaxed); + assert!(auth.credential_request(&request.token, &caller).is_err()); + auth.expire_credential_requests().unwrap(); + assert!(auth.credential_requests.lock().unwrap().pending_is_empty()); + clock.0.store(121, std::sync::atomic::Ordering::Relaxed); + let request = auth.begin_credential_request(caller.clone()).unwrap(); + auth.unlock_entry( + "otp", + Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())), + ) + .unwrap(); + let page = auth.entry_page("otp", 0).unwrap(); + let field = page.page().sections()[0].fields()[0].id(); + auth.replace_entry_field("otp", field, "changed".into()) + .unwrap(); + assert!( + auth.fill_credential(&request.token, &caller, "otp", true, None, &[]) + .is_err() + ); + } + + #[test] + fn provider_save_authenticates_the_destination_key_instead_of_the_default_key() { + use crate::{ + crypto::KeyStore, + git::{GitIdentity, GitRepository}, + mobile_credentials::CredentialCaller, + repository::{Repository, SecretBytes}, + }; + let (_root, private, backend) = crate::mobile_onboarding::android::tests::home_fixture(); + let config = private.configuration().unwrap().unwrap(); + std::fs::write( + config.key_material().join("bob.asc"), + include_bytes!("../tests/fixtures/compatibility/keys/bob-secret.asc"), + ) + .unwrap(); + let keys = KeyStore::load(config.key_material()).unwrap(); + let bob = keys + .infos() + .find(|key| key.user_ids().iter().any(|id| id.contains("Bob"))) + .unwrap(); + let repository = Repository::open(config.vault()).unwrap(); + std::fs::create_dir(config.vault().join("bob")).unwrap(); + std::fs::write( + config.vault().join("bob/.gpg-id"), + format!("{}\n", bob.fingerprint()), + ) + .unwrap(); + GitRepository::open(&repository, GitIdentity::ironstorage()) + .unwrap() + .commit_all("Add Bob recipient fixture") + .unwrap(); + let auth = private + .authentication( + backend, + std::sync::Arc::new(OtpClock(std::sync::atomic::AtomicU64::new(0))), + ) + .unwrap(); + let caller = CredentialCaller { + package: "example.client".into(), + certificates: vec!["AA".repeat(32)], + origin: None, + }; + let request = auth.begin_credential_request(caller.clone()).unwrap(); + auth.stage_credential_save( + &request.token, + &caller, + SecretBytes::new(b"bob".to_vec()), + SecretBytes::new(b"destination-password".to_vec()), + ) + .unwrap(); + auth.save_staged_credential( + &request.token, + &caller, + "bob/credential", + true, + false, + Some(SecretBytes::new(b"fixture-bob-passphrase".to_vec())), + ) + .unwrap(); + let document = auth.open_active_document("bob/credential").unwrap(); + assert_eq!( + crate::mobile_credentials::credential_value(&document, &[]) + .unwrap() + .password + .expose(), + b"destination-password" + ); + } + + #[test] + fn provider_saves_require_destination_consent_and_preserve_unrelated_fields() { + use crate::{mobile_credentials::CredentialCaller, repository::SecretBytes}; + let (_root, auth, _clock) = otp_fixture("username: alice\nnotes: preserve this"); + let caller = CredentialCaller { + package: "example.client".into(), + certificates: vec!["AA".repeat(32)], + origin: None, + }; + let request = auth.begin_credential_request(caller.clone()).unwrap(); + auth.stage_credential_save( + &request.token, + &caller, + SecretBytes::new(b"bob".to_vec()), + SecretBytes::new(b"new-password".to_vec()), + ) + .unwrap(); + assert!(!auth.state().unwrap().unlocked()); + assert!( + auth.save_staged_credential( + &request.token, + &caller, + "otp", + false, + false, + Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())) + ) + .is_err() + ); + assert!(!auth.state().unwrap().unlocked()); + assert!( + auth.save_staged_credential( + &request.token, + &caller, + "otp", + true, + false, + Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())) + ) + .is_err() + ); + auth.save_staged_credential(&request.token, &caller, "otp", true, true, None) + .unwrap(); + let document = auth.open_active_document("otp").unwrap(); + let value = crate::mobile_credentials::credential_value(&document, &[]).unwrap(); + assert_eq!(value.username.expose(), b"bob"); + assert_eq!(value.password.expose(), b"new-password"); + assert!( + document + .fields() + .iter() + .any(|field| field.value() == b"preserve this") + ); + let target = crate::repository::EntryPath::parse("otp").unwrap(); + let original = auth.repository.read_entry(&target).unwrap(); + let repeated = auth.begin_credential_request(caller.clone()).unwrap(); + auth.stage_credential_save( + &repeated.token, + &caller, + SecretBytes::new(b"bob".to_vec()), + SecretBytes::new(b"new-password".to_vec()), + ) + .unwrap(); + auth.save_staged_credential(&repeated.token, &caller, "otp", true, true, None) + .unwrap(); + assert_eq!(auth.repository.read_entry(&target).unwrap(), original); + assert!(auth.credential_request(&repeated.token, &caller).is_err()); + let request = auth.begin_credential_request(caller.clone()).unwrap(); + auth.stage_credential_save( + &request.token, + &caller, + SecretBytes::new(b"carol".to_vec()), + SecretBytes::new(b"created-password".to_vec()), + ) + .unwrap(); + auth.save_staged_credential(&request.token, &caller, "created", true, false, None) + .unwrap(); + let document = auth.open_active_document("created").unwrap(); + assert_eq!( + crate::mobile_credentials::credential_value(&document, &[]) + .unwrap() + .username + .expose(), + b"carol" + ); + } + #[test] fn otp_presentation_matches_rfc_vectors_and_boundaries_without_renewing_the_lease() { use crate::{otp::OtpCodeValidity, repository::SecretBytes}; diff --git a/crates/storage/src/mobile_credentials.rs b/crates/storage/src/mobile_credentials.rs new file mode 100644 index 0000000..f6639b9 --- /dev/null +++ b/crates/storage/src/mobile_credentials.rs @@ -0,0 +1,584 @@ +//! Request-bound credentials shared by Android Credential Manager and Autofill. +//! Callers supply OS facts; trust, matching, selection and entry parsing live here. + +use std::{collections::BTreeMap, time::Duration}; + +use crate::{ + document::{EntryDocument, EntryFieldKind}, + repository::SecretBytes, +}; +use rand::RngCore; + +const REQUEST_LIFETIME: Duration = Duration::from_secs(120); +const CHROME_RELEASE: &str = "F0FD6C5B410F25CB25C3B53346C8972FAE30F8EE7411DF910480AD6B2D60DB83"; +const FIREFOX_RELEASE: &str = "A78B62A5165B4494B2FEAD9E76A280D22D937FEE6251AECE599446B2EA319B04"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CredentialCaller { + pub package: String, + /// Current signers of the installed package, obtained from PackageManager. + pub certificates: Vec, + pub origin: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CredentialIdentity { + pub title: String, + pub binding: String, + pub match_name: String, +} + +pub fn caller_identity(caller: &CredentialCaller) -> Result { + let package = &caller.package; + if package.len() > 255 + || !package.contains('.') + || package.split('.').any(|part| { + part.is_empty() + || !part + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + }) + { + return Err("The requesting application is invalid."); + } + if caller.certificates.is_empty() || caller.certificates.len() > 8 { + return Err("The requesting application's signature is unavailable."); + } + let mut certificates = Vec::new(); + for certificate in &caller.certificates { + let fingerprint = certificate.replace(':', "").to_ascii_uppercase(); + if fingerprint.len() != 64 || !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("The requesting application's signature is invalid."); + } + certificates.push(fingerprint); + } + certificates.sort(); + certificates.dedup(); + let privileged = match package.as_str() { + "com.android.chrome" => certificates.as_slice() == [CHROME_RELEASE], + "org.mozilla.firefox" => certificates.as_slice() == [FIREFOX_RELEASE], + _ => false, + }; + let app_binding = format!("app:{package}:{}", certificates.join(",")); + if let Some(origin) = &caller.origin { + if origin.len() > 2048 { + return Err("The website identity is invalid."); + } + // Untrusted clients cannot turn an asserted webDomain into a website permission. + if privileged { + let url = url::Url::parse(origin).map_err(|_| "The website identity is invalid.")?; + if url.scheme() != "https" + || !url.username().is_empty() + || url.password().is_some() + || url.host_str().is_none() + || url.query().is_some() + || url.fragment().is_some() + || url.path() != "/" + { + return Err("Only a verified HTTPS website origin is supported."); + } + let canonical = url.origin().ascii_serialization(); + return Ok(CredentialIdentity { + title: canonical.clone(), + binding: format!("web:{canonical}:{app_binding}"), + match_name: url.host_str().unwrap_or_default().to_owned(), + }); + } + } + Ok(CredentialIdentity { + title: package.clone(), + binding: app_binding, + match_name: package.clone(), + }) +} + +/// Reviewed release signing identities, consumed by AndroidX's trusted-origin verifier. +pub fn privileged_callers_json() -> String { + format!( + r#"{{"apps":[{{"type":"android","info":{{"package_name":"com.android.chrome","signatures":[{{"build":"release","cert_fingerprint_sha256":"{}"}}]}}}},{{"type":"android","info":{{"package_name":"org.mozilla.firefox","signatures":[{{"build":"release","cert_fingerprint_sha256":"{}"}}]}}}}]}}"#, + fingerprint_colons(CHROME_RELEASE), + fingerprint_colons(FIREFOX_RELEASE) + ) +} +fn fingerprint_colons(value: &str) -> String { + value + .as_bytes() + .chunks(2) + .map(|chunk| std::str::from_utf8(chunk).unwrap_or_default()) + .collect::>() + .join(":") +} + +/// Raw AssistStructure facts, with the OS AutofillId retained only in Kotlin. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CredentialField { + pub index: u32, + pub hints: Vec, + pub html_attributes: Vec<(String, String)>, + pub input_type: u32, + pub focused: bool, + pub editable: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CredentialForm { + pub username: u32, + pub password: u32, +} + +pub fn credential_form(fields: &[CredentialField]) -> Result { + if fields.len() > 512 { + return Err("The client form is too large."); + } + let mut users = Vec::new(); + let mut passwords = Vec::new(); + let mut indexes = std::collections::BTreeSet::new(); + for field in fields { + if !indexes.insert(field.index) + || field.hints.len() > 16 + || field.hints.iter().any(|hint| hint.len() > 128) + { + return Err("The client form is invalid."); + } + if !field.editable { + continue; + } + if field.html_attributes.len() > 32 + || field + .html_attributes + .iter() + .any(|(key, value)| key.len() > 128 || value.len() > 256) + { + return Err("The HTML form is invalid."); + } + let html_password = field.html_attributes.iter().any(|(key, value)| { + (key == "type" && value == "password") + || (key == "autocomplete" && value == "current-password") + }); + let html_user = field.html_attributes.iter().any(|(key, value)| { + key == "autocomplete" && matches!(value.as_str(), "username" | "email") + }); + let password = html_password + || field.hints.iter().any(|hint| hint == "password") + || matches!(field.input_type & 0xfff, 0x81 | 0x91 | 0xe1 | 0x12); + let user = html_user + || field + .hints + .iter() + .any(|hint| matches!(hint.as_str(), "username" | "emailAddress")) + || field.input_type & 0xfff == 0x21; + if password && user { + return Err("The client form has conflicting field roles."); + } + if password { + passwords.push(field.index); + } + if user { + users.push(field.index); + } + } + match (users.as_slice(), passwords.as_slice()) { + ([username], [password]) if username != password => Ok(CredentialForm { + username: *username, + password: *password, + }), + _ => Err("Select a form with exactly one username and one password field."), + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CredentialCandidate { + pub path: String, + pub matched: bool, +} +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CredentialRequest { + pub token: String, + pub identity: CredentialIdentity, + pub revision: String, + pub candidates: Vec, +} +struct PendingCredentialRequest { + request: CredentialRequest, + deadline: Duration, + save: Option, +} + +#[derive(Default)] +pub(crate) struct CredentialRequests { + pending: BTreeMap, +} +impl CredentialRequests { + #[cfg(test)] + pub fn pending_is_empty(&self) -> bool { + self.pending.is_empty() + } + pub fn expire(&mut self, now: Duration) { + self.pending.retain(|_, request| now < request.deadline); + } + pub fn stage( + &mut self, + token: &str, + value: crate::repository::EncryptedEntry, + now: Duration, + ) -> Result<(), &'static str> { + let pending = self + .pending + .get_mut(token) + .filter(|pending| now < pending.deadline) + .ok_or("The save request expired.")?; + pending.save = Some(value); + Ok(()) + } + pub fn staged( + &self, + token: &str, + now: Duration, + ) -> Result { + let value = self + .pending + .get(token) + .filter(|pending| now < pending.deadline) + .and_then(|pending| pending.save.as_ref()) + .ok_or("The save request expired or was cancelled.")?; + Ok(value.clone()) + } + pub fn active(&self, token: &str, now: Duration) -> bool { + self.pending + .get(token) + .is_some_and(|request| now < request.deadline) + } + pub fn clear(&mut self) { + self.pending.clear(); + } + pub fn cancel(&mut self, token: &str) { + self.pending.remove(token); + } + pub fn begin( + &mut self, + caller: &CredentialCaller, + revision: String, + paths: Vec, + now: Duration, + ) -> Result { + self.pending.retain(|_, pending| now < pending.deadline); + if self.pending.len() >= 16 { + return Err("Too many credential requests are pending."); + } + let identity = caller_identity(caller)?; + let deadline = now + .checked_add(REQUEST_LIFETIME) + .ok_or("The credential request deadline is invalid.")?; + let mut random = [0; 32]; + rand::thread_rng().fill_bytes(&mut random); + let token = data_encoding::HEXLOWER.encode(&random); + let mut candidates: Vec<_> = paths + .into_iter() + .map(|path| { + let matched = path + .split('/') + .any(|part| part.eq_ignore_ascii_case(&identity.match_name)); + CredentialCandidate { path, matched } + }) + .collect(); + candidates.sort_by(|a, b| b.matched.cmp(&a.matched).then_with(|| a.path.cmp(&b.path))); + let request = CredentialRequest { + token: token.clone(), + identity, + revision, + candidates, + }; + self.pending.insert( + token, + PendingCredentialRequest { + request: request.clone(), + deadline, + save: None, + }, + ); + Ok(request) + } + pub fn validate( + &self, + token: &str, + caller: &CredentialCaller, + revision: &str, + now: Duration, + ) -> Result { + let pending = self + .pending + .get(token) + .ok_or("This credential request was cancelled or the process restarted.")?; + if now >= pending.deadline { + return Err("This credential request expired. Request it again."); + } + if pending.request.identity != caller_identity(caller)? { + return Err("The credential caller changed."); + } + if pending.request.revision != revision { + return Err("The repository changed. Request credentials again."); + } + Ok(pending.request.clone()) + } +} + +pub struct CredentialSaveDestination<'a> { + pub path: &'a str, + pub consent: bool, + pub overwrite: bool, +} + +/// Secret buffers are erased on drop and never included in Debug output. +pub struct CredentialValue { + pub username: SecretBytes, + pub password: SecretBytes, +} +pub fn credential_value( + document: &EntryDocument, + allowed_usernames: &[String], +) -> Result { + let fields = document.fields(); + let passwords: Vec<_> = fields + .iter() + .filter(|field| field.metadata().kind() == EntryFieldKind::Password) + .collect(); + let usernames: Vec<_> = fields + .iter() + .filter(|field| { + matches!( + field.metadata().kind(), + EntryFieldKind::Username | EntryFieldKind::Email + ) + }) + .collect(); + let ([password], [username]) = (passwords.as_slice(), usernames.as_slice()) else { + return Err("This entry must contain exactly one username or email and one password."); + }; + for field in [password, username] { + let value = + std::str::from_utf8(field.value()).map_err(|_| "The credential is not UTF-8 text.")?; + if value.is_empty() || value.len() > 16_384 || value.chars().any(char::is_control) { + return Err( + "The credential field is empty, too large, or contains control characters.", + ); + } + } + if !allowed_usernames.is_empty() + && !allowed_usernames + .iter() + .any(|name| name.as_bytes() == username.value()) + { + return Err("The client requested a different account."); + } + Ok(CredentialValue { + username: SecretBytes::new(username.value().to_vec()), + password: SecretBytes::new(password.value().to_vec()), + }) +} + +/// Preserve all unrelated pass fields when explicitly updating a credential. +pub fn set_credential( + document: &mut EntryDocument, + username: SecretBytes, + password: SecretBytes, +) -> Result<(), &'static str> { + use crate::document::EntryFieldDraft; + for value in [&username, &password] { + let text = + std::str::from_utf8(value.expose()).map_err(|_| "The credential is not UTF-8 text.")?; + if text.is_empty() || text.len() > 16_384 || text.chars().any(char::is_control) { + return Err( + "The credential field is empty, too large, or contains control characters.", + ); + } + } + let passwords: Vec<_> = document + .fields() + .iter() + .filter(|field| field.metadata().kind() == EntryFieldKind::Password) + .map(|field| field.id()) + .collect(); + let users: Vec<_> = document + .fields() + .iter() + .filter(|field| { + matches!( + field.metadata().kind(), + EntryFieldKind::Username | EntryFieldKind::Email + ) + }) + .map(|field| { + ( + field.id(), + field.metadata().name().unwrap_or("username").to_owned(), + ) + }) + .collect(); + if passwords.len() != 1 || users.len() > 1 { + return Err("This entry has ambiguous credential fields; edit it in IronStorage first."); + } + document + .update( + passwords[0], + EntryFieldDraft::line(password.expose().to_vec()) + .map_err(|_| "Invalid password field.")?, + ) + .map_err(|_| "Could not update the password.")?; + let (id, name) = users + .first() + .map(|(id, name)| (Some(*id), name.as_str())) + .unwrap_or((None, "username")); + let replacement = EntryFieldDraft::field(name, username.expose().to_vec()) + .map_err(|_| "Invalid username field.")?; + if let Some(id) = id { + document + .update(id, replacement) + .map_err(|_| "Could not update the username.")?; + } else { + document + .add(document.fields().len(), replacement) + .map_err(|_| "Could not add the username.")?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + fn caller() -> CredentialCaller { + CredentialCaller { + package: "example.client".into(), + certificates: vec!["AA".repeat(32)], + origin: None, + } + } + #[test] + fn untrusted_origins_stay_app_scoped_and_signature_changes_fail() { + let mut client = caller(); + client.origin = Some("https://accounts.example/".into()); + let identity = caller_identity(&client).unwrap(); + assert_eq!(identity.title, "example.client"); + let mut requests = CredentialRequests::default(); + let request = requests + .begin( + &client, + "r1".into(), + vec!["example.client/user".into()], + Duration::ZERO, + ) + .unwrap(); + assert!(request.candidates[0].matched); + client.certificates = vec!["BB".repeat(32)]; + assert!( + requests + .validate(&request.token, &client, "r1", Duration::ZERO) + .is_err() + ); + } + #[test] + fn pinned_browser_origin_and_hostile_origins() { + let mut client = caller(); + client.package = "com.android.chrome".into(); + client.certificates = vec![CHROME_RELEASE.into()]; + client.origin = Some("https://EXAMPLE.com:443/".into()); + assert_eq!( + caller_identity(&client).unwrap().title, + "https://example.com" + ); + for origin in [ + "http://example.com/", + "https://user@example.com/", + "https://example.com/login", + "https://example.com/?spoof=true", + ] { + client.origin = Some(origin.into()); + assert!(caller_identity(&client).is_err()); + } + } + #[test] + fn cancellation_deadline_revision_and_recreation_reject_requests() { + let client = caller(); + let mut requests = CredentialRequests::default(); + let request = requests + .begin(&client, "r1".into(), vec![], Duration::ZERO) + .unwrap(); + assert!( + requests + .validate(&request.token, &client, "r2", Duration::ZERO) + .is_err() + ); + assert!( + requests + .validate(&request.token, &client, "r1", REQUEST_LIFETIME) + .is_err() + ); + requests.cancel(&request.token); + assert!( + requests + .validate(&request.token, &client, "r1", Duration::ZERO) + .is_err() + ); + } + #[test] + fn html_and_duplicate_os_ids_use_the_same_strict_form_contract() { + let user = CredentialField { + index: 1, + hints: vec![], + html_attributes: vec![("autocomplete".into(), "username".into())], + input_type: 0, + focused: true, + editable: true, + }; + let password = CredentialField { + index: 2, + hints: vec![], + html_attributes: vec![("type".into(), "password".into())], + input_type: 0, + focused: false, + editable: true, + }; + assert_eq!( + credential_form(&[user.clone(), password.clone()]) + .unwrap() + .username, + 1 + ); + let mut duplicate = password; + duplicate.index = 1; + assert!(credential_form(&[user.clone(), duplicate]).is_err()); + assert!(credential_form(&vec![user; 513]).is_err()); + } + + #[test] + fn ambiguous_and_conflicting_forms_are_rejected() { + let user = CredentialField { + index: 1, + hints: vec!["username".into()], + html_attributes: vec![], + input_type: 1, + focused: true, + editable: true, + }; + let password = CredentialField { + index: 2, + hints: vec![], + html_attributes: vec![], + input_type: 0x81, + focused: false, + editable: true, + }; + assert_eq!( + credential_form(&[user.clone(), password.clone()]).unwrap(), + CredentialForm { + username: 1, + password: 2 + } + ); + let mut duplicate = password.clone(); + duplicate.index = 3; + assert!(credential_form(&[user.clone(), password, duplicate]).is_err()); + let mut conflict = user; + conflict.hints.push("password".into()); + assert!(credential_form(&[conflict]).is_err()); + } +} diff --git a/crates/storage/src/mobile_entry.rs b/crates/storage/src/mobile_entry.rs index 5264983..45662a9 100644 --- a/crates/storage/src/mobile_entry.rs +++ b/crates/storage/src/mobile_entry.rs @@ -448,6 +448,10 @@ impl MobileEntryDraft { Ok(()) } + pub(crate) fn into_document(self) -> EntryDocument { + self.document + } + pub fn document(&self) -> &EntryDocument { &self.document } diff --git a/docs/android/credentials.md b/docs/android/credentials.md new file mode 100644 index 0000000..ccecdc4 --- /dev/null +++ b/docs/android/credentials.md @@ -0,0 +1,130 @@ +# Android password providers + +IronStorage supplies password credentials through AndroidX Credential Provider on +Android 14 and newer and through framework Autofill on Android 8 and newer. Both +use the same `NativeCredentialRepository` facade and storage-owned request, +matching, search, authentication, credential-field and save APIs. Services and +selection run inside the private application UID and share one Rust owner; +requesting applications run outside that boundary. There is no second secret +cache or service-specific authentication lease. + +Enable IronStorage in its Preferences → Autofill and Credential Manager providers +section. The two status rows open Android's provider settings. For current Chrome, +select the provider in Android settings, then open Chrome Settings → Autofill and +passwords → Autofill settings, choose **Autofill using another service**, and +restart Chrome. See [Chrome's instructions](https://support.google.com/chrome/answer/142893?co=GENIE.Platform%3DAndroid&hl=en). + +## Requests and identity + +Candidate responses contain entry paths only. Rust ranks exact path components +matching the verified application package or website host. Manual search is also +Rust-owned and returns up to 100 matching paths; refine the search to find another +entry. Listing candidates never decrypts an entry or restores a passphrase. + +The OS adapter supplies installed package signing fingerprints. +The Android 11+ package-visibility permission is required to recheck the current +signers of arbitrary requesting apps, including clients with no launcher intent. +IronStorage queries only the package supplied by the OS request and never +enumerates installed apps. See [Android's package visibility contract](https://developer.android.com/training/package-visibility/declaring). +The manifest suppresses only the lint recommendation to replace this permission +with static queries: arbitrary OS-mediated clients have no common discoverable +intent filter. A Google Play release must declare and justify this use under +[Play's package visibility policy](https://support.google.com/googleplay/android-developer/answer/10158779). +Credential Manager's supplied signing identity must agree with PackageManager. Rust's +reviewed release-signature list is passed to AndroidX `CallingAppInfo.getOrigin`; +Rust then validates and canonicalizes the verified HTTPS origin. The list covers +release Chrome and Firefox. Unsupported or untrusted origin claims remain bound +to the verified application identity. Framework Autofill never treats raw +AssistStructure `webDomain` assertions as a website permission. + +Kotlin translates AssistStructure identifiers, hints, HTML attributes and input +facts. Rust chooses exactly one username/email and one password role and rejects +ambiguous roles, duplicate identifiers and oversized facts. It uses the lossless +pass document's typed username/email/password fields after explicit selection; +it rejects ambiguous entries and filters any client-specified account constraint. + +Requests have random 256-bit, process-local identifiers, a two-minute monotonic +deadline, an exact caller binding and the repository mutation revision. At most +16 Rust requests are outstanding. The final secret read checks identity, revision, +deadline and the shared authentication lease again and consumes the request. +Cancellation, relock, device lock, memory pressure and process recreation revoke +requests. Secret reads and metadata refreshes do not renew the lease. + +The selection activity is not exported. PendingIntents have an explicit component, +a fixed action and unique immutable base data; mutability exists only so Android +can attach its complete request. The activity verifies that request against its +original caller and form. It uses secure-window and recents protections, rejects +obscured touches and clears its passphrase when focus or foreground ownership is +lost. It revokes the lease after successful handoff as well as cancellation. + +## Filling and saving + +Users select an entry, explicitly consent to the named recipient receiving its +username and password, and authenticate with a valid lease, biometrics or the GPG +passphrase. Only then does Rust release the selected fields to the OS response. +Autofill supplies an authentication suggestion, including an inline presentation +when the IME advertises the supported AndroidX style. Its attribution PendingIntent +is immutable and opens the ordinary app; the private authentication PendingIntent +is not exposed as IME attribution. Responses contain no other +entry fields. Credentials are never written to logs, saved activity state, +notifications or an application dataset cache. Android 12+ authentication results +use the framework's ephemeral-dataset flag. Older Android versions retain the +handed-off values in their current framework autofill session; IronStorage does +not keep a dataset cache. + +Save offers use the same request binding. Submitted values are immediately sealed +with the installed encryption-capable private-key identities and retained in bounded process memory only. +They expire with the request. Users choose an entry path, confirm saving and +separately authorize updating an existing destination. Rust authenticates the chosen destination's recipient policy, including a different key than the default, and validates the +repository and authentication again, preserves unrelated fields, rejects an open +editor of the target and uses the recoverable structured-editor/Git transaction. +Its commit guard refuses cancelled, expired or relocked requests. There is no +silent capture and no competing password-store format. +An explicitly confirmed update containing identical fields succeeds without +rewriting the ciphertext, after rechecking the original destination and request. + +## Verification + +Rust tests use real encrypted stores to cover selection, missing consent, missing +lease, account constraints, one-use consumption, cancellation, changed signatures, +untrusted and invalid origins, stale repositories, expiry, ambiguous forms and +explicit create/update preservation. Native instrumentation exercises the same +production facade with real GPG and repository transactions on API 26 ARM64 and +strict 16-KB API 37 ARM64. + +`android/provider-client` builds a standalone fixture APK with synthetic fields and actual +Credential Manager client calls. Its public HTML asset can be served on host loopback port 8765 and reached with emulator `adb reverse tcp:8765 tcp:8765`; the form verifies synthetic values locally and submits nothing. +The opt-in `ProviderFixtureSetupTest` seeds an +unconfigured retained emulator through production onboarding and refuses to +replace existing keys or a configured store. Its debug-only Hilt access hook is +absent from release builds. The fixture passphrase and credentials are public +synthetic values in the test source, not user credentials. + +Computer-use validation on the retained strict 16-KB API 37 ARM64 emulator +confirmed real Credential Manager and native framework Autofill fills, plus a +Chrome fill whose local form checked both exact synthetic values. Chrome +145.0.7632.218 was used as explicitly requested by the user; no browser update +was installed. On this fixture Chrome offered Autofill when its password field +was focused. Before authentication, only the unlock/search suggestion and entry +paths were shown. Every handoff relocked the provider, and interrupted requests +expired without filling. + +Credential Manager also saved `fixture-bob` at an explicitly selected new +synthetic destination after consent and authentication. The opt-in +`providerSavedFixtureValidation=true` instrumentation read the resulting username +and password through the production Rust credential facade and verified both +submitted fields. +Framework Autofill's actual system save offer also created a second explicitly +selected destination; the same readback test verified both submitted fields there. +Overwrite without its separate confirmation was rejected; a confirmed identical +update completed successfully through the actual system dialog. +The separate `providerFixtureValidation=true` regression checks +installed caller visibility and verifies that normal UI/cache trim callbacks +preserve locked metadata requests while genuine memory pressure revokes them. + +The integration follows the official [Credential Provider guide](https://developer.android.com/identity/sign-in/credential-provider), +[Autofill service contract](https://developer.android.com/reference/android/service/autofill/AutofillService) +and [inline suggestion contract](https://developer.android.com/identity/autofill/ime-autofill). +AndroidX Credentials 1.6.0 and Autofill 1.3.0 are pinned with locks, checksums and +Apache-2.0 license inventory entries. Emulator evidence is ARM-only; no physical +or x86_64 runtime coverage is claimed. diff --git a/tools/android-build/src/gates.rs b/tools/android-build/src/gates.rs index 861b1bb..1a483db 100644 --- a/tools/android-build/src/gates.rs +++ b/tools/android-build/src/gates.rs @@ -61,8 +61,20 @@ fn manifest_text(source: &str) -> Result<(), Box> { "de.rfc1437.ironstorage.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION", "android.permission.INTERNET", "android.permission.CAMERA", + "android.permission.QUERY_ALL_PACKAGES", ]; for node in root.children().filter(|node| node.is_element()) { + if node.attribute((ANDROID, "name")) == Some("android.permission.QUERY_ALL_PACKAGES") + && !application.children().any(|service| { + service.has_tag_name("service") + && service.attribute((ANDROID, "name")) + == Some("de.rfc1437.ironstorage.providers.IronAutofillService") + && service.attribute((ANDROID, "permission")) + == Some("android.permission.BIND_AUTOFILL_SERVICE") + }) + { + return Err("package visibility requires the reviewed autofill provider".into()); + } if node.tag_name().name().starts_with("uses-permission") && !allowed.contains(&node.attribute((ANDROID, "name")).unwrap_or("")) { @@ -109,6 +121,46 @@ fn manifest_text(source: &str) -> Result<(), Box> { { return Err("unreviewed launcher intent".into()); } + } else if node.has_tag_name("service") + && matches!( + node.attribute((ANDROID, "name")), + Some( + "de.rfc1437.ironstorage.providers.IronAutofillService" + | "de.rfc1437.ironstorage.providers.IronCredentialProviderService" + ) + ) + { + let autofill = node.attribute((ANDROID, "name")) + == Some("de.rfc1437.ironstorage.providers.IronAutofillService"); + let (permission, action, metadata) = if autofill { + ( + "android.permission.BIND_AUTOFILL_SERVICE", + "android.service.autofill.AutofillService", + "android.autofill", + ) + } else { + ( + "android.permission.BIND_CREDENTIAL_PROVIDER_SERVICE", + "android.service.credentials.CredentialProviderService", + "android.credentials.provider", + ) + }; + let actions: Vec<_> = node + .descendants() + .filter(|node| node.has_tag_name("action") || node.has_tag_name("category")) + .filter_map(|node| node.attribute((ANDROID, "name"))) + .collect(); + if node.attribute((ANDROID, "exported")) != Some("true") + || node.attribute((ANDROID, "permission")) != Some(permission) + || actions != [action] + || !node.children().any(|node| { + node.has_tag_name("meta-data") + && node.attribute((ANDROID, "name")) == Some(metadata) + && node.attribute((ANDROID, "resource")).is_some() + }) + { + return Err("credential services must retain their system-only binding permissions and reviewed actions".into()); + } } else if node.has_tag_name("receiver") && node.attribute((ANDROID, "name")) == Some("androidx.profileinstaller.ProfileInstallReceiver") @@ -442,6 +494,10 @@ mod tests { r#""# ); assert!(manifest_text(&source).is_ok()); + assert!(manifest_text(&source.replace( + ""# + ); + assert!(manifest_text(&source).is_ok()); + let visible = source.replace( + " Result<(), Box> { for (module, allowed) in [ ("app", &["features", "core", "platform"][..]), ("performance", &["app"][..]), + ("provider-client", &[][..]), ("features", &["core"][..]), ("core", &["rust-bridge"][..]), ("platform", &["core"][..]),