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
↓ peach-browser-31cc6fe0.tar.gzatlas at this save
files changed2 files
src/background/sync-identity-encryption.test.ts+111 −0src/background/vault.ts+58 −11src/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+});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.