android-sync-identity.ts

3.0 KB · raw

1/**2 * Android sync identity — persistent X25519 keypair for the Capacitor side.3 *4 * The Kotlin SyncKey plugin stores the keypair in encrypted SharedPreferences5 * keyed by hardware-bound master key. JS handles the X25519 math; the plugin6 * is a simple persisted blob store. On first invocation we generate, persist,7 * and return; subsequent calls return the cached pair.8 *9 * Non-Android contexts (extension, dev WebView) throw — this identity is10 * Android-only. Desktop sync uses the native messaging host's identity11 * (`SyncIdentity` in the Rust host) which has its own key file.12 */1314import { Capacitor, registerPlugin } from "@capacitor/core";15import { generateKeyPair } from "@stablelib/x25519";1617interface SyncKeyPlugin {18  /**19   * Returns the stored keypair if one exists, else { privateKeyHex: '',20   * publicKeyHex: '' } so the JS side knows to generate + persist.21   */22  loadKeypair(): Promise<{ privateKeyHex: string; publicKeyHex: string }>;23  /** Persists a fresh keypair generated on the JS side. */24  saveKeypair(keys: { privateKeyHex: string; publicKeyHex: string }): Promise<void>;25  /** Wipes the persisted keypair. The JS cache must be invalidated separately. */26  clearKeypair(): Promise<void>;27}2829const SyncKey = registerPlugin<SyncKeyPlugin>("SyncKey");3031let cached: { privateKey: Uint8Array; publicKey: Uint8Array } | null = null;3233function bytesToHex(bytes: Uint8Array): string {34  return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");35}3637function hexToBytes(hex: string): Uint8Array {38  const out = new Uint8Array(hex.length / 2);39  for (let i = 0; i < hex.length; i += 2) {40    out[i / 2] = parseInt(hex.slice(i, i + 2), 16);41  }42  return out;43}4445export async function getAndroidSyncIdentity(): Promise<{46  privateKey: Uint8Array;47  publicKey: Uint8Array;48}> {49  if (cached) return cached;50  if (!Capacitor.isNativePlatform()) {51    throw new Error("Android sync identity requested on non-native platform");52  }5354  const stored = await SyncKey.loadKeypair();55  if (stored.privateKeyHex && stored.publicKeyHex) {56    cached = {57      privateKey: hexToBytes(stored.privateKeyHex),58      publicKey: hexToBytes(stored.publicKeyHex),59    };60    return cached;61  }6263  const pair = generateKeyPair();64  const privateKeyHex = bytesToHex(pair.secretKey);65  const publicKeyHex = bytesToHex(pair.publicKey);66  await SyncKey.saveKeypair({ privateKeyHex, publicKeyHex });67  cached = { privateKey: pair.secretKey, publicKey: pair.publicKey };68  return cached;69}7071/**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 */77export 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  }85}8687/** Test-only — wipe the in-memory cache. */88export function __resetForTests(): void {89  cached = null;90}