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

@@ -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)
}
}