44316f00e2ed
feat: Sign ES256 passkey assertions (v2.3.43)
- time
- 2026-06-26 13:44 UTC · 4 months ago
- machine
- koh2
- parent
- c3d88387bfd8
↓ peach-android-44316f00.tar.gzatlas at this save
files changed5 files
app/build.gradle+2 −2app/src/main/java/com/peachpasswords/passkeys/PasskeyAssertion.kt+136 −0app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt+77 −8app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+5 −1app/src/test/java/com/peachpasswords/passkeys/PasskeyAssertionTest.kt+94 −0app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616 applicationId "com.peachpasswords"1717 minSdk 261818 targetSdk 3519- versionCode 23420020- versionName "2.3.42"19+ versionCode 23430020+ versionName "2.3.43"2121 2222 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323 vectorDrawables {app/src/main/java/com/peachpasswords/passkeys/PasskeyAssertion.kt+136 −0
@@ -0,0 +1,136 @@1+package com.peachpasswords.passkeys2+3+import com.peachpasswords.vault.VaultEntry4+import java.math.BigInteger5+import java.security.AlgorithmParameters6+import java.security.KeyFactory7+import java.security.MessageDigest8+import java.security.Signature9+import java.security.interfaces.ECPrivateKey10+import java.security.spec.ECGenParameterSpec11+import java.security.spec.ECParameterSpec12+import java.security.spec.ECPrivateKeySpec13+import java.util.Base6414+import kotlinx.serialization.json.Json15+import kotlinx.serialization.json.JsonObject16+import kotlinx.serialization.json.JsonPrimitive17+import kotlinx.serialization.json.buildJsonObject18+import kotlinx.serialization.json.contentOrNull19+import kotlinx.serialization.json.intOrNull20+import kotlinx.serialization.json.jsonObject21+import kotlinx.serialization.json.jsonPrimitive22+import kotlinx.serialization.json.put23+24+data class PasskeyAssertionResult(25+ val responseJson: String,26+ val nextSignCount: Int,27+)28+29+object PasskeyAssertion {30+ private val json = Json { ignoreUnknownKeys = true }31+ private val urlEncoder = Base64.getUrlEncoder().withoutPadding()32+ private val urlDecoder = Base64.getUrlDecoder()33+34+ fun build(35+ entry: VaultEntry,36+ requestJson: String,37+ origin: String?,38+ clientDataHashOverride: ByteArray? = null,39+ ): PasskeyAssertionResult? {40+ val passkey = entry.passkey ?: return null41+ if (passkey.algorithm != -7) return null42+ val privateKeyJwk = passkey.privateKeyJwk ?: entry.privateKeyJwk ?: return null43+ val request = runCatching { json.parseToJsonElement(requestJson).jsonObject }.getOrNull() ?: return null44+ val rpId = request["rpId"]?.jsonPrimitive?.contentOrNull ?: passkey.rpId45+ if (rpId != passkey.rpId) return null46+ val challenge = request["challenge"]?.jsonPrimitive?.contentOrNull ?: return null47+48+ val nextSignCount = passkey.signCount + 149+ val flags = 0x05 or (if (passkey.backupEligible) 0x08 else 0) or (if (passkey.backupState) 0x10 else 0)50+ val authData = buildAuthenticatorData(passkey.rpId, flags, nextSignCount)51+ val clientDataJson = buildClientDataJson(52+ challenge = challenge,53+ origin = origin?.takeIf { it.isNotBlank() } ?: originFromRpId(passkey.rpId),54+ )55+ val clientDataHash = clientDataHashOverride ?: sha256(clientDataJson)56+ val signature = signEs256(privateKeyJwk, authData + clientDataHash) ?: return null57+58+ val responseJson = json.encodeToString(59+ JsonObject.serializer(),60+ buildJsonObject {61+ put("id", passkey.credentialId)62+ put("rawId", passkey.credentialId)63+ put("type", "public-key")64+ put("response", buildJsonObject {65+ put("clientDataJSON", b64(clientDataJson))66+ put("authenticatorData", b64(authData))67+ put("signature", b64(signature))68+ put("userHandle", passkey.userHandle)69+ })70+ put("authenticatorAttachment", "platform")71+ put("clientExtensionResults", buildJsonObject {})72+ },73+ )74+ return PasskeyAssertionResult(responseJson = responseJson, nextSignCount = nextSignCount)75+ }76+77+ private fun buildClientDataJson(challenge: String, origin: String): ByteArray =78+ json.encodeToString(79+ JsonObject.serializer(),80+ buildJsonObject {81+ put("type", "webauthn.get")82+ put("challenge", challenge)83+ put("origin", origin)84+ put("crossOrigin", JsonPrimitive(false))85+ },86+ ).toByteArray(Charsets.UTF_8)87+88+ private fun buildAuthenticatorData(rpId: String, flags: Int, signCount: Int): ByteArray {89+ val out = ByteArray(37)90+ sha256(rpId.toByteArray(Charsets.UTF_8)).copyInto(out, 0)91+ out[32] = flags.toByte()92+ out[33] = ((signCount ushr 24) and 0xff).toByte()93+ out[34] = ((signCount ushr 16) and 0xff).toByte()94+ out[35] = ((signCount ushr 8) and 0xff).toByte()95+ out[36] = (signCount and 0xff).toByte()96+ return out97+ }98+99+ private fun signEs256(privateKeyJwk: String, message: ByteArray): ByteArray? {100+ val jwk = runCatching { json.parseToJsonElement(privateKeyJwk).jsonObject }.getOrNull() ?: return null101+ if (jwk["kty"]?.jsonPrimitive?.contentOrNull != "EC") return null102+ if (jwk["crv"]?.jsonPrimitive?.contentOrNull != "P-256") return null103+ val d = jwk["d"]?.jsonPrimitive?.contentOrNull?.let(::b64Decode) ?: return null104+ val privateKey = ecPrivateKey(d)105+ val signer = Signature.getInstance("SHA256withECDSA")106+ signer.initSign(privateKey)107+ signer.update(message)108+ return signer.sign()109+ }110+111+ private fun ecPrivateKey(d: ByteArray): ECPrivateKey {112+ val keyFactory = KeyFactory.getInstance("EC")113+ val spec = ECPrivateKeySpec(BigInteger(1, d), ecP256Spec())114+ return keyFactory.generatePrivate(spec) as ECPrivateKey115+ }116+117+ private fun ecP256Spec(): ECParameterSpec {118+ val params = AlgorithmParameters.getInstance("EC")119+ params.init(ECGenParameterSpec("secp256r1"))120+ return params.getParameterSpec(ECParameterSpec::class.java)121+ }122+123+ private fun originFromRpId(rpId: String): String =124+ if (rpId.equals("localhost", ignoreCase = true) || rpId == "127.0.0.1" || rpId == "[::1]") {125+ "http://$rpId"126+ } else {127+ "https://$rpId"128+ }129+130+ private fun sha256(data: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(data)131+ private fun b64(data: ByteArray): String = urlEncoder.encodeToString(data)132+ private fun b64Decode(value: String): ByteArray {133+ val padded = value.padEnd(((value.length + 3) / 4) * 4, '=')134+ return urlDecoder.decode(padded)135+ }136+}app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt+77 −8
@@ -1,36 +1,105 @@11 package com.peachpasswords.passkeys22 3-import android.app.Activity43 import android.app.PendingIntent54 import android.content.Context65 import android.content.Intent76 import android.os.Build87 import android.os.Bundle8+import androidx.activity.ComponentActivity9+import androidx.credentials.GetCredentialResponse10+import androidx.credentials.GetPublicKeyCredentialOption11+import androidx.credentials.PublicKeyCredential912 import androidx.credentials.exceptions.GetCredentialUnsupportedException1013 import androidx.credentials.provider.PendingIntentHandler14+import androidx.lifecycle.lifecycleScope15+import com.peachpasswords.vault.VaultRepository16+import dagger.hilt.android.AndroidEntryPoint17+import javax.inject.Inject18+import kotlinx.coroutines.launch1119 12-class PasskeyCredentialSelectionActivity : Activity() {20+@AndroidEntryPoint21+class PasskeyCredentialSelectionActivity : ComponentActivity() {22+ @Inject lateinit var vault: VaultRepository23+1324 override fun onCreate(savedInstanceState: Bundle?) {1425 super.onCreate(savedInstanceState)26+ lifecycleScope.launch { completeSelection() }27+ }28+29+ private suspend fun completeSelection() {1530 val result = Intent()16- PendingIntentHandler.setGetCredentialException(17- result,18- GetCredentialUnsupportedException("Peach passkey signing is not available yet."),19- )31+ val entryId = intent.getStringExtra(EXTRA_ENTRY_ID)32+ val providerRequest = runCatching {33+ PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)34+ }.getOrNull()35+ val option = providerRequest?.credentialOptions36+ ?.filterIsInstance<GetPublicKeyCredentialOption>()37+ ?.firstOrNull()38+ val requestJson = option?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)39+ val entry = entryId?.let(vault::getEntry)40+ val origin = providerRequest?.callingAppInfo?.let { info ->41+ runCatching { info.getOrigin(info.packageName) }.getOrNull()42+ }43+ val assertion = if (entry != null && requestJson != null) {44+ PasskeyAssertion.build(45+ entry = entry,46+ requestJson = requestJson,47+ origin = origin,48+ clientDataHashOverride = option?.clientDataHash,49+ )50+ } else {51+ null52+ }53+54+ if (entry != null && assertion != null) {55+ val passkey = entry.passkey56+ val persisted = if (passkey != null) {57+ val now = System.currentTimeMillis()58+ runCatching {59+ vault.updateEntry(60+ entry.copy(61+ passkey = passkey.copy(signCount = assertion.nextSignCount),62+ signCount = assertion.nextSignCount,63+ lastUsedAt = now,64+ ),65+ )66+ }.isSuccess67+ } else {68+ false69+ }70+ if (persisted) {71+ PendingIntentHandler.setGetCredentialResponse(72+ result,73+ GetCredentialResponse(PublicKeyCredential(assertion.responseJson)),74+ )75+ } else {76+ PendingIntentHandler.setGetCredentialException(77+ result,78+ GetCredentialUnsupportedException("Peach could not update this passkey."),79+ )80+ }81+ } else {82+ PendingIntentHandler.setGetCredentialException(83+ result,84+ GetCredentialUnsupportedException("Peach cannot sign this passkey yet."),85+ )86+ }2087 setResult(RESULT_OK, result)2188 finish()2289 }2390 2491 companion object {2592 private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID"93+ private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON"2694 27- fun pendingIntent(context: Context, entryId: String): PendingIntent {95+ fun pendingIntent(context: Context, entryId: String, requestJson: String): PendingIntent {2896 val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)2997 .putExtra(EXTRA_ENTRY_ID, entryId)98+ .putExtra(EXTRA_REQUEST_JSON, requestJson)3099 val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 031100 return PendingIntent.getActivity(32101 context,33- entryId.hashCode(),102+ "$entryId:$requestJson".hashCode(),34103 intent,35104 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,36105 )app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+5 −1
@@ -62,7 +62,11 @@6262 PublicKeyCredentialEntry(6363 context = this,6464 username = candidate.username,65- pendingIntent = PasskeyCredentialSelectionActivity.pendingIntent(this, candidate.entryId),65+ pendingIntent = PasskeyCredentialSelectionActivity.pendingIntent(66+ this,67+ candidate.entryId,68+ option.requestJson,69+ ),6670 beginGetPublicKeyCredentialOption = option,6771 displayName = candidate.displayName,6872 lastUsedTime = Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L),app/src/test/java/com/peachpasswords/passkeys/PasskeyAssertionTest.kt+94 −0
@@ -0,0 +1,94 @@1+package com.peachpasswords.passkeys2+3+import com.peachpasswords.vault.PasskeyData4+import com.peachpasswords.vault.VaultEntry5+import java.math.BigInteger6+import java.security.MessageDigest7+import java.security.KeyPairGenerator8+import java.security.Signature9+import java.security.interfaces.ECPrivateKey10+import java.security.interfaces.ECPublicKey11+import java.security.spec.ECGenParameterSpec12+import java.util.Base6413+import kotlinx.serialization.json.Json14+import kotlinx.serialization.json.contentOrNull15+import kotlinx.serialization.json.jsonObject16+import kotlinx.serialization.json.jsonPrimitive17+import org.junit.Assert.assertEquals18+import org.junit.Assert.assertNotNull19+import org.junit.Assert.assertTrue20+import org.junit.Test21+22+class PasskeyAssertionTest {23+ @Test fun builds_es256_assertion_and_increments_counter() {24+ val keyPair = KeyPairGenerator.getInstance("EC").apply {25+ initialize(ECGenParameterSpec("secp256r1"))26+ }.generateKeyPair()27+ val entry = passkeyEntry(keyPair.private as ECPrivateKey)28+29+ val result = PasskeyAssertion.build(30+ entry = entry,31+ requestJson = """{"challenge":"Y2hhbGxlbmdl","rpId":"example.com"}""",32+ origin = "https://login.example.com",33+ )34+35+ assertNotNull(result)36+ result!!37+ assertEquals(42, result.nextSignCount)38+ val response = Json.parseToJsonElement(result.responseJson).jsonObject39+ assertEquals("cred-1", response["id"]?.jsonPrimitive?.contentOrNull)40+ val payload = response["response"]!!.jsonObject41+ val clientDataBytes = b64Decode(payload["clientDataJSON"]!!.jsonPrimitive.content)42+ val clientData = String(clientDataBytes, Charsets.UTF_8)43+ val client = Json.parseToJsonElement(clientData).jsonObject44+ assertEquals("webauthn.get", client["type"]?.jsonPrimitive?.contentOrNull)45+ assertEquals("https://login.example.com", client["origin"]?.jsonPrimitive?.contentOrNull)46+ val authData = b64Decode(payload["authenticatorData"]!!.jsonPrimitive.content)47+ assertEquals(37, authData.size)48+ assertEquals(42, ((authData[33].toInt() and 0xff) shl 24) or ((authData[34].toInt() and 0xff) shl 16) or ((authData[35].toInt() and 0xff) shl 8) or (authData[36].toInt() and 0xff))49+50+ val verifier = Signature.getInstance("SHA256withECDSA")51+ verifier.initVerify(keyPair.public as ECPublicKey)52+ verifier.update(authData + MessageDigest.getInstance("SHA-256").digest(clientDataBytes))53+ assertTrue(verifier.verify(b64Decode(payload["signature"]!!.jsonPrimitive.content)))54+ }55+56+ @Test fun returns_null_for_non_es256_or_missing_private_key() {57+ val entry = passkeyEntry(null)58+ assertEquals(null, PasskeyAssertion.build(entry, """{"challenge":"c","rpId":"example.com"}""", "https://example.com"))59+ val eddsa = entry.copy(passkey = entry.passkey!!.copy(privateKeyJwk = """{"kty":"OKP","crv":"Ed25519","d":"x","x":"y"}""", algorithm = -8))60+ assertEquals(null, PasskeyAssertion.build(eddsa, """{"challenge":"c","rpId":"example.com"}""", "https://example.com"))61+ }62+63+ private fun passkeyEntry(privateKey: ECPrivateKey?) = VaultEntry(64+ id = "entry-1",65+ type = "passkey",66+ title = "Example",67+ passkey = PasskeyData(68+ credentialId = "cred-1",69+ publicKeyCose = "cose",70+ privateKeyJwk = privateKey?.let(::privateJwk),71+ rpId = "example.com",72+ userHandle = "user-handle",73+ userName = "june",74+ userDisplayName = "June",75+ algorithm = -7,76+ signCount = 41,77+ ),78+ createdAt = 1,79+ modifiedAt = 1,80+ )81+82+ private fun privateJwk(key: ECPrivateKey): String =83+ """{"kty":"EC","crv":"P-256","d":"${b64(key.s.toFixed32())}"}"""84+85+ private fun BigInteger.toFixed32(): ByteArray {86+ val raw = toByteArray()87+ val unsigned = if (raw.size > 1 && raw[0] == 0.toByte()) raw.copyOfRange(1, raw.size) else raw88+ return ByteArray(32).also { unsigned.copyInto(it, 32 - unsigned.size) }89+ }90+91+ private fun b64(bytes: ByteArray): String = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)92+ private fun b64Decode(value: String): ByteArray =93+ Base64.getUrlDecoder().decode(value.padEnd(((value.length + 3) / 4) * 4, '='))94+}