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
↓ peach-browser-ef5de931.tar.gzatlas at this save
files changed5 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 −0manifest.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": {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",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": {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+});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+}