4ebb6a4c579e

android SyncKeyPlugin (Keystore-backed X25519); register in MainActivity; security-crypto dep; clearAndroidSyncIdentity wired into Reset; bump 2.15.0; chrome+firefox builds clean; 281 tests passing

time
2026-04-30 21:51 UTC · 5 months ago
machine
koh2
parent
61a7b6644c68
8 files android/app/build.gradle+1 −0android/app/src/main/java/com/peachpasswords/app/MainActivity.kt+1 −0android/app/src/main/java/com/peachpasswords/app/SyncKeyPlugin.kt+91 −0manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/views/Settings.tsx+4 −0src/shared/android-sync-identity.ts+18 −0
android/app/build.gradle+1 −0
@@ -36,6 +36,7 @@3636     implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"3737     implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"3838     implementation "androidx.biometric:biometric:$androidxBiometricVersion"39+    implementation "androidx.security:security-crypto:1.1.0-alpha06"3940     implementation project(':capacitor-android')4041     testImplementation "junit:junit:$junitVersion"4142     androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"

view file

android/app/src/main/java/com/peachpasswords/app/MainActivity.kt+1 −0
@@ -9,6 +9,7 @@99         registerPlugin(BiometricPlugin::class.java)1010         registerPlugin(InMemoryVaultPlugin::class.java)1111         registerPlugin(SyncPlugin::class.java)12+        registerPlugin(SyncKeyPlugin::class.java)1213         super.onCreate(savedInstanceState)1314         VaultBridge.init(this)1415     }

view file

android/app/src/main/java/com/peachpasswords/app/SyncKeyPlugin.kt+91 −0
@@ -0,0 +1,91 @@1+package com.peachpasswords.app2+3+import androidx.security.crypto.EncryptedSharedPreferences4+import androidx.security.crypto.MasterKey5+import com.getcapacitor.JSObject6+import com.getcapacitor.Plugin7+import com.getcapacitor.PluginCall8+import com.getcapacitor.PluginMethod9+import com.getcapacitor.annotation.CapacitorPlugin10+11+/**12+ * SyncKey — persistent X25519 sync identity stored under the AndroidX13+ * Keystore-backed master key. The JS layer (src/shared/android-sync-identity.ts)14+ * generates the keypair with @stablelib/x25519; this plugin only persists it.15+ *16+ * loadKeypair returns empty strings (rather than rejecting) when no keypair17+ * exists yet, so the JS side knows to generate + saveKeypair on first run.18+ * clearKeypair wipes both keys; the next loadKeypair will return empty again.19+ */20+@CapacitorPlugin(name = "SyncKey")21+class SyncKeyPlugin : Plugin() {22+23+    companion object {24+        private const val PREFS_FILE = "peach_sync_key"25+        private const val KEY_PRIVATE = "sync_private_key_hex"26+        private const val KEY_PUBLIC = "sync_public_key_hex"27+    }28+29+    private fun encryptedPrefs() = EncryptedSharedPreferences.create(30+        context,31+        PREFS_FILE,32+        MasterKey.Builder(context)33+            .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)34+            .build(),35+        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,36+        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,37+    )38+39+    @PluginMethod40+    fun loadKeypair(call: PluginCall) {41+        try {42+            val prefs = encryptedPrefs()43+            val privateKeyHex = prefs.getString(KEY_PRIVATE, "") ?: ""44+            val publicKeyHex = prefs.getString(KEY_PUBLIC, "") ?: ""45+            call.resolve(46+                JSObject()47+                    .put("privateKeyHex", privateKeyHex)48+                    .put("publicKeyHex", publicKeyHex)49+            )50+        } catch (e: Exception) {51+            call.reject("Failed to load sync keypair: ${e.message}")52+        }53+    }54+55+    @PluginMethod56+    fun saveKeypair(call: PluginCall) {57+        val privateKeyHex = call.getString("privateKeyHex")58+            ?: return call.reject("privateKeyHex required")59+        val publicKeyHex = call.getString("publicKeyHex")60+            ?: return call.reject("publicKeyHex required")61+62+        // X25519 keys are 32 bytes = 64 hex chars. Reject anything else early63+        // — saving a malformed key would brick sync until clearKeypair is run.64+        if (privateKeyHex.length != 64 || publicKeyHex.length != 64) {65+            return call.reject("Keys must be 64 hex characters (32 bytes)")66+        }67+68+        try {69+            encryptedPrefs().edit()70+                .putString(KEY_PRIVATE, privateKeyHex)71+                .putString(KEY_PUBLIC, publicKeyHex)72+                .apply()73+            call.resolve()74+        } catch (e: Exception) {75+            call.reject("Failed to save sync keypair: ${e.message}")76+        }77+    }78+79+    @PluginMethod80+    fun clearKeypair(call: PluginCall) {81+        try {82+            encryptedPrefs().edit()83+                .remove(KEY_PRIVATE)84+                .remove(KEY_PUBLIC)85+                .apply()86+            call.resolve()87+        } catch (e: Exception) {88+            call.reject("Failed to clear sync keypair: ${e.message}")89+        }90+    }91+}

view file

manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.14.10",4+  "version": "2.15.0",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.14.10",4+  "version": "2.15.0",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "2.14.10",3+  "version": "2.15.0",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/popup/views/Settings.tsx+4 −0
@@ -477,6 +477,10 @@477477 478478   async function handleResetSyncIdentity() {479479     setResetIdentityRunning(true);480+    if (Capacitor.isNativePlatform()) {481+      const { clearAndroidSyncIdentity } = await import("../../shared/android-sync-identity");482+      await clearAndroidSyncIdentity();483+    }480484     const res = await sendMessage({ type: "RESET_SYNC_IDENTITY" });481485     setResetIdentityRunning(false);482486     if ((res as { ok: boolean }).ok) {

view file

src/shared/android-sync-identity.ts+18 −0
@@ -22,6 +22,8 @@2222   loadKeypair(): Promise<{ privateKeyHex: string; publicKeyHex: string }>;2323   /** Persists a fresh keypair generated on the JS side. */2424   saveKeypair(keys: { privateKeyHex: string; publicKeyHex: string }): Promise<void>;25+  /** Wipes the persisted keypair. The JS cache must be invalidated separately. */26+  clearKeypair(): Promise<void>;2527 }2628 2729 const SyncKey = registerPlugin<SyncKeyPlugin>("SyncKey");@@ -64,6 +66,22 @@6466   await SyncKey.saveKeypair({ privateKeyHex, publicKeyHex });6567   cached = { privateKey: pair.secretKey, publicKey: pair.publicKey };6668   return cached;69+}70+71+/**72+ * Wipe the persisted keypair and the in-memory cache. The next call to73+ * getAndroidSyncIdentity() will generate and persist a fresh pair.74+ *75+ * No-op on non-native platforms — desktop doesn't use this identity.76+ */77+export async function clearAndroidSyncIdentity(): Promise<void> {78+  cached = null;79+  if (!Capacitor.isNativePlatform()) return;80+  try {81+    await SyncKey.clearKeypair();82+  } catch {83+    // Plugin may be missing on older builds — best-effort.84+  }6785 }6886 6987 /** Test-only — wipe the in-memory cache. */

view file