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
↓ peach-android-98f9187e.tar.gzatlas at this save
files changed5 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 −0app/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+}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,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,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)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+}