Implement Android Credential Manager and Autofill providers (#109)
This commit is contained in:
1 parent
316a875d63
commit
0c8366d34f
48 files changed
+4138
-78
No files matched your search
@@ -70,6 +70,8 @@ kotlin {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.credentials)
|
||||
implementation(libs.autofill)
|
||||
implementation(project(":features"))
|
||||
implementation(project(":core"))
|
||||
implementation(project(":platform"))
|
||||
|
||||
@@ -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
|
||||
|
||||
+108
@@ -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() }
|
||||
}
|
||||
}
|
||||
+108
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
+362
@@ -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()
|
||||
}
|
||||
}
|
||||
+180
@@ -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>
|
||||
Reference in new issue
Block a user