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
↓ peach-browser-4ebb6a4c.tar.gzatlas at this save
files changed8 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 −0android/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"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 }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+}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": {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",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": {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) {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. */