package com.peachpasswords.passkeys import com.peachpasswords.vault.PasskeyData import com.peachpasswords.vault.VaultEntry import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test class PasskeyCredentialPlannerTest { private val webCaller = "https://example.com" @Test fun matches_passkeys_by_exact_rp_id() { val matches = PasskeyCredentialPlanner.candidates( listOf( passkey("a", rpId = "example.com", username = "june"), passkey("b", rpId = "evil-example.com", username = "lookalike"), ), """{"challenge":"c","rpId":"example.com"}""", callerOrigin = webCaller, ) assertEquals(listOf("a"), matches.map { it.entryId }) assertEquals("june", matches.single().username) } @Test fun narrows_to_allow_credentials_when_present() { val matches = PasskeyCredentialPlanner.candidates( listOf( passkey("a", credentialId = "cred-a", lastUsedAt = 10), passkey("b", credentialId = "cred-b", lastUsedAt = 20), ), """{"challenge":"c","rpId":"example.com","allowCredentials":[{"type":"public-key","id":"cred-a"}]}""", callerOrigin = webCaller, ) assertEquals(listOf("a"), matches.map { it.entryId }) } @Test fun empty_or_invalid_requests_offer_no_credentials() { assertTrue(PasskeyCredentialPlanner.candidates(listOf(passkey("a")), """{"challenge":"c"}""", webCaller).isEmpty()) assertTrue(PasskeyCredentialPlanner.candidates(listOf(passkey("a")), "not-json", webCaller).isEmpty()) } @Test fun unknown_caller_gets_no_credentials() { assertTrue(PasskeyCredentialPlanner.candidates(listOf(passkey("a")), """{"challenge":"c","rpId":"example.com"}""", null).isEmpty()) assertTrue(PasskeyCredentialPlanner.candidates(listOf(passkey("a")), """{"challenge":"c","rpId":"example.com"}""", " ").isEmpty()) } @Test fun plaintext_http_callers_get_no_credentials() { assertTrue( PasskeyCredentialPlanner.candidates( listOf(passkey("a")), """{"challenge":"c","rpId":"example.com"}""", "http://example.com", ).isEmpty(), ) } @Test fun legacy_credentials_hide_from_native_app_callers() { // callerOrigin == null means pre-binding (or browser-synced). A native // app's apk-key-hash origin can never prove a relationship to the // rpId, so legacy credentials stay hidden from it. val nativeCaller = "android:apk-key-hash:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" assertTrue( PasskeyCredentialPlanner.candidates( listOf(passkey("a")), """{"challenge":"c","rpId":"example.com"}""", nativeCaller, ).isEmpty(), ) } @Test fun bound_credentials_only_go_to_their_bound_caller() { val boundOrigin = "android:apk-key-hash:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" val otherApp = "android:apk-key-hash:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" val entries = listOf(passkey("a", callerOrigin = boundOrigin)) val request = """{"challenge":"c","rpId":"example.com"}""" assertEquals(listOf("a"), PasskeyCredentialPlanner.candidates(entries, request, boundOrigin).map { it.entryId }) assertTrue(PasskeyCredentialPlanner.candidates(entries, request, otherApp).isEmpty()) // Web callers also cannot take a credential bound to a native app. assertTrue(PasskeyCredentialPlanner.candidates(entries, request, webCaller).isEmpty()) } private fun passkey( id: String, rpId: String = "example.com", credentialId: String = "cred-$id", username: String = "user-$id", lastUsedAt: Long? = null, callerOrigin: String? = null, ) = VaultEntry( id = id, type = "passkey", title = "Passkey $id", passkey = PasskeyData( credentialId = credentialId, publicKeyCose = "cose-$id", rpId = rpId, userHandle = "handle-$id", userName = username, userDisplayName = "User $id", callerOrigin = callerOrigin, ), lastUsedAt = lastUsedAt, createdAt = 1, modifiedAt = 1, ) }