98f9187ed004

read a passkey's privateKeyJwk as an object or a string, which is what made every browser vault with a passkey unreadable

time
2026-07-25 04:00 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
browser passkey jwk
parent
0a5587e2c57d
5 files app/src/main/java/com/peachpasswords/vault/JwkString.kt+51 −0app/src/main/java/com/peachpasswords/vault/PasskeyData.kt+1 −0app/src/main/java/com/peachpasswords/vault/VaultEntry.kt+1 −0app/src/main/java/com/peachpasswords/vault/VaultStore.kt+4 −0app/src/test/java/com/peachpasswords/vault/BrowserPasskeyJwkTest.kt+77 −0
app/src/main/java/com/peachpasswords/vault/JwkString.kt+51 −0
@@ -0,0 +1,51 @@1+package com.peachpasswords.vault2+3+import kotlinx.serialization.KSerializer4+import kotlinx.serialization.descriptors.PrimitiveKind5+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor6+import kotlinx.serialization.descriptors.SerialDescriptor7+import kotlinx.serialization.encoding.Decoder8+import kotlinx.serialization.encoding.Encoder9+import kotlinx.serialization.json.Json10+import kotlinx.serialization.json.JsonDecoder11+import kotlinx.serialization.json.JsonEncoder12+import kotlinx.serialization.json.JsonObject13+import kotlinx.serialization.json.JsonPrimitive14+15+/**16+ * Reads a passkey's `privateKeyJwk` whether it arrives as a JSON object or a17+ * string, and writes it back as an object.18+ *19+ * The browser types this field as `JsonWebKey` (`shared/types.ts`) — a real JSON20+ * object. Android modelled it as a `String`, so kotlinx hit `{` where it wanted21+ * a quote and threw "Expected beginning of the string, but got …" partway22+ * through the payload. Any browser vault holding a passkey with a private key23+ * was unreadable on Android — a Codex restore or a sync of that vault failed24+ * with a raw parser error naming a byte offset, which looks like corruption and25+ * is nothing of the sort.26+ *27+ * Kept as a compact string in memory because that is what the rest of the app28+ * and PeachScript already pass around; only the wire form changes.29+ */30+object JwkStringSerializer : KSerializer<String> {31+    override val descriptor: SerialDescriptor =32+        PrimitiveSerialDescriptor("com.peachpasswords.vault.JwkString", PrimitiveKind.STRING)33+34+    private val json = Json { encodeDefaults = true }35+36+    override fun deserialize(decoder: Decoder): String {37+        val input = decoder as? JsonDecoder ?: return decoder.decodeString()38+        return when (val element = input.decodeJsonElement()) {39+            is JsonPrimitive -> if (element.isString) element.content else element.toString()40+            else -> element.toString()41+        }42+    }43+44+    override fun serialize(encoder: Encoder, value: String) {45+        val output = encoder as? JsonEncoder ?: return encoder.encodeString(value)46+        // Write the browser's shape back, so a vault that round-trips through47+        // Android stays readable there.48+        val element = runCatching { json.parseToJsonElement(value) }.getOrNull()49+        if (element is JsonObject) output.encodeJsonElement(element) else output.encodeString(value)50+    }51+}

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+    @Serializable(with = JwkStringSerializer::class)1213     val privateKeyJwk: String? = null,1314     val rpId: String,1415     val rpName: String? = null,

view file

app/src/main/java/com/peachpasswords/vault/VaultEntry.kt+1 −0
@@ -60,6 +60,7 @@6060     val rpName: String? = null,6161     val credentialId: String? = null,6262     val publicKeyCose: String? = null,63+    @Serializable(with = JwkStringSerializer::class)6364     val privateKeyJwk: String? = null,6465     val userHandle: String? = null,6566     val userName: String? = null,

view file

app/src/main/java/com/peachpasswords/vault/VaultStore.kt+4 −0
@@ -108,6 +108,10 @@108108         VaultCrypto.padToBucket(json.encodeToString(payload).toByteArray(Charsets.UTF_8))109109     fun decode(bytes: ByteArray): VaultPayload =110110         json.decodeFromString(String(bytes, Charsets.UTF_8).trimEnd('\u0000'))111+    /** Test seam: the entry codec is only reachable through a whole payload otherwise. */112+    internal fun decodeEntryForTest(entryJson: String): VaultEntry = json.decodeFromString(entryJson)113+    internal fun encodeEntryForTest(entry: VaultEntry): String = json.encodeToString(entry)114+111115     fun encodeSettings(s: VaultSettings): String = json.encodeToString(s)112116     fun decodeSettings(s: String): VaultSettings? = runCatching { json.decodeFromString<VaultSettings>(s) }.getOrNull()113117     fun encodeSlot(s: StoredVault): String = json.encodeToString(s)

view file

app/src/test/java/com/peachpasswords/vault/BrowserPasskeyJwkTest.kt+77 −0
@@ -0,0 +1,77 @@1+package com.peachpasswords.vault2+3+import org.junit.Assert.assertEquals4+import org.junit.Assert.assertTrue5+import org.junit.Test6+7+/**8+ * A passkey's `privateKeyJwk` is a JSON OBJECT in the browser (`JsonWebKey` in9+ * shared/types.ts); Android holds it as a compact string. Before the custom10+ * serializer, decoding a real browser vault threw11+ * "Expected beginning of the string, but got {" partway through the payload —12+ * so any browser vault containing a passkey with a private key could not be13+ * restored from a Codex or synced onto Android at all, and the failure named a14+ * byte offset, which reads as file corruption rather than a schema mismatch.15+ */16+class BrowserPasskeyJwkTest {17+18+    /** Shaped exactly as the browser writes it. */19+    private val browserEntryJson = """20+        {21+          "id": "e1",22+          "type": "login",23+          "title": "GitHub",24+          "username": "june",25+          "createdAt": 1,26+          "modifiedAt": 2,27+          "passkey": {28+            "rpId": "github.com",29+            "rpName": "GitHub",30+            "credentialId": "Y3JlZA",31+            "publicKeyCose": "cG9zZQ",32+            "privateKeyJwk": {33+              "kty": "EC",34+              "crv": "P-256",35+              "x": "abc",36+              "y": "def",37+              "d": "secret"38+            },39+            "userHandle": "dXNlcg",40+            "userName": "june",41+            "userDisplayName": "June",42+            "algorithm": -7,43+            "signCount": 0,44+            "aaguid": "",45+            "backupEligible": false,46+            "backupState": false,47+            "createdAt": 148+          }49+        }50+    """.trimIndent()51+52+    @Test fun `a browser passkey entry decodes instead of throwing on the jwk object`() {53+        val entry = VaultCodec.decodeEntryForTest(browserEntryJson)54+        assertEquals("GitHub", entry.title)55+        val jwk = entry.passkey?.privateKeyJwk56+        assertTrue("the jwk should survive as compact json, got $jwk", jwk != null && jwk.contains("\"kty\""))57+        assertTrue("the private component must not be lost", jwk!!.contains("secret"))58+    }59+60+    @Test fun `re-encoding writes the jwk back as an object the browser can read`() {61+        val entry = VaultCodec.decodeEntryForTest(browserEntryJson)62+        val json = VaultCodec.encodeEntryForTest(entry)63+        // A quoted string here would be silently unreadable to the browser.64+        assertTrue("expected an object, got: $json", json.contains("\"privateKeyJwk\":{"))65+        assertTrue(json.contains("\"kty\":\"EC\""))66+    }67+68+    @Test fun `a jwk already stored as a string still round-trips`() {69+        val asString = browserEntryJson.replace(70+            Regex("\"privateKeyJwk\": \\{[^}]*\\}"),71+            "\"privateKeyJwk\": \"not-json\"",72+        )73+        val entry = VaultCodec.decodeEntryForTest(asString)74+        assertEquals("not-json", entry.passkey?.privateKeyJwk)75+        assertTrue(VaultCodec.encodeEntryForTest(entry).contains("\"privateKeyJwk\":\"not-json\""))76+    }77+}

view file