93c2fbea8f2c

fix: Preserve passkey private keys in PeachScript (v2.3.41)

- Store browser privateKeyJwk as compact JSON text in Android passkey entries - Emit privateKeyJwk back as a JSON object during passkey PeachScript export - Cover passkey private key round-trip in PeachScript tests

time
2026-06-26 13:34 UTC · 4 months ago
machine
koh2
parent
ad458e292ac6
5 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 −1
app/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 {

view file

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,

view file

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))

view file

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,

view file

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() {

view file