142232e24288
feat: Add ES256 passkey creation (v2.3.44)
- time
- 2026-06-26 13:49 UTC · 4 months ago
- machine
- koh2
- parent
- 44316f00e2ed
↓ peach-android-142232e2.tar.gzatlas at this save
files changed5 files
app/build.gradle+2 −2app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt+74 −0app/src/main/java/com/peachpasswords/passkeys/PasskeyRegistration.kt+191 −0app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+20 −1app/src/test/java/com/peachpasswords/passkeys/PasskeyRegistrationTest.kt+57 −0app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616 applicationId "com.peachpasswords"1717 minSdk 261818 targetSdk 3519- versionCode 23430020- versionName "2.3.43"19+ versionCode 23440020+ versionName "2.3.44"2121 2222 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323 vectorDrawables {app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt+74 −0
@@ -8,11 +8,16 @@88 import androidx.activity.ComponentActivity99 import androidx.credentials.GetCredentialResponse1010 import androidx.credentials.GetPublicKeyCredentialOption11+import androidx.credentials.CreatePublicKeyCredentialRequest12+import androidx.credentials.CreatePublicKeyCredentialResponse1113 import androidx.credentials.PublicKeyCredential14+import androidx.credentials.exceptions.CreateCredentialUnsupportedException1215 import androidx.credentials.exceptions.GetCredentialUnsupportedException1316 import androidx.credentials.provider.PendingIntentHandler17+import com.peachpasswords.shared.extractDomain1418 import androidx.lifecycle.lifecycleScope1519 import com.peachpasswords.vault.VaultRepository20+import com.peachpasswords.vault.isLogin1621 import dagger.hilt.android.AndroidEntryPoint1722 import javax.inject.Inject1823 import kotlinx.coroutines.launch@@ -27,6 +32,10 @@2732 }2833 2934 private suspend fun completeSelection() {35+ if (intent.getStringExtra(EXTRA_MODE) == MODE_CREATE) {36+ completeCreate()37+ return38+ }3039 val result = Intent()3140 val entryId = intent.getStringExtra(EXTRA_ENTRY_ID)3241 val providerRequest = runCatching {@@ -88,9 +97,61 @@8897 finish()8998 }9099 100+ private suspend fun completeCreate() {101+ val result = Intent()102+ val providerRequest = runCatching {103+ PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)104+ }.getOrNull()105+ val request = providerRequest?.callingRequest as? CreatePublicKeyCredentialRequest106+ val requestJson = request?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)107+ val origin = request?.origin ?: providerRequest?.callingAppInfo?.let { info ->108+ runCatching { info.getOrigin(info.packageName) }.getOrNull()109+ }110+ val registration = requestJson?.let { PasskeyRegistration.build(it, origin) }111+ val stored = registration?.let { storeRegistration(it.entryDraft) } ?: false112+ if (registration != null && stored) {113+ PendingIntentHandler.setCreateCredentialResponse(114+ result,115+ CreatePublicKeyCredentialResponse(registration.responseJson),116+ )117+ } else {118+ PendingIntentHandler.setCreateCredentialException(119+ result,120+ CreateCredentialUnsupportedException("Peach cannot create this passkey yet."),121+ )122+ }123+ setResult(RESULT_OK, result)124+ finish()125+ }126+127+ private suspend fun storeRegistration(draft: com.peachpasswords.vault.VaultEntry): Boolean {128+ val rpId = draft.passkey?.rpId ?: return false129+ val target = vault.getEntries().firstOrNull { entry ->130+ entry.deletedAt == null &&131+ entry.passkey == null &&132+ entry.isLogin() &&133+ listOfNotNull(entry.url, *entry.additionalUrls.orEmpty().toTypedArray())134+ .any { extractDomain(it) == rpId }135+ }136+ return runCatching {137+ if (target != null) {138+ vault.updateEntry(139+ target.copy(140+ passkey = draft.passkey,141+ username = target.username ?: draft.username,142+ ),143+ )144+ } else {145+ vault.addEntry(draft)146+ }147+ }.isSuccess148+ }149+91150 companion object {92151 private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID"93152 private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON"153+ private const val EXTRA_MODE = "com.peachpasswords.passkeys.EXTRA_MODE"154+ private const val MODE_CREATE = "create"94155 95156 fun pendingIntent(context: Context, entryId: String, requestJson: String): PendingIntent {96157 val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)@@ -100,6 +161,19 @@100161 return PendingIntent.getActivity(101162 context,102163 "$entryId:$requestJson".hashCode(),164+ intent,165+ PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,166+ )167+ }168+169+ fun createPendingIntent(context: Context, requestJson: String): PendingIntent {170+ val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)171+ .putExtra(EXTRA_MODE, MODE_CREATE)172+ .putExtra(EXTRA_REQUEST_JSON, requestJson)173+ val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0174+ return PendingIntent.getActivity(175+ context,176+ requestJson.hashCode(),103177 intent,104178 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,105179 )app/src/main/java/com/peachpasswords/passkeys/PasskeyRegistration.kt+191 −0
@@ -0,0 +1,191 @@1+package com.peachpasswords.passkeys2+3+import com.peachpasswords.vault.PasskeyData4+import com.peachpasswords.vault.VaultEntry5+import java.security.KeyPairGenerator6+import java.security.SecureRandom7+import java.security.interfaces.ECPrivateKey8+import java.security.interfaces.ECPublicKey9+import java.security.spec.ECGenParameterSpec10+import java.util.Base6411+import kotlinx.serialization.json.Json12+import kotlinx.serialization.json.JsonArray13+import kotlinx.serialization.json.JsonObject14+import kotlinx.serialization.json.JsonPrimitive15+import kotlinx.serialization.json.buildJsonArray16+import kotlinx.serialization.json.buildJsonObject17+import kotlinx.serialization.json.contentOrNull18+import kotlinx.serialization.json.intOrNull19+import kotlinx.serialization.json.jsonArray20+import kotlinx.serialization.json.jsonObject21+import kotlinx.serialization.json.jsonPrimitive22+import kotlinx.serialization.json.put23+24+data class PasskeyRegistrationResult(25+ val entryDraft: VaultEntry,26+ val responseJson: String,27+)28+29+object PasskeyRegistration {30+ private val json = Json { ignoreUnknownKeys = true }31+ private val random = SecureRandom()32+ private val urlEncoder = Base64.getUrlEncoder().withoutPadding()33+34+ fun build(requestJson: String, origin: String?, now: Long = System.currentTimeMillis()): PasskeyRegistrationResult? {35+ val request = runCatching { json.parseToJsonElement(requestJson).jsonObject }.getOrNull() ?: return null36+ val rp = request["rp"]?.jsonObject ?: return null37+ val user = request["user"]?.jsonObject ?: return null38+ val rpId = rp["id"]?.jsonPrimitive?.contentOrNull ?: return null39+ val rpName = rp["name"]?.jsonPrimitive?.contentOrNull ?: rpId40+ val userHandle = user["id"]?.jsonPrimitive?.contentOrNull ?: return null41+ val userName = user["name"]?.jsonPrimitive?.contentOrNull ?: return null42+ val userDisplayName = user["displayName"]?.jsonPrimitive?.contentOrNull ?: userName43+ val challenge = request["challenge"]?.jsonPrimitive?.contentOrNull ?: return null44+ if (!offersEs256(request["pubKeyCredParams"])) return null45+46+ val keyPair = KeyPairGenerator.getInstance("EC").apply {47+ initialize(ECGenParameterSpec("secp256r1"))48+ }.generateKeyPair()49+ val privateKey = keyPair.private as ECPrivateKey50+ val publicKey = keyPair.public as ECPublicKey51+ val credentialId = ByteArray(32).also(random::nextBytes)52+ val publicKeyCose = Cbor.map(53+ 1 to Cbor.uint(2),54+ 3 to Cbor.nint(-7),55+ -1 to Cbor.uint(1),56+ -2 to Cbor.bytes(publicKey.w.affineX.toFixed32()),57+ -3 to Cbor.bytes(publicKey.w.affineY.toFixed32()),58+ )59+ val authData = buildAttestationAuthData(rpId, credentialId, publicKeyCose)60+ val attestationObject = Cbor.map(61+ "fmt" to Cbor.text("none"),62+ "attStmt" to Cbor.map(),63+ "authData" to Cbor.bytes(authData),64+ )65+ val clientDataJson = clientDataJson("webauthn.create", challenge, origin ?: originFromRpId(rpId))66+ val passkey = PasskeyData(67+ credentialId = b64(credentialId),68+ publicKeyCose = b64(publicKeyCose),69+ privateKeyJwk = privateJwk(privateKey, publicKey),70+ rpId = rpId,71+ rpName = rpName,72+ userHandle = userHandle,73+ userName = userName,74+ userDisplayName = userDisplayName,75+ algorithm = -7,76+ signCount = 0,77+ aaguid = b64(ByteArray(16)),78+ backupEligible = false,79+ backupState = false,80+ transports = listOf("internal"),81+ createdAt = now,82+ )83+ val entry = VaultEntry(84+ id = "",85+ type = "login",86+ title = rpName,87+ username = userName,88+ url = "https://$rpId",89+ passkey = passkey,90+ createdAt = 0,91+ modifiedAt = 0,92+ )93+ val responseJson = json.encodeToString(94+ JsonObject.serializer(),95+ buildJsonObject {96+ put("id", passkey.credentialId)97+ put("rawId", passkey.credentialId)98+ put("type", "public-key")99+ put("response", buildJsonObject {100+ put("clientDataJSON", b64(clientDataJson))101+ put("authenticatorData", b64(authData))102+ put("attestationObject", b64(attestationObject))103+ put("transports", buildJsonArray { add(JsonPrimitive("internal")) })104+ put("publicKey", b64(publicKey.encoded))105+ put("publicKeyAlgorithm", -7)106+ })107+ put("authenticatorAttachment", "platform")108+ put("clientExtensionResults", buildJsonObject {})109+ },110+ )111+ return PasskeyRegistrationResult(entryDraft = entry, responseJson = responseJson)112+ }113+114+ private fun offersEs256(value: kotlinx.serialization.json.JsonElement?): Boolean {115+ val params = runCatching { value?.jsonArray }.getOrNull() ?: return true116+ return params.any { item ->117+ runCatching { item.jsonObject["alg"]?.jsonPrimitive?.intOrNull == -7 }.getOrDefault(false)118+ }119+ }120+121+ private fun buildAttestationAuthData(rpId: String, credentialId: ByteArray, publicKeyCose: ByteArray): ByteArray {122+ val base = ByteArray(37)123+ java.security.MessageDigest.getInstance("SHA-256").digest(rpId.toByteArray(Charsets.UTF_8)).copyInto(base, 0)124+ base[32] = 0x4d125+ val length = byteArrayOf(((credentialId.size ushr 8) and 0xff).toByte(), (credentialId.size and 0xff).toByte())126+ return base + ByteArray(16) + length + credentialId + publicKeyCose127+ }128+129+ private fun clientDataJson(type: String, challenge: String, origin: String): ByteArray =130+ json.encodeToString(131+ JsonObject.serializer(),132+ buildJsonObject {133+ put("type", type)134+ put("challenge", challenge)135+ put("origin", origin)136+ put("crossOrigin", JsonPrimitive(false))137+ },138+ ).toByteArray(Charsets.UTF_8)139+140+ private fun privateJwk(privateKey: ECPrivateKey, publicKey: ECPublicKey): String =141+ json.encodeToString(142+ JsonObject.serializer(),143+ buildJsonObject {144+ put("kty", "EC")145+ put("crv", "P-256")146+ put("d", b64(privateKey.s.toFixed32()))147+ put("x", b64(publicKey.w.affineX.toFixed32()))148+ put("y", b64(publicKey.w.affineY.toFixed32()))149+ },150+ )151+152+ private fun originFromRpId(rpId: String): String =153+ if (rpId.equals("localhost", ignoreCase = true) || rpId == "127.0.0.1" || rpId == "[::1]") "http://$rpId" else "https://$rpId"154+155+ private fun b64(data: ByteArray): String = urlEncoder.encodeToString(data)156+}157+158+private object Cbor {159+ fun uint(value: Int) = integer(value)160+ fun nint(value: Int) = integer(value)161+ fun bytes(value: ByteArray) = major(2, value.size) + value162+ fun text(value: String): ByteArray {163+ val bytes = value.toByteArray(Charsets.UTF_8)164+ return major(3, bytes.size) + bytes165+ }166+ fun map(vararg entries: Pair<Any, ByteArray>): ByteArray {167+ val body = entries.flatMap { (key, value) ->168+ val encodedKey = when (key) {169+ is Int -> integer(key)170+ is String -> text(key)171+ else -> error("unsupported cbor key")172+ }173+ listOf(encodedKey, value)174+ }.fold(ByteArray(0)) { acc, bytes -> acc + bytes }175+ return major(5, entries.size) + body176+ }177+ private fun integer(value: Int): ByteArray =178+ if (value >= 0) major(0, value) else major(1, -1 - value)179+ private fun major(type: Int, value: Int): ByteArray = when {180+ value < 24 -> byteArrayOf(((type shl 5) or value).toByte())181+ value < 0x100 -> byteArrayOf(((type shl 5) or 24).toByte(), value.toByte())182+ value < 0x10000 -> byteArrayOf(((type shl 5) or 25).toByte(), (value ushr 8).toByte(), value.toByte())183+ else -> error("cbor length too large")184+ }185+}186+187+private fun java.math.BigInteger.toFixed32(): ByteArray {188+ val raw = toByteArray()189+ val unsigned = if (raw.size > 1 && raw[0] == 0.toByte()) raw.copyOfRange(1, raw.size) else raw190+ return ByteArray(32).also { unsigned.copyInto(it, 32 - unsigned.size) }191+}app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+20 −1
@@ -5,9 +5,11 @@55 import android.graphics.drawable.Icon66 import androidx.credentials.provider.BeginCreateCredentialRequest77 import androidx.credentials.provider.BeginCreateCredentialResponse8+import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest89 import androidx.credentials.provider.BeginGetCredentialRequest910 import androidx.credentials.provider.BeginGetCredentialResponse1011 import androidx.credentials.provider.BeginGetPublicKeyCredentialOption12+import androidx.credentials.provider.CreateEntry1113 import androidx.credentials.provider.CredentialProviderService1214 import androidx.credentials.provider.ProviderClearCredentialStateRequest1315 import androidx.credentials.provider.PublicKeyCredentialEntry@@ -83,7 +85,24 @@8385 cancellationSignal: CancellationSignal,8486 callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,8587 ) {86- callback.onResult(BeginCreateCredentialResponse())88+ val publicKeyRequest = request as? BeginCreatePublicKeyCredentialRequest89+ if (publicKeyRequest == null || !vault.settings.value.passkeyProviderEnabled || !vault.isUnlocked.value) {90+ callback.onResult(BeginCreateCredentialResponse())91+ return92+ }93+ val count = vault.getEntries().count { it.passkey != null }94+ val entry = CreateEntry(95+ accountName = "Peach",96+ pendingIntent = PasskeyCredentialSelectionActivity.createPendingIntent(this, publicKeyRequest.requestJson),97+ description = "Save passkey to Peach",98+ lastUsedTime = Instant.now(),99+ icon = Icon.createWithResource(this, R.drawable.ic_peach_mark),100+ passwordCredentialCount = null,101+ publicKeyCredentialCount = count,102+ totalCredentialCount = vault.getEntries().size,103+ isAutoSelectAllowed = false,104+ )105+ callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry)))87106 }88107 89108 override fun onClearCredentialStateRequest(app/src/test/java/com/peachpasswords/passkeys/PasskeyRegistrationTest.kt+57 −0
@@ -0,0 +1,57 @@1+package com.peachpasswords.passkeys2+3+import kotlinx.serialization.json.Json4+import kotlinx.serialization.json.contentOrNull5+import kotlinx.serialization.json.int6+import kotlinx.serialization.json.jsonArray7+import kotlinx.serialization.json.jsonObject8+import kotlinx.serialization.json.jsonPrimitive9+import org.junit.Assert.assertEquals10+import org.junit.Assert.assertNotNull11+import org.junit.Assert.assertTrue12+import org.junit.Test13+import java.util.Base6414+15+class PasskeyRegistrationTest {16+ @Test fun builds_es256_registration_response_and_entry() {17+ val result = PasskeyRegistration.build(18+ requestJson = """19+ {"challenge":"Y2hhbGxlbmdl","rp":{"id":"example.com","name":"Example"},"user":{"id":"dXNlcg","name":"june","displayName":"June"},"pubKeyCredParams":[{"type":"public-key","alg":-7}]}20+ """.trimIndent(),21+ origin = "https://login.example.com",22+ now = 123,23+ )24+25+ assertNotNull(result)26+ result!!27+ val passkey = result.entryDraft.passkey!!28+ assertEquals("example.com", passkey.rpId)29+ assertEquals("june", passkey.userName)30+ assertEquals(-7, passkey.algorithm)31+ assertEquals(123, passkey.createdAt)32+ assertTrue(passkey.privateKeyJwk!!.contains("\"d\""))33+ assertTrue(b64Decode(passkey.credentialId).size == 32)34+ assertTrue(b64Decode(passkey.publicKeyCose).isNotEmpty())35+36+ val response = Json.parseToJsonElement(result.responseJson).jsonObject37+ assertEquals(passkey.credentialId, response["id"]?.jsonPrimitive?.contentOrNull)38+ val payload = response["response"]!!.jsonObject39+ assertEquals(-7, payload["publicKeyAlgorithm"]?.jsonPrimitive?.int)40+ assertEquals("internal", payload["transports"]?.jsonArray?.single()?.jsonPrimitive?.contentOrNull)41+ val clientData = Json.parseToJsonElement(String(b64Decode(payload["clientDataJSON"]!!.jsonPrimitive.content), Charsets.UTF_8)).jsonObject42+ assertEquals("webauthn.create", clientData["type"]?.jsonPrimitive?.contentOrNull)43+ assertEquals("https://login.example.com", clientData["origin"]?.jsonPrimitive?.contentOrNull)44+ assertTrue(b64Decode(payload["attestationObject"]!!.jsonPrimitive.content).isNotEmpty())45+ }46+47+ @Test fun rejects_requests_without_es256() {48+ val result = PasskeyRegistration.build(49+ """{"challenge":"c","rp":{"id":"example.com"},"user":{"id":"u","name":"june"},"pubKeyCredParams":[{"alg":-8}]}""",50+ "https://example.com",51+ )52+ assertEquals(null, result)53+ }54+55+ private fun b64Decode(value: String): ByteArray =56+ Base64.getUrlDecoder().decode(value.padEnd(((value.length + 3) / 4) * 4, '='))57+}