Implement Android Credential Manager and Autofill providers (#109)
Android ARM verification / rust (push) Failing after 49m12s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Failing after 21s

This commit is contained in:
hugo committed 2026-10-12 19:18:10 +02:00
1 parent 316a875d63
commit 0c8366d34f
48 files changed
+4138 -78

No files matched your search

+1 -1
View File
@@ -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")
+2
View File
@@ -70,6 +70,8 @@ kotlin {
}
dependencies {
implementation(libs.credentials)
implementation(libs.autofill)
implementation(project(":features"))
implementation(project(":core"))
implementation(project(":platform"))
+3 -2
View File
@@ -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
@@ -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<MainActivity>()
private var selected by mutableStateOf<String?>(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)
}
}
@@ -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() }
}
}
@@ -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)
}
}
}
@@ -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
}
+17
View File
@@ -3,9 +3,14 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- OS-mediated clients need signer verification even without discoverable intent filters. -->
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
<!-- Credential Manager is a provider on API 34+; API 26–33 uses framework Autofill. -->
<application
tools:ignore="CredentialDependency"
android:name=".IronStorageApplication"
android:allowBackup="false"
android:fullBackupContent="@xml/backup_rules"
@@ -15,6 +20,18 @@
android:supportsRtl="true"
android:theme="@style/Theme.IronStorage"
android:usesCleartextTraffic="false">
<service android:name=".providers.IronAutofillService" android:exported="true"
android:permission="android.permission.BIND_AUTOFILL_SERVICE">
<intent-filter><action android:name="android.service.autofill.AutofillService" /></intent-filter>
<meta-data android:name="android.autofill" android:resource="@xml/autofill_service" />
</service>
<service tools:targetApi="34" android:name=".providers.IronCredentialProviderService" android:exported="true" android:enabled="@bool/credential_provider_enabled"
android:permission="android.permission.BIND_CREDENTIAL_PROVIDER_SERVICE">
<intent-filter><action android:name="android.service.credentials.CredentialProviderService" /></intent-filter>
<meta-data android:name="android.credentials.provider" android:resource="@xml/credential_provider" />
</service>
<activity android:name=".providers.CredentialSelectionActivity" android:exported="false"
android:excludeFromRecents="true" android:taskAffinity="" android:windowSoftInputMode="adjustResize" />
<activity android:name=".MainActivity" android:exported="true"
android:windowSoftInputMode="adjustResize"
android:supportsPictureInPicture="false">
@@ -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)
}
@@ -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(
@@ -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<ProviderRequest?>(null)
private var selected by mutableStateOf<String?>(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<String?>(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<GetPasswordOption>()
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<android.app.assist.AssistStructure>(
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()
}
}
@@ -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()
}
}
@@ -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<BeginGetCredentialResponse, GetCredentialException>
) {
var token: String? = null
val job = scope.launch {
try {
val caller = credentialCaller(requireNotNull(request.callingAppInfo), credentials)
val options = request.beginGetCredentialOptions
.filterIsInstance<BeginGetPasswordOption>()
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<BeginCreateCredentialResponse, CreateCredentialException>
) {
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<Void?, ClearCredentialException>
) {
ProviderRequests.clear()
authentication.lock()
callback.onResult(null)
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
}
@@ -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<String> = 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<String, ProviderContext>()
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<AutofillId>()
val fields = mutableListOf<CredentialField>()
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()])
}
@@ -0,0 +1 @@
<resources><bool name="credential_provider_enabled">true</bool></resources>
@@ -0,0 +1 @@
<resources><bool name="credential_provider_enabled">false</bool></resources>
@@ -0,0 +1 @@
<autofill-service xmlns:android="http://schemas.android.com/apk/res/android" android:settingsActivity="de.rfc1437.ironstorage.MainActivity" />
@@ -0,0 +1,3 @@
<credential-provider xmlns:android="http://schemas.android.com/apk/res/android" android:settingsActivity="de.rfc1437.ironstorage.MainActivity">
<capabilities><capability name="android.credentials.TYPE_PASSWORD_CREDENTIAL" /></capabilities>
</credential-provider>
+2
View File
@@ -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" })
@@ -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() } }
}
}
}
@@ -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<String>,
val origin: String?
)
data class CredentialAttribute(val name: String, val value: String)
data class CredentialField(
val index: UInt,
val hints: List<String>,
val htmlAttributes: List<CredentialAttribute>,
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<CredentialCandidate>
)
class CredentialValue(val username: String, val password: String) {
override fun toString() = "CredentialValue([REDACTED])"
}
internal fun credentialForm(fields: List<CredentialField>): 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)
}
)
@@ -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 {
@@ -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<CredentialField>): 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<String>
): 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)
}
}
+75 -71
View File
@@ -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",
@@ -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))
}
}
@@ -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),
@@ -156,7 +156,7 @@
<string name="settings_git">Git commit author name and email</string>
<string name="settings_auth">Authentication, inactivity timeout, biometrics and appearance</string>
<string name="settings_cache">TOTP discovery cache</string>
<string name="settings_providers">Autofill and Credential Manager</string>
<string name="settings_providers">Autofill and Credential Manager providers</string>
<string name="settings_recovery">Recovery and offline use</string>
<string name="settings_about">Version, licenses, privacy and support</string>
<string name="settings_no_token">No application token stored</string>
@@ -266,4 +266,21 @@
<item quantity="one">%1$s password entry</item>
<item quantity="other">%1$s password entries</item>
</plurals>
<string name="settings_chrome_guidance">After selecting IronStorage in Android settings, open Chrome Settings → Autofill and passwords → Autofill settings. Select Autofill using another service and restart Chrome.</string>
<string name="settings_chrome_help">Chrome autofill help</string>
<string name="provider_fill_title">Fill from IronStorage</string>
<string name="provider_save_title">Save in IronStorage</string>
<string name="provider_checking">Checking the requesting application…</string>
<string name="provider_destination">Destination entry path</string>
<string name="provider_overwrite">Allow updating this destination if it exists</string>
<string name="provider_search">Search entries</string>
<string name="provider_selected">Selected: %1$s</string>
<string name="provider_choose_another">Choose another entry</string>
<string name="provider_passphrase">GPG passphrase (leave empty for biometric unlock)</string>
<string name="provider_this_application">this application</string>
<string name="provider_confirm_save">Save the supplied credential at the chosen destination</string>
<string name="provider_confirm_fill">Allow %1$s to receive this entry\'s username and password</string>
<string name="provider_working">Working…</string>
<string name="provider_save">Confirm save</string>
<string name="provider_fill">Unlock and fill</string>
</resources>
+5
View File
@@ -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" }
+165
View File
@@ -6,6 +6,9 @@
</configuration>
<components>
<component group="androidx.activity" name="activity" version="1.1.0">
<artifact name="activity-1.1.0.aar">
<sha256 value="4f2b35916768032f7d0c20e250e28b29037ed4ce9ebf3da4fcd51bcb0c6067ef" origin="Generated by Gradle"/>
</artifact>
<artifact name="activity-1.1.0.pom">
<sha256 value="3690001fc5a57ad176cacf7a13f56219bffb73b93c7978dbac1e1e5db082d6c0" origin="Generated by Gradle"/>
</artifact>
@@ -89,6 +92,11 @@
<sha256 value="2e9372ba7780ef44952adbf86b66e1f08682c1e5277c926185f6564a13799efe" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.annotation" name="annotation" version="1.10.0">
<artifact name="annotation-1.10.0.module">
<sha256 value="5b2d4387f384e5913a4aec979a20ece13bb5ce992bc90a962dea1feea7fa89e9" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.annotation" name="annotation" version="1.11.0">
<artifact name="annotation-1.11.0.module">
<sha256 value="ac8410bea7adaabc9e71285f0627c9cb929c5addd00cfc6d0ebd421dc2da1ee0" origin="Generated by Gradle"/>
@@ -151,6 +159,14 @@
<sha256 value="ae1ff29a7f3965e6589acfe7f07ef39d36405d7ab278472327afd92fc6d72a3f" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.annotation" name="annotation-jvm" version="1.10.0">
<artifact name="annotation-jvm-1.10.0.jar">
<sha256 value="ddd072ddbb553178e205517ce777b2f05aa9e412982d9ecb4eedc74f1212f697" origin="Generated by Gradle"/>
</artifact>
<artifact name="annotation-jvm-1.10.0.module">
<sha256 value="42afca374194273e4067b937cfc447bd3c8c4fd441f41d44f954050dfd6106dd" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.annotation" name="annotation-jvm" version="1.11.0">
<artifact name="annotation-jvm-1.11.0.jar">
<sha256 value="a83b827cc17fd6b41c7bf5e7a2a2b40a0895b00b3e30fdfdb820b49dc7b667f3" origin="Generated by Gradle"/>
@@ -204,6 +220,11 @@
<sha256 value="75d9865bc6b0f779043b4f6523a944bd2ccb7526f5c1f5ef24624ac78dec3bd3" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.arch.core" name="core-common" version="2.0.0">
<artifact name="core-common-2.0.0.pom">
<sha256 value="4b6f1d459ddd146b4e85ed6d46e86eb8c2639c5de47904e6db4d698721334220" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.arch.core" name="core-common" version="2.1.0">
<artifact name="core-common-2.1.0.jar">
<sha256 value="fe1237bf029d063e7f29fe39aeaf73ef74c8b0a3658486fc29d3c54326653889" origin="Generated by Gradle"/>
@@ -244,6 +265,14 @@
<sha256 value="3677abead5d4e06cf76cbacb954151e051779c2274494451fb424e375aa9e450" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.autofill" name="autofill" version="1.3.0">
<artifact name="autofill-1.3.0.aar">
<sha256 value="b5a217cee7724ea42a382965a9b22a330b988d62cb97abe1b1af50e54370c322" origin="Generated by Gradle"/>
</artifact>
<artifact name="autofill-1.3.0.module">
<sha256 value="0bae265cafe02e953f13e873a343911d2263200ca89639120dfdd756286060f9" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.benchmark" name="benchmark-common" version="1.5.0">
<artifact name="benchmark-common-1.5.0.aar">
<sha256 value="1b915a31dae4d4c57a1641a16d063983c29069290ce4153c1f356e97bd247db4" origin="Generated by Gradle"/>
@@ -955,6 +984,9 @@
</artifact>
</component>
<component group="androidx.core" name="core" version="1.15.0">
<artifact name="core-1.15.0.aar">
<sha256 value="432b85a1974076e14b487ece4a28c59a84f1b9efc3fc8be72cd7f05d32055e51" origin="Generated by Gradle"/>
</artifact>
<artifact name="core-1.15.0.module">
<sha256 value="e8a6c386e1765d870012fed2221173d4a2e8d2fd5aec732c51afb4a9f4684519" origin="Generated by Gradle"/>
</artifact>
@@ -975,7 +1007,18 @@
<sha256 value="b67ff7a1f9be95687dfa86d6055147fc1d8bbe3b1dc19048c432afca18abfd95" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.core" name="core" version="1.19.1">
<artifact name="core-1.19.1.aar">
<sha256 value="08108ea780205bd54603088a62a6dd5378f63bd25542674be24cd4cdb8191a09" origin="Generated by Gradle"/>
</artifact>
<artifact name="core-1.19.1.module">
<sha256 value="0d8ca728e53e1b694b9a14f0b68824085919500bc123684746f1f43e9f7ea8cd" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.core" name="core" version="1.3.2">
<artifact name="core-1.3.2.aar">
<sha256 value="94de196cd67950cff6ef3e1ac59015f8eaaf61840bdc238f2cf54ddef8dd0be9" origin="Generated by Gradle"/>
</artifact>
<artifact name="core-1.3.2.pom">
<sha256 value="afb5ea494dd083ed404cd51f580d218e37362f8ae326e893bee521290ed34920" origin="Generated by Gradle"/>
</artifact>
@@ -1024,6 +1067,14 @@
<sha256 value="ab530b04e2fbe73205484899a3be56c90968873eb496fc52e5ea7596d91035d1" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.core" name="core-ktx" version="1.15.0">
<artifact name="core-ktx-1.15.0.aar">
<sha256 value="c72c97ff4a32308bcaf3061eea5ddf33455ce413cee4aa1d5c82440c5759d4bc" origin="Generated by Gradle"/>
</artifact>
<artifact name="core-ktx-1.15.0.module">
<sha256 value="bee9b44664ff37e2e222ebcde4b30e38294a3f927451edc611fec7f454ffa442" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.core" name="core-ktx" version="1.16.0">
<artifact name="core-ktx-1.16.0.aar">
<sha256 value="1766dbd82f64a12dcd359ecb6f15f3cf35db4d66d22961a7c51b2ffc6468314c" origin="Generated by Gradle"/>
@@ -1040,6 +1091,14 @@
<sha256 value="3fa579c11de0f6f378002c0f0bb4bbe71100634f80f8b282951789ea7d0fb849" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.core" name="core-ktx" version="1.19.1">
<artifact name="core-ktx-1.19.1.aar">
<sha256 value="9a3e2919e64026ad18b42bf9931e8fea90a6a70e95e13a26519841e926fe5913" origin="Generated by Gradle"/>
</artifact>
<artifact name="core-ktx-1.19.1.module">
<sha256 value="580b6ea6aced160520499602dc42634277a0b2dbc1ccab18105408dea2a4b202" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.core" name="core-ktx" version="1.2.0">
<artifact name="core-ktx-1.2.0.pom">
<sha256 value="29eaddba4e6a35e68a128e594ebae3b72ef7e767f1b273ef93dc2ed019ff97f6" origin="Generated by Gradle"/>
@@ -1066,6 +1125,14 @@
<sha256 value="11386cfa46cbbfddb6a4059f14354c00691cf65d3d63c3618818a83326ef3c7f" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.credentials" name="credentials" version="1.6.0">
<artifact name="credentials-1.6.0.aar">
<sha256 value="9051922592ee3c92370ce3f942d66661f1e9050ac5b370932c71f0a380acf227" origin="Generated by Gradle"/>
</artifact>
<artifact name="credentials-1.6.0.module">
<sha256 value="639ae0bdc1d09a4d0c3cd198850fd8530363cf65c55c7c870b17596883346163" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.cursoradapter" name="cursoradapter" version="1.0.0">
<artifact name="cursoradapter-1.0.0.aar">
<sha256 value="a81c8fe78815fa47df5b749deb52727ad11f9397da58b16017f4eb2c11e28564" origin="Generated by Gradle"/>
@@ -1141,6 +1208,9 @@
</artifact>
</component>
<component group="androidx.fragment" name="fragment" version="1.2.5">
<artifact name="fragment-1.2.5.aar">
<sha256 value="d19e82d142def6c4e136da70bf92f194c0ecc61d14ab4e84567b2ced0920fa93" origin="Generated by Gradle"/>
</artifact>
<artifact name="fragment-1.2.5.pom">
<sha256 value="df0aca46b62bb47cc662cbcee63372db6d2a2859478ee38b594fba3433fe30a5" origin="Generated by Gradle"/>
</artifact>
@@ -1198,6 +1268,11 @@
<sha256 value="2baaf7f17551765524bd1e4d2f954a2a0178a112039cf549ba8b7d4b41e66931" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-common" version="2.0.0">
<artifact name="lifecycle-common-2.0.0.pom">
<sha256 value="04d525073469214d0c99e81aaa875dd548ba32b82945abd8326bc50229df700d" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-common" version="2.1.0">
<artifact name="lifecycle-common-2.1.0.pom">
<sha256 value="692c2a60437a95220c8fbb6e8943196f255f535d1a7c58714e6c46530782f276" origin="Generated by Gradle"/>
@@ -1214,6 +1289,9 @@
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-common" version="2.2.0">
<artifact name="lifecycle-common-2.2.0.jar">
<sha256 value="63898dabf7cfe5ec5d7ed8b8c2564c1427be876e1496ead95c2703cf59d3734b" origin="Generated by Gradle"/>
</artifact>
<artifact name="lifecycle-common-2.2.0.pom">
<sha256 value="cc30a8a2a97d3767bd169b747c078727308096dd3ab7917d80b2f1f42d118bd6" origin="Generated by Gradle"/>
</artifact>
@@ -1239,6 +1317,14 @@
<sha256 value="93747a9145cb36bc71005f598ede32e2b1149ade5a16e62b0e4969345bc62d85" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-common" version="2.6.2">
<artifact name="lifecycle-common-2.6.2.jar">
<sha256 value="f34831b6c71cd844e1d35d1be49d5e79447c5ab856346531b1e8676fda7374b1" origin="Generated by Gradle"/>
</artifact>
<artifact name="lifecycle-common-2.6.2.module">
<sha256 value="0fa7f28f5cff8a406a4f7870b243cb0d6d7a7c20faa7a2becaff5907ae12f9cc" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-common" version="2.9.4">
<artifact name="lifecycle-common-2.9.4.module">
<sha256 value="c578bd755ea46d7a77811309a3539538e2d7fb86a05fc2ad81c655ecf7dff7a4" origin="Generated by Gradle"/>
@@ -1273,6 +1359,14 @@
<sha256 value="1d1837f3942b33ea30aa2301fdcea2639408a0fd6fd4368c22478fa8153aebbf" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-livedata" version="2.0.0">
<artifact name="lifecycle-livedata-2.0.0.aar">
<sha256 value="c82609ced8c498f0a701a30fb6771bb7480860daee84d82e0a81ee86edf7ba39" origin="Generated by Gradle"/>
</artifact>
<artifact name="lifecycle-livedata-2.0.0.pom">
<sha256 value="a84842ffc0f14e518db75c05cc112680a8a4a164fa78395be32d88304a439423" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-livedata" version="2.1.0">
<artifact name="lifecycle-livedata-2.1.0.pom">
<sha256 value="1e4c580c995e41cd18455cfb87e3e43955be9db9ec63a8fb3c7259f01b7c4e3c" origin="Generated by Gradle"/>
@@ -1307,6 +1401,14 @@
<sha256 value="e2659e87e890b52b66f300471828b387a205e5387a392713190166f399ecf641" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-livedata" version="2.6.2">
<artifact name="lifecycle-livedata-2.6.2.aar">
<sha256 value="67359f609dfc2bf65da1270b23033f856064ec279f058e0a70c715f7c9003031" origin="Generated by Gradle"/>
</artifact>
<artifact name="lifecycle-livedata-2.6.2.module">
<sha256 value="19a287b46d1b1ffe297bee0df7dee5183aeba95600ec0de8742adeb5b67c8bab" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-livedata-core" version="2.1.0">
<artifact name="lifecycle-livedata-core-2.1.0.pom">
<sha256 value="6839d2e68c8fee4cc3bbf04e6b291a6ae842403fcd5f8d944c27814d296deff3" origin="Generated by Gradle"/>
@@ -1329,6 +1431,9 @@
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-livedata-core" version="2.2.0">
<artifact name="lifecycle-livedata-core-2.2.0.aar">
<sha256 value="556c1f3af90aa9d7d0d330565adbf6da71b2429148bac91e07c485f4f9abf614" origin="Generated by Gradle"/>
</artifact>
<artifact name="lifecycle-livedata-core-2.2.0.pom">
<sha256 value="aa51621be3817d1f5d21336b0c0525fe96ae899cec656c13d8ea18a51ea32c2f" origin="Generated by Gradle"/>
</artifact>
@@ -1346,6 +1451,14 @@
<sha256 value="e9c0dc3f0ac54419c0cfed8ff5cecb82943a7c58f7a54169f0d849b2c60a3552" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-livedata-core" version="2.6.2">
<artifact name="lifecycle-livedata-core-2.6.2.aar">
<sha256 value="2256780a3cff4a1e57fbb3d442557c17dc363ab8af105bcaf5261d8e2d5db949" origin="Generated by Gradle"/>
</artifact>
<artifact name="lifecycle-livedata-core-2.6.2.module">
<sha256 value="527d0e1ac467d1f47cc20ed7c30db170217299fabb868154cf5d1765e4e4dad9" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-livedata-core-ktx" version="2.10.0">
<artifact name="lifecycle-livedata-core-ktx-2.10.0.aar">
<sha256 value="5f18429749a183e19178d8665ae42bf7b6310b50358ae9e0e0cc452afd6e0bc3" origin="Generated by Gradle"/>
@@ -1394,6 +1507,9 @@
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-runtime" version="2.2.0">
<artifact name="lifecycle-runtime-2.2.0.aar">
<sha256 value="2f866c07a1f33a8c9bb69a9545d4f20b4f0628cd0a155432386d7cb081e1e0bc" origin="Generated by Gradle"/>
</artifact>
<artifact name="lifecycle-runtime-2.2.0.pom">
<sha256 value="7f154066fed1c9162870f728b208352831b3d1f8f23f5d388958a50ca4e9f441" origin="Generated by Gradle"/>
</artifact>
@@ -1414,6 +1530,14 @@
<sha256 value="a4cbb01a42d07047bd8d870017c96a1b0b7b4673320e86b66317a13be2ec10c7" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-runtime" version="2.6.2">
<artifact name="lifecycle-runtime-2.6.2.aar">
<sha256 value="4867fd5279742fba8388821930cb2affe06d81a52814e7e41e70392ea0ef887c" origin="Generated by Gradle"/>
</artifact>
<artifact name="lifecycle-runtime-2.6.2.module">
<sha256 value="ea0131846abe1fe9dea59ac6dfe1f0fb9d8b6d600c9eff9a1fd4ad5ee5e7cbc7" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-runtime" version="2.9.4">
<artifact name="lifecycle-runtime-2.9.4.module">
<sha256 value="dcbc5b5b50666e84258a74bfd8e514918c593a4ffcef0c0358562a02fbd80c58" origin="Generated by Gradle"/>
@@ -1525,6 +1649,9 @@
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-viewmodel" version="2.2.0">
<artifact name="lifecycle-viewmodel-2.2.0.aar">
<sha256 value="967efab24d6c49dd414a8c0ac4a1cd09b018f0b8bb43b739ad360c4158ebde27" origin="Generated by Gradle"/>
</artifact>
<artifact name="lifecycle-viewmodel-2.2.0.pom">
<sha256 value="aaa3afaa49ff47e2bc9b2389f939d3e8137616c4616684e2dfb53ba8366aa11d" origin="Generated by Gradle"/>
</artifact>
@@ -1542,6 +1669,14 @@
<sha256 value="2b406faea5c12f2b8df4b7a60931f846648f2e1f4d78361e198d1184f19a4797" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-viewmodel" version="2.6.2">
<artifact name="lifecycle-viewmodel-2.6.2.aar">
<sha256 value="e4ff4338999e1c6c9c724719f5d4aa7dd61bf6f545d5256a27a9d375df9f2330" origin="Generated by Gradle"/>
</artifact>
<artifact name="lifecycle-viewmodel-2.6.2.module">
<sha256 value="74e9a9ea46842a25642c91de2190ac24dcc1ea017395aae0eb250057d32699c6" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-viewmodel" version="2.9.4">
<artifact name="lifecycle-viewmodel-2.9.4.module">
<sha256 value="964d69d6b877296e8b79a77bb826ef5fb186aecaa7a1a7ff776fb2250c993006" origin="Generated by Gradle"/>
@@ -1636,6 +1771,14 @@
<sha256 value="dc6750a6991bfaac0817469865fe2ccb01e26186a0386299a5d056bac2cc72cc" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-viewmodel-savedstate" version="2.2.0">
<artifact name="lifecycle-viewmodel-savedstate-2.2.0.aar">
<sha256 value="3ce866fb822b20fe2f188f974992869a0a6233fe40acbefcff090d6def5e7f33" origin="Generated by Gradle"/>
</artifact>
<artifact name="lifecycle-viewmodel-savedstate-2.2.0.pom">
<sha256 value="9f31254e547b214257067611f18fc8a3c08f8df9eba634f6e21badc848596db1" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-viewmodel-savedstate" version="2.5.1">
<artifact name="lifecycle-viewmodel-savedstate-2.5.1.module">
<sha256 value="29acd5fe614b3f89123eb838f688d625eaa8b422c8d1905b48ad8e760cd7ad8b" origin="Generated by Gradle"/>
@@ -1649,6 +1792,14 @@
<sha256 value="dafb8649763d29c29cda27bc22fcdab9a9efc53c0fff9ae3de90882eabaa8944" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-viewmodel-savedstate" version="2.6.2">
<artifact name="lifecycle-viewmodel-savedstate-2.6.2.aar">
<sha256 value="7bc7dcbab17636ec076f12afe4d02671265c389457b1b366b37a0e8cb91e2da0" origin="Generated by Gradle"/>
</artifact>
<artifact name="lifecycle-viewmodel-savedstate-2.6.2.module">
<sha256 value="79f9d92880c2fb7827ac6739eb747264245ae456f4c21f77cf39fdb6a50a76ae" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-viewmodel-savedstate-android" version="2.10.0">
<artifact name="lifecycle-viewmodel-savedstate-android-2.10.0.module">
<sha256 value="949d2a862b6b584cee91af70a9e017bdb117d225bb46096da9424a1728ca79e9" origin="Generated by Gradle"/>
@@ -1809,6 +1960,14 @@
<sha256 value="ff1ff2b5ad10315fea2994ebf95631d61d6e07c9bf3f89d24dd2096cc0032094" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.profileinstaller" name="profileinstaller" version="1.3.0">
<artifact name="profileinstaller-1.3.0.aar">
<sha256 value="34e8b2bfc74e23c1525e3da903ae449b7f1b440aef45e18159ee470e91997f48" origin="Generated by Gradle"/>
</artifact>
<artifact name="profileinstaller-1.3.0.module">
<sha256 value="a16fe511e599c2042da122be09569aecd3f4f90cad37ef08bd0bbc39118c92c8" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.profileinstaller" name="profileinstaller" version="1.3.1">
<artifact name="profileinstaller-1.3.1.aar">
<sha256 value="d0e402ec31f24028a1dc7eb6a0a3f9d9635c1459392cd734396343b73d673948" origin="Generated by Gradle"/>
@@ -1847,6 +2006,9 @@
</artifact>
</component>
<component group="androidx.savedstate" name="savedstate" version="1.0.0">
<artifact name="savedstate-1.0.0.aar">
<sha256 value="2510a5619c37579c9ce1a04574faaf323cd0ffe2fc4e20fa8f8f01e5bb402e83" origin="Generated by Gradle"/>
</artifact>
<artifact name="savedstate-1.0.0.pom">
<sha256 value="844d7d22fcea798c78bf5559229daf88ec9cad8136f5eea0a29aa76b3f8fde7c" origin="Generated by Gradle"/>
</artifact>
@@ -2208,6 +2370,9 @@
</artifact>
</component>
<component group="androidx.versionedparcelable" name="versionedparcelable" version="1.1.0">
<artifact name="versionedparcelable-1.1.0.aar">
<sha256 value="9a1d77140ac222b7866b5054ee7d159bc1800987ed2d46dd6afdd145abb710c1" origin="Generated by Gradle"/>
</artifact>
<artifact name="versionedparcelable-1.1.0.pom">
<sha256 value="c729c7be0cc06323bda829d460666e79dbd43b799a21089a44bd3b293dc253b5" origin="Generated by Gradle"/>
</artifact>
+2
View File
@@ -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
+37
View File
@@ -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)
}
+205
View File
@@ -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
@@ -0,0 +1,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<!-- This password-only fixture invokes Credential Manager on API 34+ and declares no website association. -->
<application android:allowBackup="false" android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/data_extraction_rules" android:icon="@android:drawable/ic_lock_lock" android:label="Credential provider fixture"
android:theme="@android:style/Theme.Material.Light.NoActionBar"
android:usesCleartextTraffic="false" tools:ignore="CredentialDependency,CredManMissingDal">
<activity android:name=".ProviderClientActivity" android:exported="true">
<intent-filter><action android:name="android.intent.action.MAIN" /><category android:name="android.intent.category.LAUNCHER" /></intent-filter>
</activity>
<provider android:name=".BrowserFixtureProvider"
android:authorities="de.rfc1437.ironstorage.providerclient.fixture"
android:exported="false" android:grantUriPermissions="true" />
</application>
</manifest>
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>IronStorage browser fixture</title>
<style>body{font:20px sans-serif;padding:24px}input,button{display:block;font:inherit;margin:16px 0;padding:12px;max-width:100%;box-sizing:border-box}</style>
<h1>Browser credential fixture</h1>
<p>This local page uses public synthetic credentials. It sends no network requests.</p>
<form autocomplete="on" onsubmit="return false">
<label for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username">
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password">
<button type="button" onclick="document.getElementById('status').textContent=document.getElementById('username').value==='fixture-alice'&&document.getElementById('password').value==='public-provider-fixture-password'?'Chrome received the expected synthetic credential':'The fixture has not been filled'">Verify filled fixture</button>
</form><p id="status">Waiting for an explicit fill.</p>
</html>
@@ -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<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?
): Cursor {
checkPage(uri)
return MatrixCursor(arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)).apply {
addRow(
arrayOf<Any>(
"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<out String>?): Int =
throw UnsupportedOperationException("Read-only fixture")
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?
): Int = throw UnsupportedOperationException("Read-only fixture")
}
@@ -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()
}
}
@@ -0,0 +1,11 @@
<full-backup-content>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
<exclude domain="device_root" path="." />
<exclude domain="device_file" path="." />
<exclude domain="device_database" path="." />
<exclude domain="device_sharedpref" path="." />
</full-backup-content>
@@ -0,0 +1,24 @@
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
<exclude domain="device_root" path="." />
<exclude domain="device_file" path="." />
<exclude domain="device_database" path="." />
<exclude domain="device_sharedpref" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
<exclude domain="device_root" path="." />
<exclude domain="device_file" path="." />
<exclude domain="device_database" path="." />
<exclude domain="device_sharedpref" path="." />
</device-transfer>
</data-extraction-rules>
+1 -1
View File
@@ -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")
+210
View File
@@ -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<String>,
pub origin: Option<String>,
}
impl From<MobileCredentialCaller> 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<String>,
pub html_attributes: Vec<MobileCredentialAttribute>,
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<MobileCredentialCandidate>,
}
impl From<storage::CredentialRequest> 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<MobileCredentialField>,
) -> Result<MobileCredentialForm, MobileAuthenticationFfiError> {
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<MobileProviderRequest, MobileAuthenticationFfiError> {
Ok(self
.authentication
.begin_credential_request(caller.into())?
.into())
}
pub fn credential_request(
&self,
token: String,
caller: MobileCredentialCaller,
query: String,
) -> Result<MobileProviderRequest, MobileAuthenticationFfiError> {
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<String>,
) -> 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<String>,
allowed_usernames: Vec<String>,
) -> Result<MobileCredentialValue, MobileAuthenticationFfiError> {
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()
}
+3
View File
@@ -3021,3 +3021,6 @@ impl MobileAuthentication {
.map_err(Into::into)
}
}
mod credentials;
pub use credentials::*;
+2
View File
@@ -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;
+609 -1
View File
@@ -387,6 +387,8 @@ pub struct MobileAuthentication {
backend: Arc<dyn SecretStoreBackend>,
status: Mutex<MobileAuthenticationStatus>,
totp_discovery: Mutex<()>,
credential_requests: Mutex<crate::mobile_credentials::CredentialRequests>,
credential_clock: Arc<dyn AuthenticationClock>,
}
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<crate::mobile_credentials::CredentialRequest, MobileAuthenticationError> {
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<crate::mobile_credentials::CredentialRequest, MobileAuthenticationError> {
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<crate::mobile_credentials::CredentialRequest, MobileAuthenticationError> {
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<SecretBytes>,
allowed_usernames: &[String],
) -> Result<crate::mobile_credentials::CredentialValue, MobileAuthenticationError> {
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::<Result<Vec<_>, _>>()
.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<SecretBytes>,
) -> 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<SecretBytes>,
) -> 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(&current) != 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};
+584
View File
@@ -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<String>,
pub origin: Option<String>,
}
#[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<CredentialIdentity, &'static str> {
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::<Vec<_>>()
.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<String>,
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<CredentialForm, &'static str> {
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<CredentialCandidate>,
}
struct PendingCredentialRequest {
request: CredentialRequest,
deadline: Duration,
save: Option<crate::repository::EncryptedEntry>,
}
#[derive(Default)]
pub(crate) struct CredentialRequests {
pending: BTreeMap<String, PendingCredentialRequest>,
}
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<crate::repository::EncryptedEntry, &'static str> {
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<String>,
now: Duration,
) -> Result<CredentialRequest, &'static str> {
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<CredentialRequest, &'static str> {
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<CredentialValue, &'static str> {
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());
}
}
+4
View File
@@ -448,6 +448,10 @@ impl MobileEntryDraft {
Ok(())
}
pub(crate) fn into_document(self) -> EntryDocument {
self.document
}
pub fn document(&self) -> &EntryDocument {
&self.document
}
+130
View File
@@ -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.
+95
View File
@@ -61,8 +61,20 @@ fn manifest_text(source: &str) -> Result<(), Box<dyn Error>> {
"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<dyn Error>> {
{
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#"<manifest xmlns:android="{ANDROID}" package="de.rfc1437.ironstorage"><application android:allowBackup="false" android:usesCleartextTraffic="false" android:fullBackupContent="@xml/backup_rules" android:dataExtractionRules="@xml/data_extraction_rules"><activity android:name="de.rfc1437.ironstorage.MainActivity" android:exported="true"><intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter></activity><provider android:name="fixture" android:exported="false"/></application></manifest>"#
);
assert!(manifest_text(&source).is_ok());
assert!(manifest_text(&source.replace(
"<application",
"<uses-permission android:name=\"android.permission.QUERY_ALL_PACKAGES\"/><application",
)).is_err());
assert!(
manifest_text(&source.replace(
"<application",
@@ -486,6 +542,45 @@ mod tests {
assert!(manifest_text(&launcher.replace("INSTALL_PROFILE", "UNREVIEWED")).is_err());
}
#[test]
fn provider_services_require_exact_system_binding_permissions() {
let source = format!(
r#"<manifest xmlns:android="{ANDROID}" package="de.rfc1437.ironstorage"><application android:allowBackup="false" android:usesCleartextTraffic="false" android:fullBackupContent="@xml/backup_rules" android:dataExtractionRules="@xml/data_extraction_rules"><activity android:name="de.rfc1437.ironstorage.MainActivity" android:exported="true"><intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter></activity><service android:name="de.rfc1437.ironstorage.providers.IronAutofillService" android:exported="true" android:permission="android.permission.BIND_AUTOFILL_SERVICE"><intent-filter><action android:name="android.service.autofill.AutofillService"/></intent-filter><meta-data android:name="android.autofill" android:resource="@xml/autofill_service"/></service></application></manifest>"#
);
assert!(manifest_text(&source).is_ok());
let visible = source.replace(
"<application",
"<uses-permission android:name=\"android.permission.QUERY_ALL_PACKAGES\"/><application",
);
assert!(manifest_text(&visible).is_ok());
assert!(
manifest_text(&visible.replace("IronAutofillService", "UnreviewedService")).is_err()
);
assert!(
manifest_text(&source.replace(
"android.permission.BIND_AUTOFILL_SERVICE",
"android.permission.INTERNET"
))
.is_err()
);
assert!(
manifest_text(&source.replace(
"android.service.autofill.AutofillService",
"unreviewed.action"
))
.is_err()
);
let credentials = source
.replace("IronAutofillService", "IronCredentialProviderService")
.replace("BIND_AUTOFILL_SERVICE", "BIND_CREDENTIAL_PROVIDER_SERVICE")
.replace(
"android.service.autofill.AutofillService",
"android.service.credentials.CredentialProviderService",
)
.replace("android.autofill", "android.credentials.provider");
assert!(manifest_text(&credentials).is_ok());
}
#[test]
fn backup_requires_both_sections_all_domains_and_no_include() {
let exclusions = DOMAINS
+1
View File
@@ -143,6 +143,7 @@ fn check(root: &Path) -> Result<(), Box<dyn Error>> {
for (module, allowed) in [
("app", &["features", "core", "platform"][..]),
("performance", &["app"][..]),
("provider-client", &[][..]),
("features", &["core"][..]),
("core", &["rust-bridge"][..]),
("platform", &["core"][..]),