ef5de9313daf

feat: ChirpSyncVaultAdapter — production adapter backing runChirpSyncInitiator; per-entry AES-GCM envelope under HKDF-derived sync entry key; 13 tests covering vault_id, manifests, encrypt/decrypt round-trip, tombstone merge, id-swap rejection, locked-vault errors (v3.6.87)

time
2026-05-21 16:30 UTC · 5 months ago
machine
koh2
parent
21a0838bcb4e
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/chirp-sync-vault-adapter.test.ts+189 −0src/background/chirp-sync-vault-adapter.ts+213 −0
manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22   "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33   "manifest_version": 3,44   "name": "Peach",5-  "version": "3.6.86",5+  "version": "3.6.87",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "3.6.86",4+  "version": "3.6.87",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": "3.6.86",3+  "version": "3.6.87",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/chirp-sync-vault-adapter.test.ts+189 −0
@@ -0,0 +1,189 @@1+import { beforeEach, describe, expect, it, vi } from "vitest";2+3+type StorageShape = Record<string, unknown>;4+5+function makeChromeMock(local: StorageShape, sync: StorageShape): typeof chrome {6+  const area = (store: StorageShape) => ({7+    get: vi.fn(async (key?: string | string[]) => {8+      if (!key) return store;9+      if (typeof key === "string") return { [key]: store[key] };10+      return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = store[k]; return acc; }, {});11+    }),12+    set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),13+    remove: vi.fn(async (keys: string | string[]) => {14+      const list = Array.isArray(keys) ? keys : [keys];15+      for (const k of list) delete store[k];16+    }),17+  });18+  return {19+    storage: { local: area(local), sync: area(sync) },20+    alarms: {21+      clear: vi.fn(),22+      create: vi.fn(),23+      onAlarm: { addListener: vi.fn() },24+    },25+    idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },26+    runtime: { sendMessage: vi.fn(async () => undefined) },27+  } as unknown as typeof chrome;28+}29+30+describe("ChirpSyncVaultAdapter", () => {31+  let local: StorageShape;32+  let sync: StorageShape;33+34+  beforeEach(() => {35+    vi.resetModules();36+    local = {};37+    sync = {};38+    (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);39+    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));40+  });41+42+  async function setupVault(): Promise<{ vault: typeof import("./vault"); adapter: import("./chirp-sync-coordinator").ChirpSyncVaultAdapter }> {43+    const vault = await import("./vault");44+    await vault.createVault("correct horse battery staple");45+    const { createChirpSyncVaultAdapter } = await import("./chirp-sync-vault-adapter");46+    const adapter = createChirpSyncVaultAdapter();47+    return { vault, adapter };48+  }49+50+  it("computeVaultId returns a stable 16-char hex string for the same masterKey", async () => {51+    const { adapter } = await setupVault();52+    const id1 = await adapter.computeVaultId();53+    const id2 = await adapter.computeVaultId();54+    expect(id1).toBe(id2);55+    expect(id1).toMatch(/^[0-9a-f]{16}$/);56+  });57+58+  it("getOurDeviceId is stable across calls and a fresh UUID v4 on first call", async () => {59+    const { adapter } = await setupVault();60+    const id1 = await adapter.getOurDeviceId();61+    const id2 = await adapter.getOurDeviceId();62+    expect(id1).toBe(id2);63+    expect(id1).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);64+  });65+66+  it("buildEntryManifest reports {id: modifiedAt} for live entries only", async () => {67+    const { vault, adapter } = await setupVault();68+    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });69+    const b = await vault.addEntry({ type: "login", title: "Beta", password: "pw" });70+    await vault.deleteEntry(b.id);71+    const m = await adapter.buildEntryManifest();72+    expect(Object.keys(m).sort()).toEqual([a.id]);73+    expect(m[a.id]).toBe(a.modifiedAt);74+  });75+76+  it("buildTombstoneManifest reports {id: deletedAt} for soft-deleted entries", async () => {77+    const { vault, adapter } = await setupVault();78+    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });79+    await vault.deleteEntry(a.id);80+    const t = await adapter.buildTombstoneManifest();81+    expect(Object.keys(t)).toEqual([a.id]);82+    expect(typeof t[a.id]).toBe("number");83+    expect(t[a.id]).toBeGreaterThan(0);84+  });85+86+  it("getEncryptedEntries returns opaque base64 strings keyed by id", async () => {87+    const { vault, adapter } = await setupVault();88+    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });89+    const env = await adapter.getEncryptedEntries([a.id]);90+    expect(Object.keys(env)).toEqual([a.id]);91+    expect(env[a.id]).toMatch(/^[A-Za-z0-9+/]+=*$/);92+    // Plaintext title must not be visible in the envelope.93+    expect(env[a.id]).not.toContain("Alpha");94+  });95+96+  it("getEncryptedEntries silently skips unknown ids (peer may have a manifest item we dropped)", async () => {97+    const { adapter } = await setupVault();98+    const env = await adapter.getEncryptedEntries(["does-not-exist"]);99+    expect(env).toEqual({});100+  });101+102+  it("encryption uses a fresh nonce per call — same entry yields different envelopes", async () => {103+    const { vault, adapter } = await setupVault();104+    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });105+    const e1 = await adapter.getEncryptedEntries([a.id]);106+    const e2 = await adapter.getEncryptedEntries([a.id]);107+    expect(e1[a.id]).not.toBe(e2[a.id]);108+  });109+110+  it("round-trips: encrypt on one adapter, apply on a fresh import of the same vault", async () => {111+    const { vault, adapter } = await setupVault();112+    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "secret", url: "https://a.example" });113+    const envelopes = await adapter.getEncryptedEntries([a.id]);114+    // Simulate a remote vault by bumping modifiedAt on the envelope's source115+    // and then applying it back — we can't easily fork the module's storage,116+    // so we just verify decrypt path by overwriting modifiedAt locally.117+    // Delete the local entry first, then apply the envelope — applyMergedRemoteEntries118+    // will re-add it because the local copy is tombstoned with an OLDER ts (since119+    // we set modifiedAt forward by 1s in the new envelope payload).120+    // Instead, simpler proof: apply the envelope as-is to a fresh adapter import.121+    vault.lock();122+    await vault.unlock("correct horse battery staple");123+    const { createChirpSyncVaultAdapter } = await import("./chirp-sync-vault-adapter");124+    const adapter2 = createChirpSyncVaultAdapter();125+    // applyEntries should be a no-op when local mtime >= envelope mtime126+    await adapter2.applyEntries(envelopes);127+    expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Alpha");128+  });129+130+  it("applyEntries decrypts and merges newer remote entries (last-write-wins)", async () => {131+    const { vault, adapter } = await setupVault();132+    const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" });133+    // Manually produce a fresher remote envelope by encrypting an updated entry shape.134+    // We can't easily synthesise this without the entry key — so we use a second135+    // adapter instance backed by the same vault, after mutating the underlying entry.136+    await vault.updateEntry(local.id, { title: "Updated" });137+    const newer = vault.getEntries().find((e) => e.id === local.id);138+    expect(newer?.title).toBe("Updated");139+    const envelopes = await adapter.getEncryptedEntries([local.id]);140+    // Now revert the local entry to an older mtime by re-creating from scratch via141+    // applyMergedRemoteEntries — but we'd loop. The most reliable proof of the142+    // decrypt+merge path is that applyEntries on a fresher envelope keeps the143+    // newer state, and on an older envelope is a no-op. We've covered the no-op144+    // path above; for the apply path, simulate by replacing the in-memory entry145+    // with an older copy and re-running applyEntries.146+    const older: typeof local = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" };147+    await vault.restoreEntries([older], "replace");148+    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale");149+    await adapter.applyEntries(envelopes);150+    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated");151+  });152+153+  it("applyEntries rejects envelopes whose decrypted id differs from the map key", async () => {154+    const { vault, adapter } = await setupVault();155+    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });156+    const env = await adapter.getEncryptedEntries([a.id]);157+    // Re-key the envelope under a different id — adapter must refuse to merge it.158+    const swapped: Record<string, string> = { "wrong-id": env[a.id] };159+    await adapter.applyEntries(swapped);160+    // Local entry remains unchanged; no new entry under "wrong-id"161+    expect(vault.getEntries().map((e) => e.id)).toEqual([a.id]);162+  });163+164+  it("applyTombstones soft-deletes locally-live entries with newer deletedAt", async () => {165+    const { vault, adapter } = await setupVault();166+    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });167+    await adapter.applyTombstones({ [a.id]: a.modifiedAt + 10_000 });168+    expect(vault.getEntries().find((e) => e.id === a.id)).toBeUndefined();169+    expect(vault.getDeletedEntries().find((e) => e.id === a.id)).toBeDefined();170+  });171+172+  it("applyTombstones leaves entries alone when local modifiedAt is newer", async () => {173+    const { vault, adapter } = await setupVault();174+    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });175+    await adapter.applyTombstones({ [a.id]: a.modifiedAt - 10_000 });176+    expect(vault.getEntries().find((e) => e.id === a.id)).toBeDefined();177+  });178+179+  it("methods throw VaultLockedError when the vault is locked", async () => {180+    const { vault, adapter } = await setupVault();181+    vault.lock();182+    await expect(adapter.computeVaultId()).rejects.toMatchObject({ message: "vault_locked" });183+    await expect(adapter.buildEntryManifest()).rejects.toMatchObject({ message: "vault_locked" });184+    await expect(adapter.buildTombstoneManifest()).rejects.toMatchObject({ message: "vault_locked" });185+    await expect(adapter.getEncryptedEntries(["x"])).rejects.toMatchObject({ message: "vault_locked" });186+    await expect(adapter.applyEntries({ x: "AAAA" })).rejects.toMatchObject({ message: "vault_locked" });187+    await expect(adapter.applyTombstones({ x: 1 })).rejects.toMatchObject({ message: "vault_locked" });188+  });189+});

