PasskeyCredentialPlannerTest.kt

4.3 KB · raw

1package com.peachpasswords.passkeys23import com.peachpasswords.vault.PasskeyData4import com.peachpasswords.vault.VaultEntry5import org.junit.Assert.assertEquals6import org.junit.Assert.assertTrue7import org.junit.Test89class PasskeyCredentialPlannerTest {10    private val webCaller = "https://example.com"1112    @Test fun matches_passkeys_by_exact_rp_id() {13        val matches = PasskeyCredentialPlanner.candidates(14            listOf(15                passkey("a", rpId = "example.com", username = "june"),16                passkey("b", rpId = "evil-example.com", username = "lookalike"),17            ),18            """{"challenge":"c","rpId":"example.com"}""",19            callerOrigin = webCaller,20        )2122        assertEquals(listOf("a"), matches.map { it.entryId })23        assertEquals("june", matches.single().username)24    }2526    @Test fun narrows_to_allow_credentials_when_present() {27        val matches = PasskeyCredentialPlanner.candidates(28            listOf(29                passkey("a", credentialId = "cred-a", lastUsedAt = 10),30                passkey("b", credentialId = "cred-b", lastUsedAt = 20),31            ),32            """{"challenge":"c","rpId":"example.com","allowCredentials":[{"type":"public-key","id":"cred-a"}]}""",33            callerOrigin = webCaller,34        )3536        assertEquals(listOf("a"), matches.map { it.entryId })37    }3839    @Test fun empty_or_invalid_requests_offer_no_credentials() {40        assertTrue(PasskeyCredentialPlanner.candidates(listOf(passkey("a")), """{"challenge":"c"}""", webCaller).isEmpty())41        assertTrue(PasskeyCredentialPlanner.candidates(listOf(passkey("a")), "not-json", webCaller).isEmpty())42    }4344    @Test fun unknown_caller_gets_no_credentials() {45        assertTrue(PasskeyCredentialPlanner.candidates(listOf(passkey("a")), """{"challenge":"c","rpId":"example.com"}""", null).isEmpty())46        assertTrue(PasskeyCredentialPlanner.candidates(listOf(passkey("a")), """{"challenge":"c","rpId":"example.com"}""", " ").isEmpty())47    }4849    @Test fun plaintext_http_callers_get_no_credentials() {50        assertTrue(51            PasskeyCredentialPlanner.candidates(52                listOf(passkey("a")),53                """{"challenge":"c","rpId":"example.com"}""",54                "http://example.com",55            ).isEmpty(),56        )57    }5859    @Test fun legacy_credentials_hide_from_native_app_callers() {60        // callerOrigin == null means pre-binding (or browser-synced). A native61        // app's apk-key-hash origin can never prove a relationship to the62        // rpId, so legacy credentials stay hidden from it.63        val nativeCaller = "android:apk-key-hash:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"64        assertTrue(65            PasskeyCredentialPlanner.candidates(66                listOf(passkey("a")),67                """{"challenge":"c","rpId":"example.com"}""",68                nativeCaller,69            ).isEmpty(),70        )71    }7273    @Test fun bound_credentials_only_go_to_their_bound_caller() {74        val boundOrigin = "android:apk-key-hash:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"75        val otherApp = "android:apk-key-hash:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"76        val entries = listOf(passkey("a", callerOrigin = boundOrigin))77        val request = """{"challenge":"c","rpId":"example.com"}"""7879        assertEquals(listOf("a"), PasskeyCredentialPlanner.candidates(entries, request, boundOrigin).map { it.entryId })80        assertTrue(PasskeyCredentialPlanner.candidates(entries, request, otherApp).isEmpty())81        // Web callers also cannot take a credential bound to a native app.82        assertTrue(PasskeyCredentialPlanner.candidates(entries, request, webCaller).isEmpty())83    }8485    private fun passkey(86        id: String,87        rpId: String = "example.com",88        credentialId: String = "cred-$id",89        username: String = "user-$id",90        lastUsedAt: Long? = null,91        callerOrigin: String? = null,92    ) = VaultEntry(93        id = id,94        type = "passkey",95        title = "Passkey $id",96        passkey = PasskeyData(97            credentialId = credentialId,98            publicKeyCose = "cose-$id",99            rpId = rpId,100            userHandle = "handle-$id",101            userName = username,102            userDisplayName = "User $id",103            callerOrigin = callerOrigin,104        ),105        lastUsedAt = lastUsedAt,106        createdAt = 1,107        modifiedAt = 1,108    )109}