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
+186
@@ -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)
|
||||
}
|
||||
)
|
||||
+5
@@ -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 {
|
||||
|
||||
+56
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user