view file

src/background/chirp-sync-vault-adapter.ts+213 −0
@@ -0,0 +1,213 @@1+// Production adapter that backs `runChirpSyncInitiator` with the live vault.2+//3+// The coordinator (chirp-sync-coordinator.ts) never decrypts vault data; it4+// only moves opaque base64 entry blobs and metadata. This adapter is the5+// boundary where vault plaintext meets the sync layer — entries leave the6+// vault as encrypted envelopes here, and incoming envelopes are decrypted7+// here before being merged via `applyMergedRemoteEntries`.8+//9+// Envelope cipher: AES-GCM(key = HKDF(masterKey, salt="peach-sync-entry-v1"),10+//                          nonce = random 12B, aad = "peach-sync-entry-v1").11+// Both sides hold the same masterKey (same vault) so the symmetric key12+// matches without prior negotiation. The Noise XX session encrypts the13+// transport on top of this — the per-entry envelope is belt-and-braces so a14+// transport-layer leak doesn't expose plaintext entries.15+//16+// Device-manifest exchange (v2 brief §4.3) is intentionally NOT plumbed here.17+// The coordinator's optional `buildOutgoingDeviceManifest` /18+// `applyIncomingDeviceManifest` hooks are left undefined so the coordinator19+// skips that protocol step. Adding manifest support is a separate concern20+// that requires verified-issuer registry plumbing.21+22+import { HKDF } from "@stablelib/hkdf";23+import { SHA256 } from "@stablelib/sha256";24+import type { ChirpSyncVaultAdapter } from "./chirp-sync-coordinator";25+import { getOrCreateDeviceId } from "./chirp-identity";26+import { computeVaultId } from "../shared/sync-identity";27+import {28+  applyMergedRemoteEntries,29+  getDeletedEntries,30+  getDerivedKeyHex,31+  getEntries,32+  getEntryById,33+  isUnlocked,34+} from "./vault";35+import type { VaultEntry } from "../shared/types";36+37+const ENTRY_KEY_SALT = new TextEncoder().encode("peach-sync-entry-v1");38+const ENTRY_AAD = "peach-sync-entry-v1";39+40+export class VaultLockedError extends Error {41+  constructor() {42+    super("vault_locked");43+    this.name = "VaultLockedError";44+  }45+}46+47+export function createChirpSyncVaultAdapter(): ChirpSyncVaultAdapter {48+  return {49+    async computeVaultId() {50+      const master = requireMasterKey();51+      return computeVaultId(master);52+    },53+54+    async getOurDeviceId() {55+      return getOrCreateDeviceId();56+    },57+58+    async buildEntryManifest() {59+      requireUnlocked();60+      const manifest: Record<string, number> = {};61+      for (const entry of getEntries()) {62+        manifest[entry.id] = entry.modifiedAt;63+      }64+      return manifest;65+    },66+67+    async buildTombstoneManifest() {68+      requireUnlocked();69+      const manifest: Record<string, number> = {};70+      for (const entry of getDeletedEntries()) {71+        if (typeof entry.deletedAt === "number") {72+          manifest[entry.id] = entry.deletedAt;73+        }74+      }75+      return manifest;76+    },77+78+    async getEncryptedEntries(entryIds) {79+      requireUnlocked();80+      const masterKey = requireMasterKey();81+      const entryKey = await deriveEntryKey(masterKey);82+      const out: Record<string, string> = {};83+      for (const id of entryIds) {84+        const entry = getEntryById(id);85+        if (!entry) continue;86+        out[id] = await encryptEntry(entryKey, entry);87+      }88+      return out;89+    },90+91+    async applyEntries(entries) {92+      requireUnlocked();93+      const masterKey = requireMasterKey();94+      const entryKey = await deriveEntryKey(masterKey);95+      const decrypted: VaultEntry[] = [];96+      for (const [id, envelope] of Object.entries(entries)) {97+        const entry = await decryptEntryOrNull(entryKey, envelope);98+        if (!entry) continue;99+        // The map key is the authority on id — refuse mismatched envelopes100+        // so a swapped envelope can't poison a different entry slot.101+        if (entry.id !== id) continue;102+        decrypted.push(entry);103+      }104+      if (decrypted.length > 0) {105+        await applyMergedRemoteEntries(decrypted, {});106+      }107+    },108+109+    async applyTombstones(tombstones) {110+      requireUnlocked();111+      const cleaned: Record<string, number> = {};112+      for (const [id, ts] of Object.entries(tombstones)) {113+        if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {114+          cleaned[id] = ts;115+        }116+      }117+      if (Object.keys(cleaned).length > 0) {118+        await applyMergedRemoteEntries([], cleaned);119+      }120+    },121+  };122+}123+124+function requireUnlocked(): void {125+  if (!isUnlocked()) throw new VaultLockedError();126+}127+128+function requireMasterKey(): Uint8Array {129+  if (!isUnlocked()) throw new VaultLockedError();130+  const hex = getDerivedKeyHex();131+  if (!hex) throw new VaultLockedError();132+  return hexToBytes(hex);133+}134+135+async function deriveEntryKey(masterKey: Uint8Array): Promise<CryptoKey> {136+  const raw = new HKDF(SHA256, masterKey, ENTRY_KEY_SALT, new Uint8Array(0)).expand(32);137+  return crypto.subtle.importKey(138+    "raw",139+    raw.buffer as ArrayBuffer,140+    { name: "AES-GCM" },141+    false,142+    ["encrypt", "decrypt"],143+  );144+}145+146+async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {147+  const plaintext = new TextEncoder().encode(JSON.stringify(entry));148+  const nonce = crypto.getRandomValues(new Uint8Array(12));149+  const aad = new TextEncoder().encode(ENTRY_AAD);150+  const ct = await crypto.subtle.encrypt(151+    {152+      name: "AES-GCM",153+      iv: nonce.buffer as ArrayBuffer,154+      additionalData: aad.buffer as ArrayBuffer,155+      tagLength: 128,156+    },157+    key,158+    plaintext.buffer as ArrayBuffer,159+  );160+  const ctBytes = new Uint8Array(ct);161+  const envelope = new Uint8Array(nonce.length + ctBytes.length);162+  envelope.set(nonce, 0);163+  envelope.set(ctBytes, nonce.length);164+  return bytesToBase64(envelope);165+}166+167+async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {168+  try {169+    const bytes = base64ToBytes(envelopeB64);170+    if (bytes.length < 12 + 16) return null;171+    const nonce = bytes.subarray(0, 12);172+    const ct = bytes.subarray(12);173+    const aad = new TextEncoder().encode(ENTRY_AAD);174+    const pt = await crypto.subtle.decrypt(175+      {176+        name: "AES-GCM",177+        iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,178+        additionalData: aad.buffer as ArrayBuffer,179+        tagLength: 128,180+      },181+      key,182+      ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,183+    );184+    const json = new TextDecoder().decode(pt);185+    const parsed = JSON.parse(json);186+    if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;187+    return parsed as VaultEntry;188+  } catch {189+    return null;190+  }191+}192+193+function hexToBytes(hex: string): Uint8Array {194+  if (hex.length % 2 !== 0) throw new Error("hex length must be even");195+  const out = new Uint8Array(hex.length / 2);196+  for (let i = 0; i < out.length; i++) {197+    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);198+  }199+  return out;200+}201+202+function bytesToBase64(bytes: Uint8Array): string {203+  let bin = "";204+  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);205+  return btoa(bin);206+}207+208+function base64ToBytes(b64: string): Uint8Array {209+  const bin = atob(b64);210+  const out = new Uint8Array(bin.length);211+  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);212+  return out;213+}

view file