93c2fbea8f2c
fix: Preserve passkey private keys in PeachScript (v2.3.41)
- time
- 2026-06-26 13:34 UTC · 4 months ago
- machine
- koh2
- parent
- ad458e292ac6
↓ peach-android-93c2fbea.tar.gzatlas at this save
files changed5 files
app/build.gradle+2 −2app/src/main/java/com/peachpasswords/vault/PasskeyData.kt+1 −0app/src/main/java/com/peachpasswords/vault/PeachPayloadCodec.kt+18 −4app/src/main/java/com/peachpasswords/vault/VaultEntry.kt+4 −4app/src/test/java/com/peachpasswords/vault/PeachScriptTest.kt+10 −1app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616 applicationId "com.peachpasswords"1717 minSdk 261818 targetSdk 3519- versionCode 23400020- versionName "2.3.40"19+ versionCode 23410020+ versionName "2.3.41"2121 2222 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323 vectorDrawables {app/src/main/java/com/peachpasswords/vault/PasskeyData.kt+1 −0
@@ -9,6 +9,7 @@99 data class PasskeyData(1010 val credentialId: String,1111 val publicKeyCose: String,12+ val privateKeyJwk: String? = null,1213 val rpId: String,1314 val rpName: String? = null,1415 val userHandle: String,app/src/main/java/com/peachpasswords/vault/PeachPayloadCodec.kt+18 −4
@@ -539,9 +539,9 @@539539 }540540 541541 // ---- passkey JSON blob (peachPayload.ts:552-596) ----------------------542- // [PLATFORM GAP] Android's PasskeyData has no privateKeyJwk field, so a543- // browser passkey's private key is dropped on import. All other passkey +544- // login fields round-trip. See BROWSER-SPEC Implementation Notes.542+ // Browser passkeys carry privateKeyJwk as a JSON object. Android stores that543+ // object as compact JSON text so Parcelable vault entries can preserve it544+ // across import/export; Credential Provider signing is implemented separately.545545 546546 private fun encodePasskeyBlob(entry: VaultEntry): ByteArray {547547 val pk = entry.passkey@@ -550,6 +550,7 @@550550 putIfPresent("rpName", pk?.rpName ?: entry.rpName)551551 putIfPresent("credentialId", pk?.credentialId ?: entry.credentialId)552552 putIfPresent("publicKeyCose", pk?.publicKeyCose ?: entry.publicKeyCose)553+ putJsonObjectStringIfPresent("privateKeyJwk", pk?.privateKeyJwk ?: entry.privateKeyJwk)553554 putIfPresent("userHandle", pk?.userHandle ?: entry.userHandle)554555 putIfPresent("userName", pk?.userName ?: entry.userName)555556 putIfPresent("userDisplayName", pk?.userDisplayName ?: entry.userDisplayName)@@ -581,9 +582,15 @@581582 fun intf(k: String) = obj[k]?.jsonPrimitive?.intOrNull582583 fun longf(k: String) = obj[k]?.jsonPrimitive?.longOrNull583584 fun boolf(k: String) = obj[k]?.jsonPrimitive?.booleanOrNull585+ fun objectJson(k: String): String? {586+ val value = obj[k] ?: return null587+ val nested = runCatching { value.jsonObject }.getOrNull() ?: return null588+ return json.encodeToString(JsonObject.serializer(), nested)589+ }584590 val tags = obj["tags"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull }585591 val credentialId = str("credentialId")586592 val publicKeyCose = str("publicKeyCose")593+ val privateKeyJwk = objectJson("privateKeyJwk")587594 val rpId = str("rpId")588595 val userHandle = str("userHandle")589596 val userName = str("userName")@@ -591,6 +598,7 @@591598 userHandle != null && userName != null) {592599 PasskeyData(593600 credentialId = credentialId, publicKeyCose = publicKeyCose, rpId = rpId,601+ privateKeyJwk = privateKeyJwk,594602 rpName = str("rpName"), userHandle = userHandle, userName = userName,595603 userDisplayName = str("userDisplayName"), algorithm = intf("algorithm") ?: -7,596604 signCount = intf("signCount") ?: 0, aaguid = str("aaguid"),@@ -604,7 +612,8 @@604612 createdAt = createdAt, modifiedAt = modifiedAt, deletedAt = deletedAt,605613 passkey = passkey, lastUsedAt = longf("lastUsedAt"),606614 rpId = rpId, rpName = str("rpName"), credentialId = credentialId,607- publicKeyCose = publicKeyCose, userHandle = userHandle, userName = userName,615+ publicKeyCose = publicKeyCose, privateKeyJwk = privateKeyJwk,616+ userHandle = userHandle, userName = userName,608617 userDisplayName = str("userDisplayName"), algorithm = intf("algorithm"),609618 aaguid = str("aaguid"), backupEligible = boolf("backupEligible"),610619 backupState = boolf("backupState"), signCount = intf("signCount"),@@ -751,6 +760,11 @@751760 752761 private fun JsonObjectBuilder.putIfPresent(key: String, value: String?) {753762 if (!value.isNullOrEmpty()) put(key, JsonPrimitive(value))763+ }764+ private fun JsonObjectBuilder.putJsonObjectStringIfPresent(key: String, value: String?) {765+ if (value.isNullOrBlank()) return766+ val obj = runCatching { json.parseToJsonElement(value).jsonObject }.getOrNull() ?: return767+ put(key, obj)754768 }755769 private fun JsonObjectBuilder.putIfPresent(key: String, value: Int?) {756770 if (value != null) put(key, JsonPrimitive(value))app/src/main/java/com/peachpasswords/vault/VaultEntry.kt+4 −4
@@ -31,10 +31,9 @@3131 // entries FLAT on the entry — rpId/rpName/credentialId/publicKeyCose/userHandle/3232 // userName/userDisplayName/algorithm/signCount directly, NOT under a nested object.3333 // The flat fields below use the SAME camelCase names, so they serialize compatibly34- // with the browser and need no @SerialName. `passkey` is an Android-internal35- // convenience the browser ignores (ignoreUnknownKeys). Gap (follow-up, not a36- // mapping fix): the browser also carries `privateKeyJwk`; Android does not sync the37- // passkey private key, so cross-platform passkey *signing* is not yet supported.34+ // with the browser and need no @SerialName. `privateKeyJwk` is preserved as a35+ // compact JSON string internally and emitted as a JSON object in PeachScript;36+ // Android Credential Provider signing still needs a future ceremony implementation.3837 val passkey: PasskeyData? = null,3938 val signCount: Int? = null,4039 val lastUsedAt: Long? = null,@@ -61,6 +60,7 @@6160 val rpName: String? = null,6261 val credentialId: String? = null,6362 val publicKeyCose: String? = null,63+ val privateKeyJwk: String? = null,6464 val userHandle: String? = null,6565 val userName: String? = null,6666 val userDisplayName: String? = null,app/src/test/java/com/peachpasswords/vault/PeachScriptTest.kt+10 −1
@@ -7,6 +7,10 @@77 import org.junit.Assert.assertTrue88 import org.junit.Test99 import java.util.Base6410+import kotlinx.serialization.json.Json11+import kotlinx.serialization.json.contentOrNull12+import kotlinx.serialization.json.jsonObject13+import kotlinx.serialization.json.jsonPrimitive1014 1115 class PeachScriptTest {1216 @@ -146,11 +150,13 @@146150 }147151 148152 @Test fun passkey_core_fields_round_trip() {153+ val privateKeyJwk = """{"kty":"EC","crv":"P-256","x":"x1","y":"y1","d":"d1"}"""149154 val pk = VaultEntry(150155 id = "12121212-3434-5656-7878-909090909090", type = "passkey", title = "Passkey",151156 passkey = PasskeyData(152157 credentialId = "cred-abc", publicKeyCose = "cose-xyz", rpId = "example.com",153- rpName = "Example", userHandle = "uh-1", userName = "june", userDisplayName = "June",158+ privateKeyJwk = privateKeyJwk, rpName = "Example",159+ userHandle = "uh-1", userName = "june", userDisplayName = "June",154160 ),155161 createdAt = 1_500_000_000_000, modifiedAt = 1_500_000_000_000,156162 )@@ -161,6 +167,9 @@161167 assertEquals("cred-abc", back.passkey?.credentialId)162168 assertEquals("example.com", back.passkey?.rpId)163169 assertEquals("june", back.passkey?.userName)170+ val backJwk = Json.parseToJsonElement(requireNotNull(back.passkey?.privateKeyJwk)).jsonObject171+ assertEquals("EC", backJwk["kty"]?.jsonPrimitive?.contentOrNull)172+ assertEquals("d1", backJwk["d"]?.jsonPrimitive?.contentOrNull)164173 }165174 166175 @Test fun full_export_import_via_peachscript_text() {