package com.peachpasswords.passkeys import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test /** * F1/F2: caller-origin resolution policy — which callers may register for an * rpId and which may obtain a stored credential. */ class PasskeyCallerOriginTest { @Test fun apk_key_hash_origin_matches_webauthn_l3_format() { // sha256("cert") = known digest; asserts prefix + b64url-no-padding. val origin = PasskeyCallerOrigin.apkKeyHashOrigin("cert".toByteArray()) assertTrue(origin.startsWith("android:apk-key-hash:")) val digest = origin.removePrefix("android:apk-key-hash:") assertFalse(digest.contains('=')) assertEquals(43, digest.length) // 32 bytes -> 43 b64url chars unpadded } @Test fun rp_id_matches_origin_subdomain_semantics() { assertTrue(PasskeyCallerOrigin.rpIdMatchesOrigin("example.com", "https://example.com")) assertTrue(PasskeyCallerOrigin.rpIdMatchesOrigin("example.com", "https://login.example.com")) // rpId may not claim a sibling subdomain's host. assertFalse(PasskeyCallerOrigin.rpIdMatchesOrigin("login.example.com", "https://example.com")) // Suffix matching is dot-anchored: "ample.com" is not a parent of example.com. assertFalse(PasskeyCallerOrigin.rpIdMatchesOrigin("ample.com", "https://example.com")) assertFalse(PasskeyCallerOrigin.rpIdMatchesOrigin("example.com", "https://evilexample.com")) assertFalse(PasskeyCallerOrigin.rpIdMatchesOrigin("example.com", "https://evil-example.com")) } @Test fun rp_id_public_suffixes_are_rejected() { // PSL guard: an rpId that is itself a public suffix must not match. assertFalse(PasskeyCallerOrigin.rpIdMatchesOrigin("co.uk", "https://bank.co.uk")) } @Test fun non_web_origins_never_match_rp_ids() { assertFalse(PasskeyCallerOrigin.rpIdMatchesOrigin("example.com", "android:apk-key-hash:AAAA")) assertFalse(PasskeyCallerOrigin.rpIdMatchesOrigin("example.com", "not-an-origin")) } @Test fun web_callers_pass_rp_id_domain_gate_regardless_of_binding() { // Legacy (null) and web-bound credentials both serve a verified web // caller inside the rpId domain. assertTrue(PasskeyCallerOrigin.canServeCredential(null, "example.com", "https://example.com")) assertTrue(PasskeyCallerOrigin.canServeCredential("https://example.com", "example.com", "https://login.example.com")) // A web caller from another domain gets nothing. assertFalse(PasskeyCallerOrigin.canServeCredential(null, "example.com", "https://evil.com")) } @Test fun plaintext_http_origins_never_match_or_serve_credentials() { assertFalse(PasskeyCallerOrigin.rpIdMatchesOrigin("example.com", "http://example.com")) assertFalse(PasskeyCallerOrigin.rpIdMatchesOrigin("example.com", "HTTP://login.example.com")) assertFalse(PasskeyCallerOrigin.canServeCredential(null, "example.com", "http://example.com")) assertFalse( PasskeyCallerOrigin.canServeCredential( "https://example.com", "example.com", "http://example.com", ), ) } @Test fun native_callers_only_get_their_own_bound_credentials() { val appA = "android:apk-key-hash:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" val appB = "android:apk-key-hash:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" assertTrue(PasskeyCallerOrigin.canServeCredential(appA, "example.com", appA)) assertFalse(PasskeyCallerOrigin.canServeCredential(appA, "example.com", appB)) // Legacy credentials are never released to native-app callers. assertFalse(PasskeyCallerOrigin.canServeCredential(null, "example.com", appA)) } @Test fun browser_allowlist_requires_both_package_and_signing_certificate() { val fingerprint = PasskeyCallerOrigin.certificateFingerprint("browser-cert".toByteArray()) val allowlist = """ {"apps":[{"info":{"package_name":"com.example.browser","signatures":[{"cert_fingerprint_sha256":"$fingerprint"}]}}]} """.trimIndent() assertTrue( PasskeyCallerOrigin.isAllowlistedBrowser( "com.example.browser", setOf(fingerprint.lowercase()), allowlist, ), ) assertFalse( PasskeyCallerOrigin.isAllowlistedBrowser( "com.example.malicious", setOf(fingerprint), allowlist, ), ) assertFalse( PasskeyCallerOrigin.isAllowlistedBrowser( "com.example.browser", setOf("00:11:22"), allowlist, ), ) } }