31cc6fe0e97a

M12/SEC-003: encrypt sync identity private key under derived vault key

time
2026-05-16 03:16 UTC · 5 months ago
machine
koh2
parent
27a13a833aa5
2 files src/background/sync-identity-encryption.test.ts+111 −0src/background/vault.ts+58 −11
src/background/sync-identity-encryption.test.ts+111 −0
@@ -0,0 +1,111 @@1+/**2+ * sync-identity-encryption.test.ts — M12/SEC-0033+ *4+ * getSyncIdentity must encrypt the X25519 private key under the vault's5+ * derived key before persisting, must require unlock to read, and must6+ * migrate v0 (unencrypted) records to v1 (encrypted) on first read.7+ */8+9+import { beforeEach, describe, expect, it, vi } from "vitest";10+11+describe("sync identity encryption (M12/SEC-003)", () => {12+  let storage: Record<string, unknown>;13+14+  beforeEach(() => {15+    vi.resetModules();16+    vi.useRealTimers();17+    storage = {};18+19+    const chromeMock = {20+      runtime: { id: "peach-extension", sendMessage: vi.fn() },21+      storage: {22+        local: {23+          get: vi.fn(async (key?: string | string[]) => {24+            if (!key) return storage;25+            if (typeof key === "string") return { [key]: storage[key] };26+            return key.reduce<Record<string, unknown>>((acc, k) => {27+              acc[k] = storage[k];28+              return acc;29+            }, {});30+          }),31+          set: vi.fn(async (next: Record<string, unknown>) => {32+            Object.assign(storage, next);33+          }),34+          remove: vi.fn(async (keys: string | string[]) => {35+            const list = Array.isArray(keys) ? keys : [keys];36+            for (const k of list) delete storage[k];37+          }),38+        },39+        session: {40+          get: vi.fn(async () => ({})),41+          set: vi.fn(async () => {}),42+          remove: vi.fn(async () => {}),43+        },44+      },45+      alarms: {46+        clear: vi.fn(),47+        create: vi.fn(),48+        onAlarm: { addListener: vi.fn() },49+      },50+      idle: {51+        setDetectionInterval: vi.fn(),52+        onStateChanged: { addListener: vi.fn() },53+      },54+    } as unknown as typeof chrome;55+    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;56+  });57+58+  it("throws vault_locked when called before unlock", async () => {59+    const vault = await import("./vault");60+    await expect(vault.getSyncIdentity()).rejects.toThrow(/vault_locked/);61+  });62+63+  it("persisted record contains no plaintext private key field after generation", async () => {64+    const vault = await import("./vault");65+    await vault.createVault("test-pass-12345");66+    const { privateKeyBytes } = await vault.getSyncIdentity();67+68+    const stored = JSON.parse(storage.peach_sync_identity as string) as Record<string, unknown>;69+    expect(stored.priv).toBeUndefined();70+    expect(typeof stored.enc_priv).toBe("string");71+    expect(typeof stored.enc_nonce).toBe("string");72+    expect(typeof stored.enc_tag).toBe("string");73+    expect(typeof stored.pub).toBe("string");74+    expect(privateKeyBytes.length).toBe(32);75+  });76+77+  it("subsequent reads return the same private key bytes", async () => {78+    const vault = await import("./vault");79+    await vault.createVault("test-pass-12345");80+    const first = await vault.getSyncIdentity();81+    const second = await vault.getSyncIdentity();82+    expect(Array.from(second.privateKeyBytes)).toEqual(Array.from(first.privateKeyBytes));83+    expect(second.publicKeyHex).toBe(first.publicKeyHex);84+  });85+86+  it("migrates v0 (unencrypted priv/pub) record to v1 encrypted form", async () => {87+    const vault = await import("./vault");88+    await vault.createVault("test-pass-12345");89+90+    // Overwrite with a v0-shaped record.91+    const fakePriv = new Uint8Array(32).fill(7);92+    const fakePub = new Uint8Array(32).fill(8);93+    const b64 = (b: Uint8Array) => {94+      let s = "";95+      for (const v of b) s += String.fromCharCode(v);96+      return btoa(s);97+    };98+    storage.peach_sync_identity = JSON.stringify({99+      priv: b64(fakePriv),100+      pub: b64(fakePub),101+    });102+103+    const result = await vault.getSyncIdentity();104+    expect(Array.from(result.privateKeyBytes)).toEqual(Array.from(fakePriv));105+106+    // Storage was upgraded.107+    const stored = JSON.parse(storage.peach_sync_identity as string) as Record<string, unknown>;108+    expect(stored.priv).toBeUndefined();109+    expect(typeof stored.enc_priv).toBe("string");110+  });111+});

view file

src/background/vault.ts+58 −11
@@ -2270,34 +2270,81 @@22702270   return true;22712271 }22722272 2273+/**2274+ * Returns the X25519 sync identity, encrypting the private key on first call2275+ * and on any unencrypted-shape migration. Requires the vault to be unlocked2276+ * — the private key is wrapped under the same derived key as the vault, so2277+ * a locked browser cannot leak it from storage alone (M12/SEC-003).2278+ *2279+ * Persistence shapes:2280+ *   v0 (pre-M12, migrated on first read):2281+ *     { priv: base64, pub: base64 }2282+ *   v1 (encrypted):2283+ *     { pub: base64, enc_priv: hex, enc_nonce: hex, enc_tag: hex }2284+ */22732285 export async function getSyncIdentity(): Promise<{22742286   privateKeyBytes: Uint8Array;22752287   publicKeyBytes: Uint8Array;22762288   publicKeyHex: string;22772289 }> {2290+  if (!derivedKey) throw new Error('vault_locked');22782291   const raw = await chrome.storage.local.get('peach_sync_identity');22792292   const stored = (raw as Record<string, unknown>)['peach_sync_identity'];2293+22802294   if (typeof stored === 'string') {2281-    const { priv, pub } = JSON.parse(stored) as { priv: string; pub: string };2282-    const privateKeyBytes = base64DecodeBytes(priv);2283-    const publicKeyBytes = base64DecodeBytes(pub);2284-    return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };2295+    const parsed = JSON.parse(stored) as {2296+      pub: string;2297+      priv?: string;2298+      enc_priv?: string;2299+      enc_nonce?: string;2300+      enc_tag?: string;2301+    };2302+    const publicKeyBytes = base64DecodeBytes(parsed.pub);2303+2304+    if (parsed.enc_priv && parsed.enc_nonce && parsed.enc_tag) {2305+      const privHex = await decrypt(2306+        derivedKey,2307+        parsed.enc_nonce,2308+        parsed.enc_priv,2309+        parsed.enc_tag,2310+      );2311+      const privateKeyBytes = unhex(privHex);2312+      return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };2313+    }2314+2315+    if (parsed.priv) {2316+      // Migrate v0 (unencrypted private key) → v1.2317+      const privateKeyBytes = base64DecodeBytes(parsed.priv);2318+      await persistEncryptedSyncIdentity(privateKeyBytes, publicKeyBytes);2319+      return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };2320+    }22852321   }22862322 2287-  // Generate a fresh X25519 keypair and persist it.2323+  // Generate a fresh X25519 keypair and persist it encrypted.22882324   const { generateKeyPair } = await import('@stablelib/x25519');22892325   const keypair = generateKeyPair();2290-  await chrome.storage.local.set({2291-    peach_sync_identity: JSON.stringify({2292-      priv: base64EncodeBytes(keypair.secretKey),2293-      pub: base64EncodeBytes(keypair.publicKey),2294-    }),2295-  });2326+  await persistEncryptedSyncIdentity(keypair.secretKey, keypair.publicKey);22962327   return {22972328     privateKeyBytes: keypair.secretKey,22982329     publicKeyBytes: keypair.publicKey,22992330     publicKeyHex: bytesToHex(keypair.publicKey),23002331   };2332+}2333+2334+async function persistEncryptedSyncIdentity(2335+  privateKeyBytes: Uint8Array,2336+  publicKeyBytes: Uint8Array,2337+): Promise<void> {2338+  if (!derivedKey) throw new Error('vault_locked');2339+  const enc = await encrypt(derivedKey, hex(privateKeyBytes));2340+  await chrome.storage.local.set({2341+    peach_sync_identity: JSON.stringify({2342+      pub: base64EncodeBytes(publicKeyBytes),2343+      enc_priv: enc.ciphertext,2344+      enc_nonce: enc.nonce,2345+      enc_tag: enc.tag,2346+    }),2347+  });23012348 }23022349 23032350 // Re-decrypt vault entries from storage using the current in-memory key.

view file