b82564183743

rename the sync layer off the chirp name: sync-coordinator/runner/transport/vault-adapter, device-identity, pairing-handshake, vault-adopt, and drop the orphaned audio-frame types (v4.0.32)

time
2026-07-25 00:05 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
code pairing rename
parent
1672cc400bce
60 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/chirp-identity.test.ts+0 −85src/background/chirp-identity.ts+0 −143src/background/chirp-sync-coordinator.test.ts+0 −305src/background/chirp-sync-coordinator.ts+0 −295src/background/chirp-sync-runner.test.ts+0 −415src/background/chirp-sync-runner.ts+0 −181src/background/chirp-sync-static.test.ts+0 −97src/background/chirp-sync-vault-adapter.test.ts+0 −596src/background/chirp-sync-vault-adapter.ts+0 −453src/background/code-pairing-bg.ts+2 −2src/background/code-pairing.test.ts+2 −2src/background/code-pairing.ts+2 −2src/background/desktop-pairing-probe.ts+1 −1src/background/device-identity.test.ts+85 −0src/background/device-identity.ts+146 −0src/background/entry.ts+2 −2src/background/messages.ts+7 −7src/background/paired-devices.ts+1 −1src/background/sync-coordinator.test.ts+305 −0src/background/sync-coordinator.ts+295 −0src/background/sync-runner.test.ts+415 −0src/background/sync-runner.ts+181 −0src/background/sync-static.test.ts+97 −0src/background/sync-trigger.desktop.test.ts+9 −9src/background/sync-trigger.ts+3 −3src/background/sync-vault-adapter.test.ts+596 −0src/background/sync-vault-adapter.ts+453 −0src/background/vault.test.ts+3 −3src/background/vault.ts+2 −2src/drafts/TEST-EXPANSION/chirp-transport.probe.test.ts+0 −242src/drafts/TEST-EXPANSION/sync.disconnect.test.ts+8 −8src/popup/App.tsx+13 −13src/popup/views/PairDevice.test.tsx+20 −20src/popup/views/PairDevice.tsx+2 −3src/popup/views/Settings.tsx+1 −1src/shared/chirp-adopt.test.ts+0 −246src/shared/chirp-adopt.ts+0 −214src/shared/chirp-handshake.test.ts+0 −187src/shared/chirp-handshake.ts+0 −386src/shared/chirp-pair-identity-vault-id.test.ts+0 −115src/shared/chirp-sync-transport.test.ts+0 −91src/shared/chirp-sync-transport.ts+0 −292src/shared/device-manifest.ts+1 −1src/shared/local-network.ts+4 −4src/shared/noise-xx.ts+1 −1src/shared/pairing-dial.ts+1 −1src/shared/pairing-handshake.test.ts+187 −0src/shared/pairing-handshake.ts+373 −0src/shared/pairing-handshake.vault-id.test.ts+115 −0src/shared/sync-limits.ts+2 −2src/shared/sync-transport.test.ts+91 −0src/shared/sync-transport.ts+292 −0src/shared/sync-types.ts+14 −47src/shared/sync.disconnect.test.ts+8 −8src/shared/types.ts+3 −3src/shared/vault-adopt.test.ts+246 −0src/shared/vault-adopt.ts+214 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.0.31",4+  "version": "4.0.32",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.0.31",4+  "version": "4.0.32",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": "4.0.31",3+  "version": "4.0.32",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/chirp-identity.test.ts+0 −85
@@ -1,85 +0,0 @@1-import { beforeEach, describe, expect, it, vi } from "vitest";2-3-// PCH-01-004: deviceId must live in chrome.storage.local (device-local), never4-// in chrome.storage.sync (which roams across profiles and collides identities).5-6-type StorageShape = Record<string, unknown>;7-const DEVICE_ID_KEY = "peach_chirp_device_id";8-9-describe("chirp-identity getOrCreateDeviceId (PCH-01-004)", () => {10-  let local: StorageShape;11-  let sync: StorageShape;12-  let localSet: ReturnType<typeof vi.fn>;13-  let syncSet: ReturnType<typeof vi.fn>;14-  let syncRemove: ReturnType<typeof vi.fn>;15-16-  function makeArea(store: StorageShape, set: ReturnType<typeof vi.fn>, remove?: ReturnType<typeof vi.fn>) {17-    return {18-      get: vi.fn(async (key?: string | string[]) => {19-        if (!key) return store;20-        if (typeof key === "string") return { [key]: store[key] };21-        return key.reduce<Record<string, unknown>>((r, k) => {22-          r[k] = store[k];23-          return r;24-        }, {});25-      }),26-      set,27-      remove: remove ?? vi.fn(async () => undefined),28-    };29-  }30-31-  beforeEach(() => {32-    vi.resetModules();33-    local = {};34-    sync = {};35-    localSet = vi.fn(async (next: Record<string, unknown>) => {36-      Object.assign(local, next);37-    });38-    syncSet = vi.fn(async (next: Record<string, unknown>) => {39-      Object.assign(sync, next);40-    });41-    syncRemove = vi.fn(async (key: string) => {42-      delete sync[key];43-    });44-45-    const chromeMock = {46-      storage: {47-        local: makeArea(local, localSet),48-        sync: makeArea(sync, syncSet, syncRemove),49-      },50-    } as unknown as typeof chrome;51-    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;52-  });53-54-  it("migrates_legacy_sync_id_to_local_and_removes_from_sync", async () => {55-    sync[DEVICE_ID_KEY] = "legacy-roamed-id";56-    const { getOrCreateDeviceId } = await import("./chirp-identity");57-58-    const id = await getOrCreateDeviceId();59-60-    expect(id).toBe("legacy-roamed-id");61-    expect(local[DEVICE_ID_KEY]).toBe("legacy-roamed-id");62-    expect(syncRemove).toHaveBeenCalledWith(DEVICE_ID_KEY);63-    expect(sync[DEVICE_ID_KEY]).toBeUndefined();64-  });65-66-  it("fresh_install_writes_id_to_local_only", async () => {67-    const { getOrCreateDeviceId } = await import("./chirp-identity");68-69-    const id = await getOrCreateDeviceId();70-71-    expect(typeof id).toBe("string");72-    expect(id.length).toBeGreaterThan(0);73-    expect(local[DEVICE_ID_KEY]).toBe(id);74-    expect(syncSet).not.toHaveBeenCalled();75-  });76-77-  it("returns_a_stable_id_across_calls", async () => {78-    const { getOrCreateDeviceId } = await import("./chirp-identity");79-80-    const first = await getOrCreateDeviceId();81-    const second = await getOrCreateDeviceId();82-83-    expect(second).toBe(first);84-  });85-});

view file at the parent

src/background/chirp-identity.ts+0 −143
@@ -1,143 +0,0 @@1-// Local-device identity helpers for the chirp-sync architecture.2-//3-// `deviceId` is a stable UUID v4 generated on first call and persisted in4-// `chrome.storage.local`. It is deliberately device-local and must NOT roam:5-// it is the only symmetry-breaker in the sync-identity HKDF (see PCH-01-004),6-// so a shared id across profiles would collide derived sync keys. A first-read7-// migration moves any legacy value out of `chrome.storage.sync`.8-//9-// `deviceName` defaults to a short, friendly browser+OS description and10-// can be overridden by the user from Settings → Sync.11-//12-// The local sync identity itself is derived deterministically from the13-// vault master key (`deriveSyncIdentityKeypair`); the function below is a14-// thin coordinator that pulls the master key out of `vault.ts` and exposes15-// only the public half to callers that don't need to sign.16-17-import {18-  deriveSyncIdentityKeypair,19-  deriveSyncSigningKeypair,20-  pubkeyToHex,21-} from "../shared/sync-identity";22-import type { DeviceType } from "../shared/sync-types";23-24-const DEVICE_ID_KEY = "peach_chirp_device_id";25-const DEVICE_NAME_KEY = "peach_chirp_device_name";26-27-export interface LocalChirpIdentity {28-  deviceId: string;29-  deviceName: string;30-  deviceType: DeviceType;31-  longTermPublicKeyHex: string;32-  signingPublicKeyHex: string;33-}34-35-export async function getOrCreateDeviceId(): Promise<string> {36-  // PCH-01-004: deviceId is the ONLY symmetry-breaker in the sync-identity HKDF37-  // (sync-identity.ts:54). It must NOT roam across profiles via chrome.storage.sync,38-  // or two profiles on the same browser account sharing a vault derive identical39-  // static + signing keys and collide in the device graph. Keep it device-local.40-  const local = await chrome.storage.local.get(DEVICE_ID_KEY);41-  const localId = (local as Record<string, unknown>)[DEVICE_ID_KEY];42-  if (typeof localId === "string" && localId.length > 0) return localId;43-44-  // First-read migration: adopt a value previously stored in sync, then evict it45-  // from sync so a sibling profile cannot re-roam it back. Idempotent — once the46-  // id lives in local, this branch never runs again.47-  const synced = await chrome.storage.sync.get(DEVICE_ID_KEY);48-  const syncedId = (synced as Record<string, unknown>)[DEVICE_ID_KEY];49-  if (typeof syncedId === "string" && syncedId.length > 0) {50-    await chrome.storage.local.set({ [DEVICE_ID_KEY]: syncedId });51-    await chrome.storage.sync.remove(DEVICE_ID_KEY);52-    return syncedId;53-  }54-55-  const fresh = generateUuidV4();56-  await chrome.storage.local.set({ [DEVICE_ID_KEY]: fresh });57-  return fresh;58-}59-60-export async function getDeviceName(): Promise<string> {61-  const result = await chrome.storage.sync.get(DEVICE_NAME_KEY);62-  const stored = (result as Record<string, unknown>)[DEVICE_NAME_KEY];63-  if (typeof stored === "string" && stored.length > 0) return stored;64-  return defaultDeviceName();65-}66-67-export async function setDeviceName(name: string): Promise<void> {68-  const trimmed = name.trim();69-  if (!trimmed) throw new Error("device name must not be empty");70-  if (trimmed.length > 64) throw new Error("device name must be 64 chars or fewer");71-  await chrome.storage.sync.set({ [DEVICE_NAME_KEY]: trimmed });72-}73-74-/**75- * Build the public-facing identity record for the local extension. The76- * private key half (used only for ongoing Noise XX sessions) is not77- * exposed by this function — Phase-1 pairing uses ephemeral keys only.78- */79-export async function buildLocalIdentity(80-  masterKey: Uint8Array,81-): Promise<LocalChirpIdentity> {82-  const deviceId = await getOrCreateDeviceId();83-  const deviceName = await getDeviceName();84-  const kp = deriveSyncIdentityKeypair(masterKey, deviceId);85-  const signing = await deriveSyncSigningKeypair(masterKey, deviceId);86-  return {87-    deviceId,88-    deviceName,89-    deviceType: extensionDeviceType(),90-    longTermPublicKeyHex: pubkeyToHex(kp.publicKey),91-    signingPublicKeyHex: pubkeyToHex(signing.publicKeyRaw),92-  };93-}94-95-/**96- * Return the local sync identity keypair (public and private). Reserved97- * for the Phase-2 WebSocket sync coordinator that runs in the background.98- * Never surface the private key over a message handler.99- */100-export function deriveLocalSyncKeypair(masterKey: Uint8Array, deviceId: string) {101-  return deriveSyncIdentityKeypair(masterKey, deviceId);102-}103-104-function defaultDeviceName(): string {105-  // Best-effort browser + platform string. Avoids userAgent parsing — the106-  // user can rename via Settings.107-  const ua = (typeof navigator !== "undefined" ? navigator.userAgent : "") || "";108-  if (/Firefox/i.test(ua)) return "Firefox";109-  if (/Edg\//i.test(ua)) return "Edge";110-  if (/Chrome/i.test(ua)) return "Chrome";111-  if (/Safari/i.test(ua)) return "Safari";112-  return "Browser";113-}114-115-function extensionDeviceType(): DeviceType {116-  // The same module ships in both the Chrome extension and the Capacitor117-  // mobile build. On Capacitor, Peach acts as an iOS / Android sync host118-  // rather than an extension; the `isNativePlatform` check toggles the tag.119-  try {120-    const native = (globalThis as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor;121-    if (native?.isNativePlatform?.()) {122-      const platform = (globalThis as unknown as { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();123-      if (platform === "ios") return "ios";124-      return "android";125-    }126-  } catch { /* not on Capacitor */ }127-  return "extension";128-}129-130-function generateUuidV4(): string {131-  // crypto.randomUUID is available in MV3 service workers, popup pages,132-  // and modern Capacitor WebView. Falls back to a manual assembly only if133-  // a runtime is missing it (effectively unreachable for our targets).134-  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {135-    return crypto.randomUUID();136-  }137-  const bytes = new Uint8Array(16);138-  crypto.getRandomValues(bytes as Uint8Array<ArrayBuffer>);139-  bytes[6] = (bytes[6] & 0x0f) | 0x40;140-  bytes[8] = (bytes[8] & 0x3f) | 0x80;141-  const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");142-  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;143-}

view file at the parent

src/background/chirp-sync-coordinator.test.ts+0 −305
@@ -1,305 +0,0 @@1-import { describe, expect, it, vi } from "vitest";2-import { runChirpSyncInitiator, type ChirpSyncVaultAdapter } from "./chirp-sync-coordinator";3-import type { ChirpSyncMessage, SignedDeviceManifest } from "../shared/sync-types";4-import type { ChirpSyncSession } from "../shared/chirp-sync-transport";5-import { buildSignedDeviceManifest } from "../shared/device-manifest";6-import { deriveSyncSigningKeypair } from "../shared/sync-identity";7-8-function inMemoryPair(): [ChirpSyncSession, FakePeer] {9-  const a: Uint8Array[] = [];10-  const b: Uint8Array[] = [];11-  const aR: Array<(v: Uint8Array) => void> = [];12-  const bR: Array<(v: Uint8Array) => void> = [];13-14-  const ourSession: ChirpSyncSession = {15-    send(bytes) {16-      const r = bR.shift();17-      if (r) r(bytes); else b.push(bytes);18-    },19-    receive(): Promise<Uint8Array> {20-      const v = a.shift();21-      if (v) return Promise.resolve(v);22-      return new Promise<Uint8Array>((res) => aR.push(res));23-    },24-    async close() { /* no-op */ },25-    remoteStaticPublicKey: new Uint8Array(32),26-  };27-28-  const peer: FakePeer = {29-    async send(msg: ChirpSyncMessage) {30-      const bytes = new TextEncoder().encode(JSON.stringify(msg));31-      const r = aR.shift();32-      if (r) r(bytes); else a.push(bytes);33-    },34-    async receive(): Promise<ChirpSyncMessage> {35-      const v = b.shift();36-      const bytes = v ?? await new Promise<Uint8Array>((res) => bR.push(res));37-      return JSON.parse(new TextDecoder().decode(bytes)) as ChirpSyncMessage;38-    },39-  };40-  return [ourSession, peer];41-}42-43-interface FakePeer {44-  send(msg: ChirpSyncMessage): Promise<void>;45-  receive(): Promise<ChirpSyncMessage>;46-}47-48-function makeVaultAdapter(opts: {49-  vaultId?: string;50-  deviceId?: string;51-  localVersion?: number;52-  entries?: Record<string, number>;53-  tombstones?: Record<string, number>;54-  ciphertext?: Record<string, string>;55-}): ChirpSyncVaultAdapter & {56-  appliedEntries: Record<string, string>[];57-  appliedTombstones: Record<string, number>[];58-} {59-  const appliedEntries: Record<string, string>[] = [];60-  const appliedTombstones: Record<string, number>[] = [];61-  return {62-    async computeVaultId() { return opts.vaultId ?? "vault-abc"; },63-    async getOurDeviceId() { return opts.deviceId ?? "device-init"; },64-    // Use a non-zero default so the equal-version short circuit doesn't fire in65-    // tests that aren't explicitly testing that path.66-    async getLocalVersion() { return opts.localVersion ?? 5; },67-    async buildEntryManifest() { return { ...(opts.entries ?? {}) }; },68-    async buildTombstoneManifest() { return { ...(opts.tombstones ?? {}) }; },69-    async getEncryptedEntries(ids) {70-      const out: Record<string, string> = {};71-      const cipher = opts.ciphertext ?? {};72-      for (const id of ids) if (cipher[id] !== undefined) out[id] = cipher[id];73-      return out;74-    },75-    async applyEntries(entries) { appliedEntries.push(entries); },76-    async applyTombstones(t) { appliedTombstones.push(t); },77-    appliedEntries,78-    appliedTombstones,79-  };80-}81-82-describe("runChirpSyncInitiator — HELLO fields and version optimisation", () => {83-  it("sends HELLO with version and deviceTime", async () => {84-    const [session, peer] = inMemoryPair();85-    const vault = makeVaultAdapter({ localVersion: 7 });86-87-    const initPromise = runChirpSyncInitiator(session, vault);88-89-    const sentHello = await peer.receive();90-    expect(sentHello.type).toBe("SYNC_HELLO");91-    if (sentHello.type === "SYNC_HELLO") {92-      expect(sentHello.version).toBe(7);93-      expect(typeof sentHello.deviceTime).toBe("number");94-      expect(sentHello.deviceTime).toBeGreaterThan(0);95-    }96-    // Send a mismatched vaultId to abort early so the test doesn't hang.97-    await peer.send({ type: "SYNC_HELLO", vaultId: "different-vault", deviceId: "p", version: 7, deviceTime: Date.now() });98-    await peer.receive(); // SYNC_ERROR99-    await initPromise;100-  });101-102-  it("skips manifest exchange and returns completed when versions are equal", async () => {103-    const [session, peer] = inMemoryPair();104-    const vault = makeVaultAdapter({ localVersion: 10 });105-106-    const initPromise = runChirpSyncInitiator(session, vault);107-108-    await peer.receive(); // SYNC_HELLO109-    await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 10, deviceTime: Date.now() });110-111-    // Coordinator should send SYNC_DONE without any manifest exchange.112-    const done = await peer.receive();113-    expect(done.type).toBe("SYNC_DONE");114-115-    // No SYNC_MANIFEST was sent — peer's queue only has the DONE.116-    await peer.send({ type: "SYNC_DONE" });117-118-    const outcome = await initPromise;119-    expect(outcome.status).toBe("completed");120-    expect(outcome.entriesReceived).toBe(0);121-    expect(outcome.entriesSent).toBe(0);122-  });123-124-  it("logs a clock-skew warning but does not abort when clocks differ by > 120s", async () => {125-    const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);126-    const [session, peer] = inMemoryPair();127-    const vault = makeVaultAdapter({ localVersion: 5 });128-129-    const initPromise = runChirpSyncInitiator(session, vault);130-131-    await peer.receive(); // SYNC_HELLO132-    // Peer reports a deviceTime 200 s in the past — beyond the 120 s threshold.133-    await peer.send({134-      type: "SYNC_HELLO",135-      vaultId: "vault-abc",136-      deviceId: "p",137-      version: 3, // different version so exchange proceeds138-      deviceTime: Date.now() - 200_000,139-    });140-    // Yield so the coordinator's async continuation can run the clock-skew check.141-    await new Promise((r) => setTimeout(r, 0));142-    expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("clock skew"));143-144-    // Drive the sync to completion so the test doesn't leak.145-    await peer.receive(); // SYNC_MANIFEST146-    await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });147-    await peer.receive(); // SYNC_REQUEST148-    await peer.send({ type: "SYNC_REQUEST", entryIds: [] });149-    await peer.receive(); // SYNC_ENTRIES150-    await peer.receive(); // SYNC_TOMBSTONES151-    await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });152-    await peer.receive(); // SYNC_DONE153-    await peer.send({ type: "SYNC_DONE" });154-155-    const outcome = await initPromise;156-    expect(outcome.status).toBe("completed");157-    warnSpy.mockRestore();158-  });159-});160-161-describe("runChirpSyncInitiator", () => {162-  it("completes a full sync with bi-directional updates", async () => {163-    const [session, peer] = inMemoryPair();164-    const vault = makeVaultAdapter({165-      entries: { a: 100, b: 50 }, // we have 'a' at t=100, 'b' at t=50166-      tombstones: { x: 70 },167-      ciphertext: { a: "ct-a", b: "ct-b" },168-    });169-170-    const initPromise = runChirpSyncInitiator(session, vault);171-172-    const peerProtocol = (async () => {173-      const hello = await peer.receive();174-      expect(hello.type).toBe("SYNC_HELLO");175-      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "device-peer", version: 3, deviceTime: Date.now() });176-177-      const ourManifest = await peer.receive();178-      expect(ourManifest.type).toBe("SYNC_MANIFEST");179-      await peer.send({180-        type: "SYNC_MANIFEST",181-        entries: { a: 80, b: 90, c: 200 }, // we have newer 'a'; peer has newer 'b' and 'c'182-        tombstones: { x: 60, y: 99 },        // peer has newer tombstone for 'y'183-      });184-185-      const ourRequest = await peer.receive();186-      expect(ourRequest.type).toBe("SYNC_REQUEST");187-      // We should request the entries the peer reported newer188-      if (ourRequest.type === "SYNC_REQUEST") {189-        expect(ourRequest.entryIds.sort()).toEqual(["b", "c"]);190-      }191-      // Peer asks us for entries the peer claimed it had older versions of192-      await peer.send({ type: "SYNC_REQUEST", entryIds: ["a"] });193-194-      const ourEntries = await peer.receive();195-      expect(ourEntries.type).toBe("SYNC_ENTRIES");196-      if (ourEntries.type === "SYNC_ENTRIES") {197-        expect(ourEntries.entries).toEqual({ a: "ct-a" });198-      }199-      await peer.send({ type: "SYNC_ENTRIES", entries: { b: "ct-b-new", c: "ct-c" } });200-201-      const ourTombs = await peer.receive();202-      expect(ourTombs.type).toBe("SYNC_TOMBSTONES");203-      if (ourTombs.type === "SYNC_TOMBSTONES") {204-        // We had x@70, peer had x@60, so we ship x@70205-        expect(ourTombs.tombstones).toEqual({ x: 70 });206-      }207-      await peer.send({ type: "SYNC_TOMBSTONES", tombstones: { y: 99 } });208-209-      const ourDone = await peer.receive();210-      expect(ourDone.type).toBe("SYNC_DONE");211-      await peer.send({ type: "SYNC_DONE" });212-    })();213-214-    const outcome = await initPromise;215-    await peerProtocol;216-217-    expect(outcome.status).toBe("completed");218-    expect(outcome.entriesSent).toBe(1);219-    expect(outcome.entriesReceived).toBe(2);220-    expect(outcome.tombstonesSent).toBe(1);221-    expect(outcome.tombstonesReceived).toBe(1);222-    expect(vault.appliedEntries).toEqual([{ b: "ct-b-new", c: "ct-c" }]);223-    expect(vault.appliedTombstones).toEqual([{ y: 99 }]);224-  });225-226-  it("aborts with vault_mismatch on a peer with a different vault_id", async () => {227-    const [session, peer] = inMemoryPair();228-    const vault = makeVaultAdapter({ vaultId: "vault-A" });229-230-    const initPromise = runChirpSyncInitiator(session, vault);231-    const peerProtocol = (async () => {232-      const hello = await peer.receive();233-      expect(hello.type).toBe("SYNC_HELLO");234-      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-B", deviceId: "evil", version: 3, deviceTime: Date.now() });235-      // Peer should receive a SYNC_ERROR and we should stop.236-      const err = await peer.receive();237-      expect(err.type).toBe("SYNC_ERROR");238-    })();239-240-    const outcome = await initPromise;241-    await peerProtocol;242-    expect(outcome.status).toBe("vault_mismatch");243-  });244-245-  it("exchanges device manifests when both adapters opt in", async () => {246-    const [session, peer] = inMemoryPair();247-    const ourKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x11), "us");248-    const peerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x22), "them");249-250-    const outgoing = await buildSignedDeviceManifest("us", [], ourKp.privateKey);251-    const incomingFromPeer = await buildSignedDeviceManifest("them", [], peerKp.privateKey);252-    const applied: SignedDeviceManifest[] = [];253-254-    const base = makeVaultAdapter({});255-    const vault: ChirpSyncVaultAdapter = {256-      ...base,257-      async buildOutgoingDeviceManifest() { return outgoing; },258-      async applyIncomingDeviceManifest(m) { applied.push(m); },259-    };260-261-    const initPromise = runChirpSyncInitiator(session, vault);262-    const peerProtocol = (async () => {263-      await peer.receive(); // hello264-      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now() });265-      await peer.receive(); // manifest266-      await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });267-      await peer.receive(); // request268-      await peer.send({ type: "SYNC_REQUEST", entryIds: [] });269-      await peer.receive(); // entries270-      await peer.receive(); // tombstones271-      await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });272-      const ourManifest = await peer.receive();273-      expect(ourManifest.type).toBe("SYNC_DEVICE_MANIFEST");274-      if (ourManifest.type === "SYNC_DEVICE_MANIFEST") {275-        expect(ourManifest.manifest.body.issuerDeviceId).toBe("us");276-      }277-      await peer.send({ type: "SYNC_DEVICE_MANIFEST", manifest: incomingFromPeer });278-      await peer.receive(); // done279-      await peer.send({ type: "SYNC_DONE" });280-    })();281-282-    const outcome = await initPromise;283-    await peerProtocol;284-    expect(outcome.status).toBe("completed");285-    expect(applied).toHaveLength(1);286-    expect(applied[0].body.issuerDeviceId).toBe("them");287-  });288-289-  it("returns errored when peer sends a wrong frame", async () => {290-    const [session, peer] = inMemoryPair();291-    const vault = makeVaultAdapter({ vaultId: "v" });292-293-    const initPromise = runChirpSyncInitiator(session, vault);294-    void (async () => {295-      await peer.receive(); // hello296-      await peer.send({ type: "SYNC_HELLO", vaultId: "v", deviceId: "p", version: 3, deviceTime: Date.now() });297-      await peer.receive(); // our manifest298-      await peer.send({ type: "SYNC_DONE" }); // wrong frame299-    })();300-301-    const outcome = await initPromise;302-    expect(outcome.status).toBe("errored");303-    expect(outcome.error).toMatch(/SYNC_MANIFEST/);304-  });305-});

view file at the parent

src/background/chirp-sync-coordinator.ts+0 −295
@@ -1,295 +0,0 @@1-// Chirp Sync — Phase 2 manifest-diff coordinator.2-//3-// After Noise XX is open and the responder's static key has been verified4-// against the paired-device registry, this coordinator drives the5-// manifest exchange:6-//7-//   ->  SYNC_HELLO { vaultId, deviceId }8-//   <-  SYNC_HELLO { vaultId, deviceId }       (mismatch → SYNC_ERROR)9-//   <-> SYNC_MANIFEST { entries, tombstones }  (each side sends one)10-//   ->  SYNC_REQUEST { entryIds }              (entries newer on the peer)11-//   <-  SYNC_ENTRIES { entries }12-//   <-  SYNC_REQUEST { entryIds }              (entries newer on us)13-//   ->  SYNC_ENTRIES { entries }14-//   <-> SYNC_TOMBSTONES { tombstones }15-//   <-> SYNC_DONE16-//17-// Entries are opaque base64 ciphertext — the coordinator never decrypts.18-// Last-write-wins at the entry level by mtime; tombstones override updates19-// with a later deletion timestamp.20-21-import type { ChirpSyncMessage, SignedDeviceManifest } from "../shared/sync-types";22-import type { ChirpSyncSession } from "../shared/chirp-sync-transport";23-import { SYNC_LIMITS } from "../shared/sync-limits";24-25-const SESSION_TIMEOUT_MS = 30_000;26-27-// Sentinel signature used when an adapter can't build a real manifest (e.g.28-// the master key isn't accessible from the coordinator context). The peer29-// recognises this exact value and skips signature verification. 64 hex30-// chars of zero is impossible to produce as a real Ed25519 signature for a31-// fresh body, so collisions are negligible.32-const EMPTY_MANIFEST_PLACEHOLDER: SignedDeviceManifest = {33-  body: { manifestVersion: 1, issuerDeviceId: "", issuedAt: 0, devices: [] },34-  signatureHex: "0".repeat(128),35-};36-37-/**38- * Vault-side adapter — the coordinator's dependency on the vault layer.39- * The vault holds the canonical entry/tombstone tables; the coordinator40- * pulls a manifest, requests entries by id, applies remote entries, and41- * applies remote tombstones.42- */43-export interface ChirpSyncVaultAdapter {44-  computeVaultId(): Promise<string>;45-  getOurDeviceId(): Promise<string>;46-  /** Returns the vault's monotonic version (`peach_last_modified_at`). */47-  getLocalVersion(): Promise<number>;48-  /** Returns the per-entry mtimes for our active entries. */49-  buildEntryManifest(): Promise<Record<string, number>>;50-  /** Returns the per-entry deletion timestamps for our tombstones. */51-  buildTombstoneManifest(): Promise<Record<string, number>>;52-  /** Returns base64 ciphertext for each requested entry id. */53-  getEncryptedEntries(entryIds: string[]): Promise<Record<string, string>>;54-  /** Apply incoming encrypted entries; vault decides per-entry overwrite. */55-  applyEntries(entries: Record<string, string>): Promise<void>;56-  /** Apply incoming tombstones; vault retains the latest deletion ts. */57-  applyTombstones(tombstones: Record<string, number>): Promise<void>;58-  /**59-   * Optional — v2 brief §4.3 device manifest exchange. The extension (as60-   * the trust anchor for the device graph) returns a signed manifest61-   * enumerating every paired device it knows about; the peer verifies it62-   * against the extension's known Ed25519 signing pubkey and upserts any63-   * newly-vouched devices into its own registry.64-   *65-   * When this method is undefined, the coordinator skips the manifest66-   * step entirely. Both ends must opt in for the exchange to occur.67-   */68-  buildOutgoingDeviceManifest?(): Promise<SignedDeviceManifest | null>;69-  /** v2 brief §4.3 — handle a manifest received from the peer. */70-  applyIncomingDeviceManifest?(manifest: SignedDeviceManifest): Promise<void>;71-}72-73-export interface SyncOutcome {74-  status: "completed" | "vault_mismatch" | "errored" | "timeout";75-  entriesReceived: number;76-  entriesSent: number;77-  tombstonesReceived: number;78-  tombstonesSent: number;79-  error?: string;80-}81-82-/**83- * Run one sync round as the initiator. The extension is always the84- * initiator (it is the WebSocket client). The peer's role is symmetric85- * after SYNC_HELLO, so a corresponding responder helper exists for the86- * mobile-side coordinator that calls this same protocol the other way.87- */88-export async function runChirpSyncInitiator(89-  session: ChirpSyncSession,90-  vault: ChirpSyncVaultAdapter,91-): Promise<SyncOutcome> {92-  const result: SyncOutcome = {93-    status: "errored",94-    entriesReceived: 0,95-    entriesSent: 0,96-    tombstonesReceived: 0,97-    tombstonesSent: 0,98-  };99-100-  const timer = scheduleTimeout(SESSION_TIMEOUT_MS);101-  try {102-    const ourVaultId = await vault.computeVaultId();103-    const ourDeviceId = await vault.getOurDeviceId();104-    const localVersion = await vault.getLocalVersion();105-    const localTime = Date.now();106-107-    await sendMessage(session, {108-      type: "SYNC_HELLO",109-      vaultId: ourVaultId,110-      deviceId: ourDeviceId,111-      version: localVersion,112-      deviceTime: localTime,113-    });114-    const helloIn = await receiveMessage(session, timer);115-    if (helloIn.type !== "SYNC_HELLO") {116-      return errorOutcome(result, `expected SYNC_HELLO, got ${helloIn.type}`);117-    }118-    if (helloIn.vaultId !== ourVaultId) {119-      await sendMessage(session, { type: "SYNC_ERROR", error: "vault_id_mismatch" });120-      result.status = "vault_mismatch";121-      return result;122-    }123-124-    if (Math.abs(localTime - (helloIn.deviceTime ?? 0)) > 120_000) {125-      console.warn(`[chirp-sync] clock skew detected: local=${localTime} remote=${helloIn.deviceTime ?? "unknown"}`);126-    }127-128-    // Equal versions — nothing to exchange.129-    if (typeof helloIn.version === "number" && helloIn.version === localVersion) {130-      await sendMessage(session, { type: "SYNC_DONE", entriesReceived: 0, entriesSent: 0 });131-      const done = await receiveMessage(session, timer);132-      if (done.type !== "SYNC_DONE") {133-        return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);134-      }135-      result.status = "completed";136-      return result;137-    }138-139-    const ourEntries = await vault.buildEntryManifest();140-    const ourTombstones = await vault.buildTombstoneManifest();141-    await sendMessage(session, {142-      type: "SYNC_MANIFEST",143-      entries: ourEntries,144-      tombstones: ourTombstones,145-    });146-    const manifestIn = await receiveMessage(session, timer);147-    if (manifestIn.type !== "SYNC_MANIFEST") {148-      return errorOutcome(result, `expected SYNC_MANIFEST, got ${manifestIn.type}`);149-    }150-151-    const wantFromPeer = diffNewer(manifestIn.entries, ourEntries);152-    await sendMessage(session, { type: "SYNC_REQUEST", entryIds: wantFromPeer });153-154-    const requestIn = await receiveMessage(session, timer);155-    if (requestIn.type !== "SYNC_REQUEST") {156-      return errorOutcome(result, `expected SYNC_REQUEST, got ${requestIn.type}`);157-    }158-    // Sanity: the peer's request must be a subset of what its manifest159-    // declared as newer. A malformed/over-eager request is a protocol160-    // violation but not a data-safety problem — we just send what they ask.161-162-    const ourPayload = await vault.getEncryptedEntries(requestIn.entryIds);163-    await sendMessage(session, { type: "SYNC_ENTRIES", entries: ourPayload });164-    result.entriesSent = Object.keys(ourPayload).length;165-166-    if (wantFromPeer.length > 0) {167-      const entriesIn = await receiveMessage(session, timer);168-      if (entriesIn.type !== "SYNC_ENTRIES") {169-        return errorOutcome(result, `expected SYNC_ENTRIES, got ${entriesIn.type}`);170-      }171-      await vault.applyEntries(entriesIn.entries);172-      result.entriesReceived = Object.keys(entriesIn.entries).length;173-    }174-175-    // Tombstones: each side ships the tombstones that the other does not176-    // yet have a newer record for.177-    const newerTombstonesForPeer: Record<string, number> = {};178-    for (const [id, ts] of Object.entries(ourTombstones)) {179-      const peerTs = manifestIn.tombstones[id] ?? 0;180-      if (ts > peerTs) newerTombstonesForPeer[id] = ts;181-    }182-    await sendMessage(session, { type: "SYNC_TOMBSTONES", tombstones: newerTombstonesForPeer });183-    result.tombstonesSent = Object.keys(newerTombstonesForPeer).length;184-185-    const tombstonesIn = await receiveMessage(session, timer);186-    if (tombstonesIn.type !== "SYNC_TOMBSTONES") {187-      return errorOutcome(result, `expected SYNC_TOMBSTONES, got ${tombstonesIn.type}`);188-    }189-    await vault.applyTombstones(tombstonesIn.tombstones);190-    result.tombstonesReceived = Object.keys(tombstonesIn.tombstones).length;191-192-    // v2 brief §5.6 / §4.3 — device manifest exchange. Optional: both ends193-    // must expose the adapter methods. Disabling on either side cleanly194-    // bypasses the step. The exchange is symmetric on the wire; the195-    // production adapter still treats the extension as the trust anchor and196-    // only applies extension-issued manifests.197-    if (vault.buildOutgoingDeviceManifest && vault.applyIncomingDeviceManifest) {198-      const ours = await vault.buildOutgoingDeviceManifest();199-      if (ours) {200-        await sendMessage(session, { type: "SYNC_DEVICE_MANIFEST", manifest: ours });201-      } else {202-        // No manifest to send (e.g. master key unavailable) — still emit a203-        // protocol frame so the peer's recv doesn't hang. Empty body is204-        // serialised by the adapter when present; if it returned null we205-        // synthesise a no-op shape the receiver can ignore.206-        await sendMessage(session, {207-          type: "SYNC_DEVICE_MANIFEST",208-          manifest: EMPTY_MANIFEST_PLACEHOLDER,209-        });210-      }211-      const manifestIn = await receiveMessage(session, timer);212-      if (manifestIn.type !== "SYNC_DEVICE_MANIFEST") {213-        return errorOutcome(result, `expected SYNC_DEVICE_MANIFEST, got ${manifestIn.type}`);214-      }215-      if (manifestIn.manifest && manifestIn.manifest.signatureHex !== EMPTY_MANIFEST_PLACEHOLDER.signatureHex) {216-        await vault.applyIncomingDeviceManifest(manifestIn.manifest);217-      }218-    }219-220-    await sendMessage(session, {221-      type: "SYNC_DONE",222-      entriesReceived: result.entriesReceived,223-      entriesSent: result.entriesSent,224-    });225-    const done = await receiveMessage(session, timer);226-    if (done.type !== "SYNC_DONE") {227-      return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);228-    }229-    result.status = "completed";230-    return result;231-  } catch (err) {232-    if ((err as Error).message === "chirp_sync_timeout") {233-      result.status = "timeout";234-      result.error = "session timed out after 30s";235-      return result;236-    }237-    return errorOutcome(result, (err as Error).message);238-  } finally {239-    // Session lifetime is owned by the runner (chirp-sync-runner.ts), which240-    // closes it in its own finally block. The coordinator must not close it241-    // here — the runner still needs to record the outcome after we return.242-    timer.cancel();243-  }244-}245-246-function diffNewer(remote: Record<string, number>, local: Record<string, number>): string[] {247-  const want: string[] = [];248-  for (const [id, ts] of Object.entries(remote)) {249-    const ours = local[id];250-    if (ours === undefined || ts > ours) want.push(id);251-  }252-  return want;253-}254-255-interface Timer {256-  signal: AbortSignal;257-  cancel(): void;258-}259-260-function scheduleTimeout(ms: number): Timer {261-  const ctrl = new AbortController();262-  const t = setTimeout(() => ctrl.abort(new Error("chirp_sync_timeout")), ms);263-  return {264-    signal: ctrl.signal,265-    cancel: () => clearTimeout(t),266-  };267-}268-269-async function sendMessage(session: ChirpSyncSession, msg: ChirpSyncMessage): Promise<void> {270-  session.send(new TextEncoder().encode(JSON.stringify(msg)));271-}272-273-async function receiveMessage(session: ChirpSyncSession, timer: Timer): Promise<ChirpSyncMessage> {274-  const pending = session.receive();275-  const aborted = new Promise<never>((_, reject) => {276-    if (timer.signal.aborted) reject(new Error("chirp_sync_timeout"));277-    timer.signal.addEventListener("abort", () => reject(new Error("chirp_sync_timeout")));278-  });279-  const bytes = await Promise.race([pending, aborted]);280-  // MV-3: decrypted plaintext can exceed the wire frame (and a non-WS281-  // transport may not have enforced anything) — re-check before JSON.parse282-  // materialises a multi-GB string/object graph.283-  if (bytes.length > SYNC_LIMITS.MAX_FRAME_BYTES) {284-    throw new Error(`sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes)`);285-  }286-  const parsed = JSON.parse(new TextDecoder().decode(bytes));287-  if (typeof parsed?.type !== "string") throw new Error("malformed SyncMessage");288-  return parsed as ChirpSyncMessage;289-}290-291-function errorOutcome(outcome: SyncOutcome, error: string): SyncOutcome {292-  outcome.status = "errored";293-  outcome.error = error;294-  return outcome;295-}

view file at the parent

src/background/chirp-sync-runner.test.ts+0 −415
@@ -1,415 +0,0 @@1-import { beforeEach, describe, expect, it, vi } from "vitest";2-import type { PairedDevice, ChirpSyncMessage } from "../shared/sync-types";3-import type {4-  ChirpSyncSession,5-  SyncEndpoint,6-} from "../shared/chirp-sync-transport";7-import type { ChirpSyncVaultAdapter } from "./chirp-sync-coordinator";8-import type { SyncRunnerDeps } from "./chirp-sync-runner";9-10-type StorageShape = Record<string, unknown>;11-12-function makeChromeMock(local: StorageShape, sync: StorageShape): typeof chrome {13-  const area = (store: StorageShape) => ({14-    get: vi.fn(async (key?: string | string[]) => {15-      if (!key) return store;16-      if (typeof key === "string") return { [key]: store[key] };17-      return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = store[k]; return acc; }, {});18-    }),19-    set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),20-    remove: vi.fn(async (keys: string | string[]) => {21-      const list = Array.isArray(keys) ? keys : [keys];22-      for (const k of list) delete store[k];23-    }),24-  });25-  return {26-    storage: { local: area(local), sync: area(sync) },27-    alarms: {28-      clear: vi.fn(),29-      create: vi.fn(),30-      onAlarm: { addListener: vi.fn() },31-    },32-    idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },33-    runtime: { sendMessage: vi.fn(async () => undefined) },34-  } as unknown as typeof chrome;35-}36-37-const REMOTE_PUBKEY_HEX = "ab".repeat(32);38-39-function makeDevice(overrides: Partial<PairedDevice> = {}): PairedDevice {40-  return {41-    deviceId: "pixel-xyz",42-    deviceName: "Pixel",43-    deviceType: "android",44-    longTermPublicKeyHex: REMOTE_PUBKEY_HEX,45-    wsPort: 8765,46-    pairedAt: 1700000000000,47-    ...overrides,48-  };49-}50-51-interface FakePeer {52-  send(msg: ChirpSyncMessage): Promise<void>;53-  receive(): Promise<ChirpSyncMessage>;54-}55-56-/**57- * In-memory ChirpSyncSession ↔ FakePeer pair. The session is what the58- * runner passes to runChirpSyncInitiator; the peer is the other end of the59- * wire, controlled by the test body. Mirrors the pattern in60- * chirp-sync-coordinator.test.ts.61- */62-function inMemoryPair(): { session: ChirpSyncSession; peer: FakePeer; closed: { v: boolean } } {63-  const inboundToSession: Uint8Array[] = [];64-  const outboundFromSession: Uint8Array[] = [];65-  const sessionReaders: Array<(v: Uint8Array) => void> = [];66-  const peerReaders: Array<(v: Uint8Array) => void> = [];67-  const closed = { v: false };68-69-  const session: ChirpSyncSession = {70-    send(bytes) {71-      const r = peerReaders.shift();72-      if (r) r(bytes); else outboundFromSession.push(bytes);73-    },74-    receive(): Promise<Uint8Array> {75-      const v = inboundToSession.shift();76-      if (v) return Promise.resolve(v);77-      return new Promise<Uint8Array>((res) => sessionReaders.push(res));78-    },79-    async close() { closed.v = true; },80-    remoteStaticPublicKey: new Uint8Array(32),81-  };82-  const peer: FakePeer = {83-    async send(msg) {84-      const bytes = new TextEncoder().encode(JSON.stringify(msg));85-      const r = sessionReaders.shift();86-      if (r) r(bytes); else inboundToSession.push(bytes);87-    },88-    async receive() {89-      const v = outboundFromSession.shift();90-      const bytes = v ?? await new Promise<Uint8Array>((res) => peerReaders.push(res));91-      return JSON.parse(new TextDecoder().decode(bytes)) as ChirpSyncMessage;92-    },93-  };94-  return { session, peer, closed };95-}96-97-/**98- * Drive a happy-path SYNC_DONE handshake on the peer side, matching the99- * coordinator's HELLO → MANIFEST → REQUEST → ENTRIES → TOMBSTONES → DONE100- * sequence. `peerEntries` lets the peer send a SYNC_ENTRIES payload of any101- * size for the entriesReceived assertion; `wantFromUs` requests N entries102- * from our side (drives entriesSent).103- */104-async function runPeer(105-  peer: FakePeer,106-  opts: {107-    vaultId?: string;108-    peerEntriesB64?: Record<string, string>;109-    wantFromUsIds?: string[];110-  } = {},111-): Promise<void> {112-  const hello = await peer.receive();113-  if (hello.type !== "SYNC_HELLO") throw new Error("expected HELLO from initiator");114-  await peer.send({115-    type: "SYNC_HELLO",116-    vaultId: opts.vaultId ?? hello.vaultId,117-    deviceId: "peer-device",118-    version: 0,119-    deviceTime: Date.now(),120-  });121-  const manifest = await peer.receive();122-  if (manifest.type !== "SYNC_MANIFEST") throw new Error("expected MANIFEST");123-124-  // Peer manifest declares the entries it will ship in SYNC_ENTRIES.125-  const peerEntryManifest: Record<string, number> = {};126-  for (const id of Object.keys(opts.peerEntriesB64 ?? {})) {127-    peerEntryManifest[id] = Date.now();128-  }129-  await peer.send({130-    type: "SYNC_MANIFEST",131-    entries: peerEntryManifest,132-    tombstones: {},133-  });134-135-  const ourRequest = await peer.receive();136-  if (ourRequest.type !== "SYNC_REQUEST") throw new Error("expected REQUEST from initiator");137-138-  // Peer asks for the ids it wants from us.139-  await peer.send({ type: "SYNC_REQUEST", entryIds: opts.wantFromUsIds ?? [] });140-141-  const ourEntries = await peer.receive();142-  if (ourEntries.type !== "SYNC_ENTRIES") throw new Error("expected ENTRIES from initiator");143-144-  // Only ship SYNC_ENTRIES when the initiator actually requested any. The145-  // coordinator only awaits this frame when wantFromPeer.length > 0.146-  if (Object.keys(peerEntryManifest).length > 0) {147-    await peer.send({ type: "SYNC_ENTRIES", entries: opts.peerEntriesB64 ?? {} });148-  }149-150-  const ourTombstones = await peer.receive();151-  if (ourTombstones.type !== "SYNC_TOMBSTONES") throw new Error("expected TOMBSTONES");152-  await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });153-154-  const done = await peer.receive();155-  if (done.type !== "SYNC_DONE") throw new Error("expected DONE");156-  await peer.send({ type: "SYNC_DONE" });157-}158-159-function emptyAdapter(): ChirpSyncVaultAdapter {160-  return {161-    async computeVaultId() { return "vault-abc"; },162-    async getOurDeviceId() { return "device-init"; },163-    // Non-zero so the equal-version short circuit doesn't fire in runner tests164-    // that exercise the full manifest-diff flow.165-    async getLocalVersion() { return 5; },166-    async buildEntryManifest() { return {}; },167-    async buildTombstoneManifest() { return {}; },168-    async getEncryptedEntries() { return {}; },169-    async applyEntries() { /* no-op */ },170-    async applyTombstones() { /* no-op */ },171-  };172-}173-174-describe("productionSyncRunner", () => {175-  let local: StorageShape;176-  let sync: StorageShape;177-178-  beforeEach(() => {179-    vi.resetModules();180-    local = {};181-    sync = {};182-    (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);183-    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));184-  });185-186-  async function bootVault(): Promise<typeof import("./vault")> {187-    const vault = await import("./vault");188-    await vault.createVault("correct horse battery staple");189-    return vault;190-  }191-192-  it("returns VAULT_LOCKED result (does not throw) when the vault is locked", async () => {193-    const vault = await bootVault();194-    vault.lock();195-    const { runWithDeps } = await import("./chirp-sync-runner");196-    const deps: SyncRunnerDeps = {197-      openSession: vi.fn(),198-      probeSubnet: vi.fn(),199-      getSubnet: vi.fn(async () => "192.168.1"),200-      createAdapter: emptyAdapter,201-    };202-    const result = await runWithDeps(makeDevice(), deps);203-    expect(result).toEqual({ ok: false, error: "VAULT_LOCKED" });204-    expect(deps.openSession).not.toHaveBeenCalled();205-  });206-207-  it("throws when the device has no longTermPublicKeyHex", async () => {208-    await bootVault();209-    const { runWithDeps } = await import("./chirp-sync-runner");210-    const deps: SyncRunnerDeps = {211-      openSession: vi.fn(),212-      probeSubnet: vi.fn(),213-      getSubnet: vi.fn(),214-      createAdapter: emptyAdapter,215-    };216-    await expect(217-      runWithDeps(makeDevice({ longTermPublicKeyHex: undefined }), deps),218-    ).rejects.toThrow("paired_device_missing_pubkey");219-  });220-221-  it("uses the cached last-known IP first and skips the subnet probe", async () => {222-    await bootVault();223-    const { setLastKnownIp } = await import("./paired-devices");224-    await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.42");225-226-    const { session, peer } = inMemoryPair();227-    const openSession = vi.fn(async (_e: SyncEndpoint) => session);228-    const probeSubnet = vi.fn();229-    const deps: SyncRunnerDeps = {230-      openSession,231-      probeSubnet,232-      getSubnet: vi.fn(async () => "192.168.1"),233-      createAdapter: emptyAdapter,234-    };235-236-    const { runWithDeps } = await import("./chirp-sync-runner");237-    const runPromise = runWithDeps(makeDevice(), deps);238-    await runPeer(peer);239-    const result = await runPromise;240-    expect(result).toEqual({ ok: true, entriesReceived: 0, entriesSent: 0 });241-    expect(openSession).toHaveBeenCalledTimes(1);242-    expect(openSession.mock.calls[0][0]).toEqual({ ip: "192.168.1.42", port: 8765 });243-    expect(probeSubnet).not.toHaveBeenCalled();244-  });245-246-  it("falls back to subnet probe when cached IP fails to connect", async () => {247-    await bootVault();248-    const { setLastKnownIp, getLastKnownIp } = await import("./paired-devices");249-    await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.13");250-251-    const { session, peer } = inMemoryPair();252-    const openSession = vi.fn()253-      .mockRejectedValueOnce(new Error("ECONNREFUSED"))254-      .mockResolvedValueOnce(session);255-    const probeSubnet = vi.fn(async () => ({ ip: "192.168.1.99", port: 8765 }));256-    const deps: SyncRunnerDeps = {257-      openSession,258-      probeSubnet,259-      getSubnet: vi.fn(async () => "192.168.1"),260-      createAdapter: emptyAdapter,261-    };262-263-    const { runWithDeps } = await import("./chirp-sync-runner");264-    const runPromise = runWithDeps(makeDevice(), deps);265-    await runPeer(peer);266-    await runPromise;267-    expect(openSession).toHaveBeenCalledTimes(2);268-    expect(openSession.mock.calls[1][0]).toEqual({ ip: "192.168.1.99", port: 8765 });269-    expect(probeSubnet).toHaveBeenCalledTimes(1);270-    expect(await getLastKnownIp(REMOTE_PUBKEY_HEX)).toBe("192.168.1.99");271-  });272-273-  it("throws host_unreachable when cached IP fails AND subnet probe returns null", async () => {274-    await bootVault();275-    const { setLastKnownIp } = await import("./paired-devices");276-    await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.13");277-278-    const deps: SyncRunnerDeps = {279-      openSession: vi.fn().mockRejectedValue(new Error("ECONNREFUSED")),280-      probeSubnet: vi.fn(async () => null),281-      getSubnet: vi.fn(async () => "192.168.1"),282-      createAdapter: emptyAdapter,283-    };284-    const { runWithDeps } = await import("./chirp-sync-runner");285-    await expect(runWithDeps(makeDevice(), deps)).rejects.toThrow("host_unreachable");286-  });287-288-  it("throws subnet_unknown when no cached IP and the local subnet can't be determined", async () => {289-    await bootVault();290-    const deps: SyncRunnerDeps = {291-      openSession: vi.fn(),292-      probeSubnet: vi.fn(),293-      getSubnet: vi.fn(async () => null),294-      createAdapter: emptyAdapter,295-    };296-    const { runWithDeps } = await import("./chirp-sync-runner");297-    await expect(runWithDeps(makeDevice(), deps)).rejects.toThrow("subnet_unknown");298-  });299-300-  it("surfaces entriesReceived from the peer's SYNC_ENTRIES payload", async () => {301-    await bootVault();302-    const { session, peer } = inMemoryPair();303-    const openSession = vi.fn(async () => session);304-    const deps: SyncRunnerDeps = {305-      openSession,306-      probeSubnet: vi.fn(async () => ({ ip: "192.168.1.1", port: 8765 })),307-      getSubnet: vi.fn(async () => "192.168.1"),308-      createAdapter: emptyAdapter,309-    };310-    const { runWithDeps } = await import("./chirp-sync-runner");311-    const runPromise = runWithDeps(makeDevice(), deps);312-    await runPeer(peer, {313-      peerEntriesB64: { "id-1": "AAAA", "id-2": "BBBB", "id-3": "CCCC" },314-    });315-    const result = await runPromise;316-    expect(result.ok).toBe(true);317-    if (result.ok) {318-      expect(result.entriesReceived).toBe(3);319-      expect(result.entriesSent).toBe(0);320-    }321-  });322-323-  it("surfaces entriesSent from the adapter's encrypted-entry response", async () => {324-    await bootVault();325-    const { session, peer } = inMemoryPair();326-    const adapter: ChirpSyncVaultAdapter = {327-      ...emptyAdapter(),328-      async getEncryptedEntries(ids) {329-        const out: Record<string, string> = {};330-        for (const id of ids) out[id] = "ENVELOPE_" + id;331-        return out;332-      },333-    };334-    const deps: SyncRunnerDeps = {335-      openSession: vi.fn(async () => session),336-      probeSubnet: vi.fn(async () => ({ ip: "192.168.1.1", port: 8765 })),337-      getSubnet: vi.fn(async () => "192.168.1"),338-      createAdapter: () => adapter,339-    };340-    const { runWithDeps } = await import("./chirp-sync-runner");341-    const runPromise = runWithDeps(makeDevice(), deps);342-    await runPeer(peer, { wantFromUsIds: ["a", "b"] });343-    const result = await runPromise;344-    expect(result.ok).toBe(true);345-    if (result.ok) expect(result.entriesSent).toBe(2);346-  });347-348-  it("translates a vault_id_mismatch coordinator outcome into an error throw", async () => {349-    await bootVault();350-    const { session, peer } = inMemoryPair();351-    const deps: SyncRunnerDeps = {352-      openSession: vi.fn(async () => session),353-      probeSubnet: vi.fn(async () => ({ ip: "192.168.1.1", port: 8765 })),354-      getSubnet: vi.fn(async () => "192.168.1"),355-      createAdapter: emptyAdapter,356-    };357-    const { runWithDeps } = await import("./chirp-sync-runner");358-    const runPromise = runWithDeps(makeDevice(), deps);359-    // Peer responds with a vault_id that does NOT match ours, forcing the360-    // coordinator to send SYNC_ERROR and return status:"vault_mismatch".361-    (async () => {362-      const hello = await peer.receive();363-      if (hello.type === "SYNC_HELLO") {364-        await peer.send({365-          type: "SYNC_HELLO",366-          vaultId: "wrong-vault-id",367-          deviceId: "peer-device",368-          version: 0,369-          deviceTime: Date.now(),370-        });371-      }372-    })().catch(() => undefined);373-    await expect(runPromise).rejects.toThrow("vault_id_mismatch");374-  });375-});376-377-describe("sync-trigger persistence", () => {378-  let local: StorageShape;379-  let sync: StorageShape;380-381-  beforeEach(() => {382-    vi.resetModules();383-    local = {};384-    sync = {};385-    (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);386-    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));387-  });388-389-  it("persists lastSyncAt to chrome.storage.local on a successful sync", async () => {390-    const trigger = await import("./sync-trigger");391-    const paired = await import("./paired-devices");392-    trigger._resetSyncTriggerForTest();393-    await paired.upsertPairedDevice({394-      deviceId: "P",395-      deviceName: "Pixel",396-      deviceType: "android",397-      longTermPublicKeyHex: "a".repeat(64),398-      trustSource: "direct",399-      pairedAt: 1700000000000,400-    });401-    trigger.setSyncRunner(async () => ({ ok: true, entriesReceived: 1, entriesSent: 0 }));402-    await trigger.triggerSyncIfReachable({ reason: "manual" });403-    expect(typeof local.peach_last_sync_at).toBe("number");404-    expect(local.peach_last_sync_at).toBeGreaterThan(0);405-  });406-407-  it("hydratePersistedSyncState fills lastSyncAt from storage when in-memory state is empty", async () => {408-    local.peach_last_sync_at = 1_700_005_000_000;409-    const trigger = await import("./sync-trigger");410-    trigger._resetSyncTriggerForTest();411-    expect(trigger.getSyncState().lastSyncAt).toBeUndefined();412-    await trigger.hydratePersistedSyncState();413-    expect(trigger.getSyncState().lastSyncAt).toBe(1_700_005_000_000);414-  });415-});

view file at the parent

src/background/chirp-sync-runner.ts+0 −181
@@ -1,181 +0,0 @@1-// Production sync runner — composes the live vault adapter, WebSocket2-// transport, and coordinator into the function the dispatcher injects via3-// `setSyncRunner`. The runner is responsible for opening one Noise XX4-// session against a single paired device, driving the sync protocol, and5-// surfacing a `SyncRunResult` (or throwing on failure). The dispatcher in6-// sync-trigger.ts iterates over hosts and aggregates results.7-//8-// Connection strategy:9-//   1. Try `getLastKnownIp(deviceLongTermPubkey)` if cached — fast path.10-//   2. Fall back to `probeSubnetForPeer` over the local /24. The subnet11-//      probe is itself rate-limited (60s per-pubkey cooldown) inside the12-//      transport, so we don't add another layer here.13-//   3. On a successful session, persist the IP we connected on so the next14-//      sync round skips the probe.15-//16-// We do NOT verify vault_id here — the coordinator's SYNC_HELLO exchange17-// does that (returns `status: "vault_mismatch"` if the peer holds a18-// different vault). The runner just translates the coordinator's outcome19-// into the throw-or-return contract the dispatcher expects.20-21-import { runChirpSyncInitiator, type ChirpSyncVaultAdapter } from "./chirp-sync-coordinator";22-import { createChirpSyncVaultAdapter } from "./chirp-sync-vault-adapter";23-import {24-  getLastKnownIp,25-  setLastKnownIp,26-} from "./paired-devices";27-import {28-  openChirpSyncSession,29-  probeSubnetForPeer,30-  type ChirpSyncSession,31-  type SyncEndpoint,32-} from "../shared/chirp-sync-transport";33-import { deriveSyncIdentityKeypair, hexToPubkey } from "../shared/sync-identity";34-import { getLocalSubnet } from "../shared/local-network";35-import { getOrCreateDeviceId } from "./chirp-identity";36-import { getRootKeyHex, isUnlocked } from "./vault";37-import type { PairedDevice } from "../shared/sync-types";38-import type { SyncRunResult } from "./sync-trigger";39-40-const DEFAULT_PORT = 8765;41-42-/**43- * Test/injection seam. Production wires in the real `openChirpSyncSession`;44- * unit tests inject a fake to avoid real WebSocket + Noise XX.45- */46-export interface SyncRunnerDeps {47-  openSession: (48-    endpoint: SyncEndpoint,49-    ourPriv: Uint8Array,50-    ourPub: Uint8Array,51-    expectedRemoteHex: string,52-  ) => Promise<ChirpSyncSession>;53-  probeSubnet: (54-    subnet: string,55-    port: number,56-    ourPriv: Uint8Array,57-    ourPub: Uint8Array,58-    expectedRemoteHex: string,59-  ) => Promise<SyncEndpoint | null>;60-  getSubnet: () => Promise<string | null>;61-  createAdapter: (context?: { deviceName?: string }) => ChirpSyncVaultAdapter;62-}63-64-const productionDeps: SyncRunnerDeps = {65-  openSession: (endpoint, priv, pub, expected) =>66-    openChirpSyncSession(endpoint, priv, pub, expected),67-  probeSubnet: (subnet, port, priv, pub, expected) =>68-    probeSubnetForPeer(subnet, port, priv, pub, expected),69-  getSubnet: getLocalSubnet,70-  createAdapter: (context) => createChirpSyncVaultAdapter(context),71-};72-73-export async function productionSyncRunner(device: PairedDevice): Promise<SyncRunResult> {74-  return runWithDeps(device, productionDeps);75-}76-77-/**78- * Inner runner with explicit deps — exported so tests can inject fakes.79- */80-export async function runWithDeps(81-  device: PairedDevice,82-  deps: SyncRunnerDeps,83-): Promise<SyncRunResult> {84-  if (!isUnlocked()) return { ok: false, error: "VAULT_LOCKED" };85-  if (!device.longTermPublicKeyHex) {86-    throw new Error("paired_device_missing_pubkey");87-  }88-  const expectedRemoteHex = device.longTermPublicKeyHex;89-  const port = device.wsPort ?? DEFAULT_PORT;90-91-  // Sync identity derives from the stable root key, not the password-derived92-  // key — keeps the Noise XX static key constant across password changes (SEV-2-B).93-  const masterKeyHex = getRootKeyHex();94-  if (!masterKeyHex) return { ok: false, error: "VAULT_LOCKED" };95-  const masterKey = hexToBytesLocal(masterKeyHex);96-  const ourDeviceId = await getOrCreateDeviceId();97-  const ourKeypair = deriveSyncIdentityKeypair(masterKey, ourDeviceId);98-99-  // Sanity — the remote pubkey must be 32-byte hex so noise can compare it.100-  void hexToPubkey(expectedRemoteHex);101-102-  // HV-1b: pass the peer being synced so remote-mutation alerts can name it.103-  const adapter = deps.createAdapter({ deviceName: device.deviceName });104-  const session = await connect(device, expectedRemoteHex, port, ourKeypair, deps);105-106-  try {107-    const outcome = await runChirpSyncInitiator(session, adapter);108-    if (outcome.status === "completed") {109-      return {110-        ok: true,111-        entriesReceived: outcome.entriesReceived,112-        entriesSent: outcome.entriesSent,113-      };114-    }115-    if (outcome.status === "vault_mismatch") {116-      throw new Error("vault_id_mismatch");117-    }118-    if (outcome.status === "timeout") {119-      throw new Error("sync_timeout");120-    }121-    throw new Error(outcome.error ?? "sync_errored");122-  } finally {123-    // The coordinator does NOT close the session (it returns and leaves the124-    // session open). This runner is the sole owner of session lifetime.125-    await session.close().catch(() => undefined);126-  }127-}128-129-async function connect(130-  _device: PairedDevice,131-  expectedRemoteHex: string,132-  port: number,133-  ourKeypair: { secretKey: Uint8Array; publicKey: Uint8Array },134-  deps: SyncRunnerDeps,135-): Promise<ChirpSyncSession> {136-  const cached = await getLastKnownIp(expectedRemoteHex);137-  if (cached) {138-    try {139-      const session = await deps.openSession(140-        { ip: cached, port },141-        ourKeypair.secretKey,142-        ourKeypair.publicKey,143-        expectedRemoteHex,144-      );145-      return session;146-    } catch {147-      // Cached IP stale — fall through to the subnet probe.148-    }149-  }150-151-  const subnet = await deps.getSubnet();152-  if (!subnet) {153-    throw new Error(cached ? "host_unreachable" : "subnet_unknown");154-  }155-156-  const found = await deps.probeSubnet(157-    subnet,158-    port,159-    ourKeypair.secretKey,160-    ourKeypair.publicKey,161-    expectedRemoteHex,162-  );163-  if (!found) throw new Error("host_unreachable");164-165-  const session = await deps.openSession(166-    found,167-    ourKeypair.secretKey,168-    ourKeypair.publicKey,169-    expectedRemoteHex,170-  );171-  await setLastKnownIp(expectedRemoteHex, found.ip).catch(() => undefined);172-  return session;173-}174-175-function hexToBytesLocal(hex: string): Uint8Array {176-  const out = new Uint8Array(hex.length / 2);177-  for (let i = 0; i < out.length; i++) {178-    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);179-  }180-  return out;181-}

view file at the parent

src/background/chirp-sync-static.test.ts+0 −97
@@ -1,97 +0,0 @@1-// IV.7 — Static safety assertions for Chirp Sync.2-// These tests read source files to enforce structural invariants that3-// are cheaper to verify statically than to exercise at runtime.4-5-import { describe, expect, it } from "vitest";6-import { readFileSync } from "node:fs";7-import { resolve } from "node:path";8-9-const here = __dirname;10-const src = (rel: string) => readFileSync(resolve(here, rel), "utf8");11-12-describe("Chirp Sync — static safety assertions", () => {13-  it("no WebSocket server in any browser-extension sync source file", () => {14-    const files = [15-      "./sync-trigger.ts",16-      "./chirp-sync-runner.ts",17-      "./chirp-sync-coordinator.ts",18-      "./chirp-sync-vault-adapter.ts",19-      "../shared/chirp-sync-transport.ts",20-    ];21-    const forbidden = /WebSocketServer|wss\.listen|createServer|http\.listen|net\.listen/;22-    for (const f of files) {23-      expect(src(f), `${f} must not start a WebSocket server`).not.toMatch(forbidden);24-    }25-  });26-27-  it("applyMergedRemoteEntries does not call fireSyncTrigger or TRIGGER_SYNC", () => {28-    const vaultSrc = src("./vault.ts");29-    const fnStart = vaultSrc.indexOf("applyMergedRemoteEntries");30-    expect(fnStart, "applyMergedRemoteEntries must exist in vault.ts").toBeGreaterThan(-1);31-    // Grab a generous slice starting from the function — large enough to cover32-    // the implementation but not so large that it bleeds into unrelated code.33-    const fnBody = vaultSrc.slice(fnStart, fnStart + 4000);34-    expect(fnBody).not.toMatch(/triggerSyncIfReachable|TRIGGER_SYNC|fireSyncTrigger/);35-  });36-37-  it("addEntryDirect / updateEntryDirect / deleteEntryDirect do not call fireSyncTrigger", () => {38-    const vaultSrc = src("./vault.ts");39-    for (const fn of ["addEntryDirect", "updateEntryDirect", "deleteEntryDirect"]) {40-      const start = vaultSrc.indexOf(`function ${fn}`);41-      if (start === -1) continue;42-      // Slice up to the start of the very next export declaration so we don't43-      // bleed into adjacent function bodies or their JSDoc comments.44-      const nextExport = vaultSrc.indexOf("\nexport ", start + fn.length);45-      const end = nextExport > start ? nextExport : start + 600;46-      const body = vaultSrc.slice(start, end);47-      expect(body, `${fn} must not call fireSyncTrigger`).not.toMatch(48-        /triggerSyncIfReachable|TRIGGER_SYNC|fireSyncTrigger/,49-      );50-    }51-  });52-53-  it("device registry is stored in chrome.storage.local, not chrome.storage.sync", () => {54-    const registrySrc = src("./paired-devices.ts");55-    expect(registrySrc).toContain("storage.local");56-    expect(registrySrc).not.toMatch(57-      /storage\.sync[^;]*peach_paired_devices|peach_paired_devices[^;]*storage\.sync/,58-    );59-  });60-61-  it("setSyncRunner and productionSyncRunner are called in entry.ts", () => {62-    const entrySrc = src("./entry.ts");63-    expect(entrySrc).toContain("setSyncRunner");64-    expect(entrySrc).toContain("productionSyncRunner");65-  });66-67-  it("sync-trigger syncs both direct and user-confirmed vouched hosts", () => {68-    const triggerSrc = src("./sync-trigger.ts");69-    expect(triggerSrc).toMatch(/trustSource === "direct"/);70-    // Mesh introduction is pointless if a device the user confirmed out of the71-    // MV-7 pending queue is then never dialled.72-    expect(triggerSrc).toMatch(/trustSource === "vouched"/);73-  });74-75-  it("the browser never listens — no pairing server or audio-chirp module survives", () => {76-    const appSrc = src("../popup/App.tsx");77-    expect(appSrc).not.toContain("startMDNSListener");78-    expect(appSrc).not.toContain("openPairingServer");79-    expect(appSrc).not.toContain("chirp-pair-capacitor");80-    // Adoption dials out like everything else now.81-    expect(appSrc).toContain("dialPairingChannel");82-  });83-84-  it("vault_id uses the full 64-char BLAKE3 hex digest", () => {85-    const identitySrc = src("../shared/sync-identity.ts");86-    expect(identitySrc).toContain("VAULT_ID_HEX_LENGTH = 64");87-    expect(identitySrc).not.toContain("VAULT_ID_HEX_LENGTH = 16");88-  });89-90-  it("per-entry key derivation uses entryId as part of HKDF info", () => {91-    const adapterSrc = src("./chirp-sync-vault-adapter.ts");92-    expect(adapterSrc).toContain("ENTRY_KEY_INFO_SUFFIX");93-    expect(adapterSrc).toContain("sync-entry-v1");94-    // The info must be derived from entryId — look for the concat pattern.95-    expect(adapterSrc).toMatch(/entryId.*ENTRY_KEY_INFO_SUFFIX|ENTRY_KEY_INFO_SUFFIX.*entryId/);96-  });97-});

view file at the parent

src/background/chirp-sync-vault-adapter.test.ts+0 −596
@@ -1,596 +0,0 @@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-  async function signingPubkeyHex(deviceId: string): Promise<string> {51-    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");52-    const kp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x42), deviceId);53-    return pubkeyToHex(kp.publicKeyRaw);54-  }55-56-  it("computeVaultId returns a stable 64-char hex string for the same masterKey", async () => {57-    const { adapter } = await setupVault();58-    const id1 = await adapter.computeVaultId();59-    const id2 = await adapter.computeVaultId();60-    expect(id1).toBe(id2);61-    expect(id1).toMatch(/^[0-9a-f]{64}$/);62-  });63-64-  it("vault_id and sync signing key are unchanged after a password change (SEV-2-B)", async () => {65-    const { vault, adapter } = await setupVault();66-    const deviceId = await adapter.getOurDeviceId();67-    const vaultIdBefore = await adapter.computeVaultId();68-69-    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");70-    const rootBefore = vault.getRootKeyHex()!;71-    const unhex = (h: string) => Uint8Array.from(h.match(/../g)!.map((b) => parseInt(b, 16)));72-    const signBefore = pubkeyToHex(73-      (await deriveSyncSigningKeypair(unhex(rootBefore), deviceId)).publicKeyRaw,74-    );75-76-    expect(await vault.changePassword("correct horse battery staple", "a different passphrase")).toBe(true);77-    vault.lock();78-    expect(await vault.unlock("a different passphrase")).toBe(true);79-80-    // vault_id derives from the stable root key, so it must not move.81-    expect(await adapter.computeVaultId()).toBe(vaultIdBefore);82-    const rootAfter = vault.getRootKeyHex()!;83-    expect(rootAfter).toBe(rootBefore);84-    const signAfter = pubkeyToHex(85-      (await deriveSyncSigningKeypair(unhex(rootAfter), deviceId)).publicKeyRaw,86-    );87-    expect(signAfter).toBe(signBefore);88-  });89-90-  it("getOurDeviceId is stable across calls and a fresh UUID v4 on first call", async () => {91-    const { adapter } = await setupVault();92-    const id1 = await adapter.getOurDeviceId();93-    const id2 = await adapter.getOurDeviceId();94-    expect(id1).toBe(id2);95-    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}$/);96-  });97-98-  it("buildEntryManifest reports {id: modifiedAt} for live entries only", async () => {99-    const { vault, adapter } = await setupVault();100-    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });101-    const b = await vault.addEntry({ type: "login", title: "Beta", password: "pw" });102-    await vault.deleteEntry(b.id);103-    const m = await adapter.buildEntryManifest();104-    expect(Object.keys(m).sort()).toEqual([a.id]);105-    expect(m[a.id]).toBe(a.modifiedAt);106-  });107-108-  it("buildTombstoneManifest reports {id: deletedAt} for soft-deleted entries", async () => {109-    const { vault, adapter } = await setupVault();110-    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });111-    await vault.deleteEntry(a.id);112-    const t = await adapter.buildTombstoneManifest();113-    expect(Object.keys(t)).toEqual([a.id]);114-    expect(typeof t[a.id]).toBe("number");115-    expect(t[a.id]).toBeGreaterThan(0);116-  });117-118-  it("getEncryptedEntries returns opaque base64 strings keyed by id", async () => {119-    const { vault, adapter } = await setupVault();120-    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });121-    const env = await adapter.getEncryptedEntries([a.id]);122-    expect(Object.keys(env)).toEqual([a.id]);123-    expect(env[a.id]).toMatch(/^[A-Za-z0-9+/]+=*$/);124-    // Plaintext title must not be visible in the envelope.125-    expect(env[a.id]).not.toContain("Alpha");126-  });127-128-  it("getEncryptedEntries silently skips unknown ids (peer may have a manifest item we dropped)", async () => {129-    const { adapter } = await setupVault();130-    const env = await adapter.getEncryptedEntries(["does-not-exist"]);131-    expect(env).toEqual({});132-  });133-134-  it("encryption uses a fresh nonce per call — same entry yields different envelopes", async () => {135-    const { vault, adapter } = await setupVault();136-    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });137-    const e1 = await adapter.getEncryptedEntries([a.id]);138-    const e2 = await adapter.getEncryptedEntries([a.id]);139-    expect(e1[a.id]).not.toBe(e2[a.id]);140-  });141-142-  it("round-trips: encrypt on one adapter, apply on a fresh import of the same vault", async () => {143-    const { vault, adapter } = await setupVault();144-    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "secret", url: "https://a.example" });145-    const envelopes = await adapter.getEncryptedEntries([a.id]);146-    // Simulate a remote vault by bumping modifiedAt on the envelope's source147-    // and then applying it back — we can't easily fork the module's storage,148-    // so we just verify decrypt path by overwriting modifiedAt locally.149-    // Delete the local entry first, then apply the envelope — applyMergedRemoteEntries150-    // will re-add it because the local copy is tombstoned with an OLDER ts (since151-    // we set modifiedAt forward by 1s in the new envelope payload).152-    // Instead, simpler proof: apply the envelope as-is to a fresh adapter import.153-    vault.lock();154-    await vault.unlock("correct horse battery staple");155-    const { createChirpSyncVaultAdapter } = await import("./chirp-sync-vault-adapter");156-    const adapter2 = createChirpSyncVaultAdapter();157-    // applyEntries should be a no-op when local mtime >= envelope mtime158-    await adapter2.applyEntries(envelopes);159-    expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Alpha");160-  });161-162-  it("applyEntries decrypts and merges newer remote entries (last-write-wins)", async () => {163-    const { vault, adapter } = await setupVault();164-    const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" });165-    // Manually produce a fresher remote envelope by encrypting an updated entry shape.166-    // We can't easily synthesise this without the entry key — so we use a second167-    // adapter instance backed by the same vault, after mutating the underlying entry.168-    await vault.updateEntry(local.id, { title: "Updated" });169-    const newer = vault.getEntries().find((e) => e.id === local.id);170-    expect(newer?.title).toBe("Updated");171-    const envelopes = await adapter.getEncryptedEntries([local.id]);172-    // Now revert the local entry to an older mtime by re-creating from scratch via173-    // applyMergedRemoteEntries — but we'd loop. The most reliable proof of the174-    // decrypt+merge path is that applyEntries on a fresher envelope keeps the175-    // newer state, and on an older envelope is a no-op. We've covered the no-op176-    // path above; for the apply path, simulate by replacing the in-memory entry177-    // with an older copy and re-running applyEntries.178-    const older: typeof local = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" };179-    await vault.restoreEntries([older], "replace");180-    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale");181-    await adapter.applyEntries(envelopes);182-    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated");183-  });184-185-  it("applyEntries rejects envelopes whose decrypted id differs from the map key", async () => {186-    const { vault, adapter } = await setupVault();187-    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });188-    const env = await adapter.getEncryptedEntries([a.id]);189-    // Re-key the envelope under a different id — adapter must refuse to merge it.190-    const swapped: Record<string, string> = { "wrong-id": env[a.id] };191-    await adapter.applyEntries(swapped);192-    // Local entry remains unchanged; no new entry under "wrong-id"193-    expect(vault.getEntries().map((e) => e.id)).toEqual([a.id]);194-  });195-196-  it("entry_envelope_roundtrips_with_separated_info (HKDF separator)", async () => {197-    const { vault, adapter } = await setupVault();198-    const a = await vault.addEntry({ type: "login", title: "Original", password: "pw" });199-    await vault.updateEntry(a.id, { title: "Fresh" });200-    const envelopes = await adapter.getEncryptedEntries([a.id]);201-    // Roll local back to an older copy, then apply: encrypt+decrypt both use the202-    // null-byte-separated info, so the round-trip must succeed.203-    const stale = { ...a, modifiedAt: a.modifiedAt - 1000, title: "Stale" };204-    await vault.restoreEntries([stale], "replace");205-    await adapter.applyEntries(envelopes);206-    expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Fresh");207-  });208-209-  it("legacy_info_envelope_fails_closed (HKDF separator wire break)", async () => {210-    // Mint an envelope under the OLD, unseparated info (`entryId + suffix`) and211-    // assert the patched adapter cannot decrypt it — it drops silently. Locks in212-    // the conscious wire break and serves as the fixture for any future migration.213-    const { vault, adapter } = await setupVault();214-    const { HKDF } = await import("@stablelib/hkdf");215-    const { SHA256 } = await import("@stablelib/sha256");216-217-    const masterHex = vault.getRootKeyHex()!;218-    const masterKey = Uint8Array.from(masterHex.match(/../g)!.map((b) => parseInt(b, 16)));219-    const entryId = crypto.randomUUID();220-    const legacyInfo = new TextEncoder().encode(entryId + "sync-entry-v1"); // no \x00221-    const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), legacyInfo).expand(32);222-    const key = await crypto.subtle.importKey("raw", raw.buffer as ArrayBuffer, { name: "AES-GCM" }, false, ["encrypt"]);223-224-    const entry = { id: entryId, type: "login", title: "LegacyOnly", password: "pw", createdAt: Date.now(), modifiedAt: Date.now() };225-    const nonce = crypto.getRandomValues(new Uint8Array(12));226-    const aad = new TextEncoder().encode("peach-sync-entry-v1");227-    const ct = new Uint8Array(228-      await crypto.subtle.encrypt(229-        { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, additionalData: aad.buffer as ArrayBuffer, tagLength: 128 },230-        key,231-        new TextEncoder().encode(JSON.stringify(entry)).buffer as ArrayBuffer,232-      ),233-    );234-    const envelope = new Uint8Array(nonce.length + ct.length);235-    envelope.set(nonce, 0);236-    envelope.set(ct, nonce.length);237-    const legacyB64 = Buffer.from(envelope).toString("base64");238-239-    await adapter.applyEntries({ [entryId]: legacyB64 });240-    // Decrypt fails under the new separated info → envelope dropped, entry absent.241-    expect(vault.getEntries().some((e) => e.id === entryId)).toBe(false);242-  });243-244-  it("applyTombstones soft-deletes locally-live entries with newer deletedAt", async () => {245-    const { vault, adapter } = await setupVault();246-    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });247-    await adapter.applyTombstones({ [a.id]: a.modifiedAt + 10_000 });248-    expect(vault.getEntries().find((e) => e.id === a.id)).toBeUndefined();249-    expect(vault.getDeletedEntries().find((e) => e.id === a.id)).toBeDefined();250-  });251-252-  it("applyTombstones leaves entries alone when local modifiedAt is newer", async () => {253-    const { vault, adapter } = await setupVault();254-    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });255-    await adapter.applyTombstones({ [a.id]: a.modifiedAt - 10_000 });256-    expect(vault.getEntries().find((e) => e.id === a.id)).toBeDefined();257-  });258-259-  // --- MV-3: sync receive bounds ---260-261-  it("MV-3: applyEntries throws when the batch exceeds MAX_ENTRY_COUNT", async () => {262-    const { adapter } = await setupVault();263-    const { SYNC_LIMITS } = await import("../shared/sync-limits");264-    const tooMany: Record<string, string> = {};265-    for (let i = 0; i <= SYNC_LIMITS.MAX_ENTRY_COUNT; i++) tooMany[`id-${i}`] = "AAAA";266-    await expect(adapter.applyEntries(tooMany)).rejects.toThrow(/exceeds limit/);267-  });268-269-  it("MV-3: applyEntries skips an oversized envelope but still applies valid ones", async () => {270-    const { vault, adapter } = await setupVault();271-    const { SYNC_LIMITS } = await import("../shared/sync-limits");272-    const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" });273-    await vault.updateEntry(local.id, { title: "Updated" });274-    const envelopes = await adapter.getEncryptedEntries([local.id]);275-    // Make the local copy stale so the valid envelope would win on merge.276-    const older = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" };277-    await vault.restoreEntries([older], "replace");278-    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale");279-280-    const oversized = "A".repeat(281-      Math.ceil((SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3) + 128,282-    );283-    await adapter.applyEntries({ ...envelopes, "oversized-id": oversized });284-285-    // The valid envelope applied; the oversized one was skipped (never decrypted).286-    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated");287-    expect(vault.getEntries().some((e) => e.id === "oversized-id")).toBe(false);288-  });289-290-  it("MV-3: applyTombstones throws when the batch exceeds MAX_TOMBSTONE_COUNT", async () => {291-    const { adapter } = await setupVault();292-    const { SYNC_LIMITS } = await import("../shared/sync-limits");293-    const tooMany: Record<string, number> = {};294-    for (let i = 0; i <= SYNC_LIMITS.MAX_TOMBSTONE_COUNT; i++) tooMany[`id-${i}`] = i + 1;295-    await expect(adapter.applyTombstones(tooMany)).rejects.toThrow(/exceeds limit/);296-  });297-298-  it("buildOutgoingDeviceManifest signs manifestable paired devices and skips legacy entries without signing keys", async () => {299-    const { adapter } = await setupVault();300-    const { upsertPairedDevice } = await import("./paired-devices");301-    await upsertPairedDevice({302-      deviceId: "android-1",303-      deviceName: "Pixel",304-      deviceType: "android",305-      longTermPublicKeyHex: "a".repeat(64),306-      signingPublicKeyHex: await signingPubkeyHex("android-1"),307-      wsPort: 49152,308-      pairedAt: 1_700_000_000_000,309-      trustSource: "direct",310-    });311-    await upsertPairedDevice({312-      deviceId: "legacy-android",313-      deviceName: "Legacy Pixel",314-      deviceType: "android",315-      longTermPublicKeyHex: "b".repeat(64),316-      pairedAt: 1_700_000_000_100,317-      trustSource: "direct",318-    });319-320-    const manifest = await adapter.buildOutgoingDeviceManifest?.();321-    expect(manifest).toBeDefined();322-    expect(manifest?.signatureHex).toMatch(/^[0-9a-f]{128}$/);323-    expect(manifest?.body.devices).toHaveLength(1);324-    expect(manifest?.body.devices[0]).toMatchObject({325-      deviceId: "android-1",326-      deviceName: "Pixel",327-      deviceType: "android",328-      longTermPublicKeyHex: "a".repeat(64),329-      wsPort: 49152,330-    });331-  });332-333-  it("MV-7: applyIncomingDeviceManifest queues unknown vouched devices for confirmation and updates existing ones in place", async () => {334-    const { adapter } = await setupVault();335-    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");336-    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");337-    const {338-      findPairedDeviceById,339-      listPairedDevices,340-      upsertPairedDevice,341-    } = await import("./paired-devices");342-    const { listPendingVouchedDevices } = await import(343-      "./pending-vouched-devices"344-    );345-346-    const issuerId = "extension-issuer";347-    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);348-    await upsertPairedDevice({349-      deviceId: issuerId,350-      deviceName: "Firefox",351-      deviceType: "extension",352-      longTermPublicKeyHex: "c".repeat(64),353-      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),354-      pairedAt: 1_700_000_000_000,355-      trustSource: "direct",356-    });357-    await upsertPairedDevice({358-      deviceId: "android-existing",359-      deviceName: "Existing Pixel",360-      deviceType: "android",361-      longTermPublicKeyHex: "d".repeat(64),362-      signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),363-      pairedAt: 1_700_000_000_010,364-      trustSource: "direct",365-    });366-367-    const manifest = await buildSignedDeviceManifest(368-      issuerId,369-      [370-        {371-          deviceId: "android-new",372-          deviceName: "New Pixel",373-          deviceType: "android",374-          longTermPublicKeyHex: "e".repeat(64),375-          signingPublicKeyHex: await signingPubkeyHex("android-new"),376-          wsPort: 51234,377-          pairedAt: 1_700_000_000_020,378-        },379-        {380-          deviceId: "android-existing",381-          deviceName: "Existing Pixel renamed",382-          deviceType: "android",383-          longTermPublicKeyHex: "f".repeat(64),384-          signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),385-          wsPort: 52345,386-          pairedAt: 1_700_000_000_030,387-        },388-      ],389-      issuerKp.privateKey,390-    );391-392-    await adapter.applyIncomingDeviceManifest?.(manifest);393-394-    // MV-7: the never-seen device is NOT auto-paired; it waits in the pending395-    // queue for explicit user confirmation.396-    expect(await findPairedDeviceById("android-new")).toBeNull();397-    const pending = await listPendingVouchedDevices();398-    expect(pending.map((d) => d.deviceId)).toEqual(["android-new"]);399-    expect(pending[0]).toMatchObject({400-      deviceName: "New Pixel",401-      longTermPublicKeyHex: "e".repeat(64),402-      vouchedByDeviceId: "extension-issuer",403-    });404-405-    // The already-paired device is still updated in place.406-    const existing = await findPairedDeviceById("android-existing");407-    expect(existing).toMatchObject({408-      deviceName: "Existing Pixel renamed",409-      longTermPublicKeyHex: "f".repeat(64),410-      trustSource: "direct",411-      wsPort: 52345,412-    });413-    expect((await listPairedDevices()).map((d) => d.deviceId).sort()).toEqual([414-      "android-existing",415-      "extension-issuer",416-    ]);417-  });418-419-  it("MV-8d: records a DEVICE_KEY_ROTATED alert when an existing device's key changes in a manifest", async () => {420-    const { adapter } = await setupVault();421-    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");422-    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");423-    const { upsertPairedDevice } = await import("./paired-devices");424-    const { getPendingSyncAlerts } = await import("./sync-alerts");425-426-    const issuerId = "extension-issuer";427-    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);428-    await upsertPairedDevice({429-      deviceId: issuerId,430-      deviceName: "Firefox",431-      deviceType: "extension",432-      longTermPublicKeyHex: "c".repeat(64),433-      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),434-      pairedAt: 1,435-      trustSource: "direct",436-    });437-    await upsertPairedDevice({438-      deviceId: "android-existing",439-      deviceName: "Pixel",440-      deviceType: "android",441-      longTermPublicKeyHex: "d".repeat(64),442-      signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),443-      pairedAt: 2,444-      trustSource: "direct",445-    });446-447-    const manifest = await buildSignedDeviceManifest(448-      issuerId,449-      [450-        {451-          deviceId: "android-existing",452-          deviceName: "Pixel",453-          deviceType: "android",454-          longTermPublicKeyHex: "f".repeat(64),455-          signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),456-          wsPort: 52345,457-          pairedAt: 3,458-        },459-      ],460-      issuerKp.privateKey,461-    );462-463-    await adapter.applyIncomingDeviceManifest?.(manifest);464-    // recordSyncAlerts is fire-and-forget — flush microtasks.465-    for (let i = 0; i < 25; i++) await Promise.resolve();466-467-    const alerts = await getPendingSyncAlerts();468-    expect(469-      alerts.some(470-        (a) => a.type === "DEVICE_KEY_ROTATED" && a.deviceName === "Pixel",471-      ),472-    ).toBe(true);473-  });474-475-  it("accepts a manifest from the phone it paired with — mesh introduction runs in that direction", async () => {476-    // The browser can never listen, so it joins the mesh THROUGH a native peer;477-    // that peer is the one holding the peer list. A phone-issued manifest must478-    // therefore be honoured. MV-7 still applies: the introduced device lands in479-    // the pending queue, not the registry.480-    const { adapter } = await setupVault();481-    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");482-    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");483-    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");484-    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");485-486-    const mobileIssuerId = "android-issuer";487-    const mobileIssuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), mobileIssuerId);488-    await upsertPairedDevice({489-      deviceId: mobileIssuerId,490-      deviceName: "Issuer Pixel",491-      deviceType: "android",492-      longTermPublicKeyHex: "1".repeat(64),493-      signingPublicKeyHex: pubkeyToHex(mobileIssuerKp.publicKeyRaw),494-      pairedAt: 1_700_000_000_000,495-      trustSource: "direct",496-    });497-    const mobileManifest = await buildSignedDeviceManifest(498-      mobileIssuerId,499-      [{500-        deviceId: "mesh-peer",501-        deviceName: "Other Pixel",502-        deviceType: "android",503-        longTermPublicKeyHex: "2".repeat(64),504-        signingPublicKeyHex: await signingPubkeyHex("mesh-peer"),505-        pairedAt: 1_700_000_000_010,506-      }],507-      mobileIssuerKp.privateKey,508-    );509-    await adapter.applyIncomingDeviceManifest?.(mobileManifest);510-511-    expect(await findPairedDeviceById("mesh-peer")).toBeNull();512-    expect((await listPendingVouchedDevices()).map((d) => d.deviceId)).toEqual(["mesh-peer"]);513-  });514-515-  it("applyIncomingDeviceManifest ignores vouched issuers and bad signatures", async () => {516-    const { adapter } = await setupVault();517-    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");518-    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");519-    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");520-    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");521-522-    // Trust does not travel two hops: a device we only know because someone523-    // else vouched for it cannot go on to vouch for further devices.524-    const vouchedIssuerId = "android-vouched-issuer";525-    const vouchedKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), vouchedIssuerId);526-    await upsertPairedDevice({527-      deviceId: vouchedIssuerId,528-      deviceName: "Second-hand Pixel",529-      deviceType: "android",530-      longTermPublicKeyHex: "1".repeat(64),531-      signingPublicKeyHex: pubkeyToHex(vouchedKp.publicKeyRaw),532-      pairedAt: 1_700_000_000_000,533-      trustSource: "vouched",534-    });535-    const onward = await buildSignedDeviceManifest(536-      vouchedIssuerId,537-      [{538-        deviceId: "should-not-add",539-        deviceName: "Other Pixel",540-        deviceType: "android",541-        longTermPublicKeyHex: "2".repeat(64),542-        signingPublicKeyHex: await signingPubkeyHex("should-not-add"),543-        pairedAt: 1_700_000_000_010,544-      }],545-      vouchedKp.privateKey,546-    );547-    await adapter.applyIncomingDeviceManifest?.(onward);548-    expect(await findPairedDeviceById("should-not-add")).toBeNull();549-    expect(await listPendingVouchedDevices()).toEqual([]);550-551-    const extensionIssuerId = "extension-bad-sig";552-    const extensionKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), extensionIssuerId);553-    await upsertPairedDevice({554-      deviceId: extensionIssuerId,555-      deviceName: "Chrome",556-      deviceType: "extension",557-      longTermPublicKeyHex: "3".repeat(64),558-      signingPublicKeyHex: pubkeyToHex(extensionKp.publicKeyRaw),559-      pairedAt: 1_700_000_000_020,560-      trustSource: "direct",561-    });562-    const good = await buildSignedDeviceManifest(563-      extensionIssuerId,564-      [{565-        deviceId: "forged-add",566-        deviceName: "Forged Pixel",567-        deviceType: "android",568-        longTermPublicKeyHex: "4".repeat(64),569-        signingPublicKeyHex: await signingPubkeyHex("forged-add"),570-        pairedAt: 1_700_000_000_030,571-      }],572-      extensionKp.privateKey,573-    );574-    const forged = {575-      ...good,576-      signatureHex:577-        good.signatureHex.slice(0, 2) +578-        ((parseInt(good.signatureHex.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0")) +579-        good.signatureHex.slice(4),580-    };581-    await adapter.applyIncomingDeviceManifest?.(forged);582-    expect(await findPairedDeviceById("forged-add")).toBeNull();583-  });584-585-  it("methods throw VaultLockedError when the vault is locked", async () => {586-    const { vault, adapter } = await setupVault();587-    vault.lock();588-    await expect(adapter.computeVaultId()).rejects.toMatchObject({ message: "vault_locked" });589-    await expect(adapter.buildEntryManifest()).rejects.toMatchObject({ message: "vault_locked" });590-    await expect(adapter.buildTombstoneManifest()).rejects.toMatchObject({ message: "vault_locked" });591-    await expect(adapter.getEncryptedEntries(["x"])).rejects.toMatchObject({ message: "vault_locked" });592-    await expect(adapter.applyEntries({ x: "AAAA" })).rejects.toMatchObject({ message: "vault_locked" });593-    await expect(adapter.applyTombstones({ x: 1 })).rejects.toMatchObject({ message: "vault_locked" });594-    await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toMatchObject({ message: "vault_locked" });595-  });596-});

view file at the parent

src/background/chirp-sync-vault-adapter.ts+0 −453
@@ -1,453 +0,0 @@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 with a PER-ENTRY key derived as10-//   HKDF-SHA256(ikm = syncMasterKey, salt = "", info = entryId + "\x00" + "sync-entry-v1")11-// (random 12B nonce, aad = "peach-sync-entry-v1"). Binding the key to the12-// entry id means a ciphertext minted for one entry slot cannot be replayed13-// into another: decryptEntryOrNull rejects any envelope whose decrypted14-// entry.id does not match the map key it arrived under. Both sides hold the15-// same syncMasterKey (same vault) so keys match without prior negotiation. The16-// Noise XX session encrypts the transport on top — the envelope is17-// belt-and-braces so a transport-layer leak doesn't expose plaintext entries.18-//19-// Device-manifest exchange (v2 brief §4.3) is plumbed here. The extension is20-// the trust anchor: manifests are signed with its vault-derived Ed25519 key,21-// and receivers only apply manifests whose issuer is a paired extension.22-23-import { HKDF } from "@stablelib/hkdf";24-import { SHA256 } from "@stablelib/sha256";25-import type { ChirpSyncVaultAdapter } from "./chirp-sync-coordinator";26-import { getOrCreateDeviceId } from "./chirp-identity";27-import {28-  computeVaultId,29-  deriveSyncSigningKeypair,30-  hexToPubkey,31-} from "../shared/sync-identity";32-import {33-  buildSignedDeviceManifest,34-  verifyDeviceManifest,35-} from "../shared/device-manifest";36-import {37-  applyMergedRemoteEntries,38-  getDeletedEntries,39-  getRootKeyHex,40-  getEntries,41-  getEntryById,42-  getVaultVersion,43-  isUnlocked,44-} from "./vault";45-import {46-  findPairedDeviceById,47-  listPairedDevices,48-  upsertPairedDevice,49-} from "./paired-devices";50-import { addPendingVouchedDevice } from "./pending-vouched-devices";51-import { recordSyncAlerts } from "./sync-alerts";52-import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";53-import type { VaultEntry } from "../shared/types";54-import type {55-  ManifestDeviceEntry,56-  PairedDevice,57-  SignedDeviceManifest,58-} from "../shared/sync-types";59-60-// Each entry uses a unique AES-GCM key: HKDF(masterKey, info = entryId + "\x00" + "sync-entry-v1").61-// The info binding prevents a ciphertext from one entry slot being replayed into another.62-const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";63-const ENTRY_AAD = "peach-sync-entry-v1";64-65-export class VaultLockedError extends Error {66-  constructor() {67-    super("vault_locked");68-    this.name = "VaultLockedError";69-  }70-}71-72-export function createChirpSyncVaultAdapter(73-  context: { deviceName?: string } = {},74-): ChirpSyncVaultAdapter {75-  return {76-    async computeVaultId() {77-      const master = requireMasterKey();78-      return computeVaultId(master);79-    },80-81-    async getOurDeviceId() {82-      return getOrCreateDeviceId();83-    },84-85-    async getLocalVersion() {86-      return getVaultVersion();87-    },88-89-    async buildEntryManifest() {90-      requireUnlocked();91-      const manifest: Record<string, number> = {};92-      for (const entry of getEntries()) {93-        manifest[entry.id] = entry.modifiedAt;94-      }95-      return manifest;96-    },97-98-    async buildTombstoneManifest() {99-      // KNOWN LIMITATION: tombstones accumulate without bound. Every deleted100-      // entry leaves a {id, deletedAt} record that persists indefinitely and is101-      // sent in every sync session, so a long-lived vault's manifest grows with102-      // total deletions, not live entries. Acceptable for now (records are tiny103-      // and the trash-retention purge eventually splices the entry array).104-      // Future: prune tombstones older than N months once every paired device105-      // has confirmed it holds no entry with that id.106-      requireUnlocked();107-      const manifest: Record<string, number> = {};108-      for (const entry of getDeletedEntries()) {109-        if (typeof entry.deletedAt === "number") {110-          manifest[entry.id] = entry.deletedAt;111-        }112-      }113-      return manifest;114-    },115-116-    async getEncryptedEntries(entryIds) {117-      requireUnlocked();118-      const masterKey = requireMasterKey();119-      const out: Record<string, string> = {};120-      for (const id of entryIds) {121-        const entry = getEntryById(id);122-        if (!entry) continue;123-        const key = await deriveEntryKey(masterKey, id);124-        out[id] = await encryptEntry(key, entry);125-      }126-      return out;127-    },128-129-    async applyEntries(entries) {130-      requireUnlocked();131-      const entryList = Object.entries(entries);132-      // MV-3: bound the batch before any per-entry crypto work.133-      if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {134-        throw new Error(135-          `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,136-        );137-      }138-      const masterKey = requireMasterKey();139-      const decrypted: VaultEntry[] = [];140-      for (const [id, envelope] of entryList) {141-        // MV-3: per-entry ceiling, checked on the (base64) envelope before142-        // decrypting. Base64 inflates by 4/3, so allow that factor.143-        if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) {144-          continue; // oversized envelope — skip, never decrypt145-        }146-        const key = await deriveEntryKey(masterKey, id);147-        const entry = await decryptEntryOrNull(key, envelope);148-        if (!entry) continue;149-        // MV-3: defense in depth — re-measure the decrypted plaintext.150-        if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) {151-          continue;152-        }153-        // The map key is the authority on id — refuse mismatched envelopes154-        // so a swapped envelope can't poison a different entry slot.155-        if (entry.id !== id) continue;156-        decrypted.push(entry);157-      }158-      if (decrypted.length > 0) {159-        await applyMergedRemoteEntries(decrypted, {}, context);160-      }161-    },162-163-    async applyTombstones(tombstones) {164-      requireUnlocked();165-      const tombstoneList = Object.entries(tombstones);166-      // MV-3: bound the tombstone batch before processing.167-      if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {168-        throw new Error(169-          `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,170-        );171-      }172-      const cleaned: Record<string, number> = {};173-      for (const [id, ts] of tombstoneList) {174-        if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {175-          // HV-1c: clamp before the value participates in any comparison.176-          // vault.ts clamps again defensively; clamping here keeps the177-          // invariant local to the boundary where peer data enters.178-          cleaned[id] = clampSyncTimestamp(ts);179-        }180-      }181-      if (Object.keys(cleaned).length > 0) {182-        await applyMergedRemoteEntries([], cleaned, context);183-      }184-    },185-186-    async buildOutgoingDeviceManifest() {187-      requireUnlocked();188-      const masterKey = requireMasterKey();189-      const issuerDeviceId = await getOrCreateDeviceId();190-      const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);191-      const devices = (await listPairedDevices())192-        .map(toManifestEntry)193-        .filter((d): d is ManifestDeviceEntry => d !== null);194-      return buildSignedDeviceManifest(195-        issuerDeviceId,196-        devices,197-        signing.privateKey,198-      );199-    },200-201-    async applyIncomingDeviceManifest(manifest) {202-      requireUnlocked();203-      await applyVerifiedPeerManifest(manifest);204-    },205-  };206-}207-208-function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {209-  if (210-    !device.deviceId ||211-    !device.deviceName ||212-    !device.deviceType ||213-    !device.longTermPublicKeyHex ||214-    !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||215-    !device.signingPublicKeyHex ||216-    !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)217-  ) {218-    return null;219-  }220-  return {221-    deviceId: device.deviceId,222-    deviceName: device.deviceName,223-    deviceType: device.deviceType,224-    longTermPublicKeyHex: device.longTermPublicKeyHex,225-    signingPublicKeyHex: device.signingPublicKeyHex,226-    wsPort: device.wsPort,227-    pairedAt: device.pairedAt,228-  };229-}230-231-/**232- * Apply a device manifest issued by a peer we pair with directly.233- *234- * The issuer used to be required to be an "extension", on the assumption that235- * the extension was the mesh's trust anchor. It is not: the browser can never236- * listen for a connection, so the device it joins the mesh THROUGH is a native237- * peer (a phone), and that peer is the one holding the peer list to hand over.238- * Rejecting its manifest made mesh introduction impossible in the direction it239- * actually happens.240- *241- * Trust is not widened by that change. The issuer must still be a DIRECTLY242- * paired device — a vouched peer cannot vouch onward — and the MV-7 gate below243- * still routes any device we have never seen into the pending queue for244- * explicit user confirmation rather than trusting it on the signature alone.245- */246-async function applyVerifiedPeerManifest(247-  manifest: SignedDeviceManifest,248-): Promise<void> {249-  const issuerId = manifest?.body?.issuerDeviceId;250-  if (typeof issuerId !== "string" || !issuerId) return;251-252-  const issuer = await findPairedDeviceById(issuerId);253-  if (254-    !issuer ||255-    issuer.trustSource !== "direct" ||256-    !issuer.signingPublicKeyHex ||257-    !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)258-  ) {259-    return;260-  }261-262-  let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;263-  try {264-    body = await verifyDeviceManifest(265-      manifest,266-      hexToPubkey(issuer.signingPublicKeyHex),267-    );268-  } catch {269-    return;270-  }271-  if (body.issuerDeviceId !== issuer.deviceId) return;272-273-  const ourDeviceId = await getOrCreateDeviceId();274-  for (const entry of body.devices) {275-    if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {276-      continue;277-    }278-    const existing = await findPairedDeviceById(entry.deviceId);279-280-    // MV-7: a device we have NEVER seen does not become a sync target on281-    // the strength of the manifest signature alone. Queue it for explicit282-    // user confirmation; until then it cannot push entries or tombstones283-    // at us (it is absent from the paired-devices registry, so the runner284-    // never opens a session to it and inbound static keys don't match).285-    if (!existing) {286-      await addPendingVouchedDevice({287-        deviceId: entry.deviceId,288-        deviceName: entry.deviceName,289-        deviceType: entry.deviceType,290-        longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),291-        signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),292-        wsPort: entry.wsPort,293-        pairedAt: entry.pairedAt,294-        receivedAt: Date.now(),295-        vouchedByDeviceId: issuer.deviceId,296-      });297-      notifyPopup({298-        type: "VOUCHED_DEVICE_PENDING",299-        payload: { deviceName: entry.deviceName, deviceId: entry.deviceId },300-      });301-      continue; // not trusted yet302-    }303-304-    // MV-8d: a key changing under a stable deviceId is legitimate after a305-    // reinstall, but it is exactly what a graph-poisoning attack looks like —306-    // signal the user either way, durably (the alert queue survives a closed307-    // popup) and live (the broadcast updates an open one).308-    if (309-      existing.longTermPublicKeyHex &&310-      existing.longTermPublicKeyHex !== entry.longTermPublicKeyHex.toLowerCase()311-    ) {312-      notifyPopup({313-        type: "DEVICE_KEY_ROTATED",314-        payload: { deviceName: entry.deviceName, deviceId: entry.deviceId },315-      });316-      void recordSyncAlerts([317-        {318-          type: "DEVICE_KEY_ROTATED",319-          entryId: "",320-          entryTitle: entry.deviceName,321-          deviceName: entry.deviceName,322-          timestamp: Date.now(),323-        },324-      ]);325-    }326-327-    // Existing device — update in place as before.328-    await upsertPairedDevice({329-      ...existing,330-      deviceId: entry.deviceId,331-      deviceName: entry.deviceName,332-      deviceType: entry.deviceType,333-      longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),334-      signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),335-      pairedAt: existing.pairedAt ?? entry.pairedAt,336-      trustSource: existing.trustSource ?? "direct",337-      wsPort: existing.wsPort ?? entry.wsPort,338-      lastSyncedAt: existing.lastSyncedAt,339-    });340-  }341-}342-343-// MV-7: fire-and-forget broadcast to the popup. The popup may be closed,344-// which is fine — the pending-vouched queue is the durable signal.345-function notifyPopup(msg: { type: string; payload?: unknown }): void {346-  try {347-    void chrome.runtime.sendMessage(msg).catch(() => undefined);348-  } catch {349-    /* popup closed — pending queue is the durable signal */350-  }351-}352-353-function requireUnlocked(): void {354-  if (!isUnlocked()) throw new VaultLockedError();355-}356-357-// Sync derives everything — vault_id, the Noise XX static key, the Ed25519358-// manifest signing key, and the per-entry envelope keys — from the vault's359-// stable root key, NOT the password-derived key. This keeps paired devices'360-// identities and envelope keys constant across a master-password change on361-// either side (SEV-2-B); two paired devices with independently-rotated362-// passwords still share this key and can decrypt each other's entries.363-function requireMasterKey(): Uint8Array {364-  if (!isUnlocked()) throw new VaultLockedError();365-  const hex = getRootKeyHex();366-  if (!hex) throw new VaultLockedError();367-  return hexToBytes(hex);368-}369-370-async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {371-  // Null-byte separator (HKDF hardening): entryId cannot contain \x00, so this372-  // makes the (entryId, suffix) boundary unambiguous regardless of future id373-  // formats. Coordinated wire change — peach-android VaultSyncAdapter derives374-  // the identical info; the two MUST stay byte-for-byte in lockstep.375-  const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);376-  const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);377-  return crypto.subtle.importKey(378-    "raw",379-    raw.buffer as ArrayBuffer,380-    { name: "AES-GCM" },381-    false,382-    ["encrypt", "decrypt"],383-  );384-}385-386-async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {387-  const plaintext = new TextEncoder().encode(JSON.stringify(entry));388-  const nonce = crypto.getRandomValues(new Uint8Array(12));389-  const aad = new TextEncoder().encode(ENTRY_AAD);390-  const ct = await crypto.subtle.encrypt(391-    {392-      name: "AES-GCM",393-      iv: nonce.buffer as ArrayBuffer,394-      additionalData: aad.buffer as ArrayBuffer,395-      tagLength: 128,396-    },397-    key,398-    plaintext.buffer as ArrayBuffer,399-  );400-  const ctBytes = new Uint8Array(ct);401-  const envelope = new Uint8Array(nonce.length + ctBytes.length);402-  envelope.set(nonce, 0);403-  envelope.set(ctBytes, nonce.length);404-  return bytesToBase64(envelope);405-}406-407-async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {408-  try {409-    const bytes = base64ToBytes(envelopeB64);410-    if (bytes.length < 12 + 16) return null;411-    const nonce = bytes.subarray(0, 12);412-    const ct = bytes.subarray(12);413-    const aad = new TextEncoder().encode(ENTRY_AAD);414-    const pt = await crypto.subtle.decrypt(415-      {416-        name: "AES-GCM",417-        iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,418-        additionalData: aad.buffer as ArrayBuffer,419-        tagLength: 128,420-      },421-      key,422-      ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,423-    );424-    const json = new TextDecoder().decode(pt);425-    const parsed = JSON.parse(json);426-    if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;427-    return parsed as VaultEntry;428-  } catch {429-    return null;430-  }431-}432-433-function hexToBytes(hex: string): Uint8Array {434-  if (hex.length % 2 !== 0) throw new Error("hex length must be even");435-  const out = new Uint8Array(hex.length / 2);436-  for (let i = 0; i < out.length; i++) {437-    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);438-  }439-  return out;440-}441-442-function bytesToBase64(bytes: Uint8Array): string {443-  let bin = "";444-  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);445-  return btoa(bin);446-}447-448-function base64ToBytes(b64: string): Uint8Array {449-  const bin = atob(b64);450-  const out = new Uint8Array(bin.length);451-  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);452-  return out;453-}

view file at the parent

src/background/code-pairing-bg.ts+2 −2
@@ -17,13 +17,13 @@1717 } from "./code-pairing";1818 import { openPairingSocket } from "../shared/pairing-dial";1919 import { upsertPairedDevice, setLastKnownIp } from "./paired-devices";20-import { buildLocalIdentity } from "./chirp-identity";20+import { buildLocalIdentity } from "./device-identity";2121 import { computeVaultId } from "../shared/sync-identity";2222 import { isUnlocked, getRootKeyHex } from "./vault";2323 import { probeSubnetForPort, wsPortOpen } from "./desktop-pairing-probe";2424 import { discoverPeachSync } from "./desktop-pairing-mdns";2525 import { getLocalSubnet } from "../shared/local-network";26-import type { LocalIdentity } from "../shared/chirp-handshake";26+import type { LocalIdentity } from "../shared/pairing-handshake";2727 2828 function hexToBytesLocal(hex: string): Uint8Array {2929   const out = new Uint8Array(hex.length / 2);

view file

src/background/code-pairing.test.ts+2 −2
@@ -15,9 +15,9 @@1515 import { PAIRING_PORT, type PairingSocket } from "../shared/pairing-dial";1616 1717 // REAL SAS implementation — reused, not re-derived (ground truth §5).18-import { derivePairingSAS } from "../shared/chirp-handshake";18+import { derivePairingSAS } from "../shared/pairing-handshake";1919 import { encodePairingCode } from "../shared/pairing-code";20-import type { LocalIdentity } from "../shared/chirp-handshake";20+import type { LocalIdentity } from "../shared/pairing-handshake";2121 import type { PairedDevice } from "../shared/sync-types";2222 2323 // ── helpers ────────────────────────────────────────────────────────────────

view file

src/background/code-pairing.ts+2 −2
@@ -15,7 +15,7 @@1515 // 6-digit helper code has no address, so the host is probed for.1616 1717 import { dialPairingChannel, type PairingDialDeps } from "../shared/pairing-dial";18-import type { LocalIdentity } from "../shared/chirp-handshake";18+import type { LocalIdentity } from "../shared/pairing-handshake";1919 import { parsePairingCode } from "../shared/pairing-code";2020 import type { DeviceType, PairedDevice } from "../shared/sync-types";2121 @@ -81,7 +81,7 @@8181  * `confirm` is a function the caller resolves AFTER the user has compared the8282  * SAS shown here against the SAS the peer displays. Resolving false (or8383  * rejecting) aborts before any device is persisted — this is the out-of-band84- * MITM defence (see the SECURITY note in chirp-handshake.ts), and it, not the84+ * MITM defence (see the SECURITY note in pairing-handshake.ts), and it, not the8585  * pairing token, is what actually authenticates the peer.8686  */8787 export async function pairWithCode(

view file

src/background/desktop-pairing-probe.ts+1 −1
@@ -1,6 +1,6 @@11 // Handshake-free /24 port probe for desktop pairing (Change 5).22 //3-// Unlike `probeSubnetForPeer` (chirp-sync-transport.ts), this does NOT run3+// Unlike `probeSubnetForPeer` (sync-transport.ts), this does NOT run44 // Noise — during code pairing we have no long-term pubkey, and the pairing55 // server on port 49142 speaks the plaintext PEACH_HELLO protocol, not Noise66 // XX. This probe answers only "is a WebSocket server reachable here?"; the

view file

src/background/device-identity.test.ts+85 −0
@@ -0,0 +1,85 @@1+import { beforeEach, describe, expect, it, vi } from "vitest";2+3+// PCH-01-004: deviceId must live in chrome.storage.local (device-local), never4+// in chrome.storage.sync (which roams across profiles and collides identities).5+6+type StorageShape = Record<string, unknown>;7+const DEVICE_ID_KEY = "peach_chirp_device_id";8+9+describe("device-identity getOrCreateDeviceId (PCH-01-004)", () => {10+  let local: StorageShape;11+  let sync: StorageShape;12+  let localSet: ReturnType<typeof vi.fn>;13+  let syncSet: ReturnType<typeof vi.fn>;14+  let syncRemove: ReturnType<typeof vi.fn>;15+16+  function makeArea(store: StorageShape, set: ReturnType<typeof vi.fn>, remove?: ReturnType<typeof vi.fn>) {17+    return {18+      get: vi.fn(async (key?: string | string[]) => {19+        if (!key) return store;20+        if (typeof key === "string") return { [key]: store[key] };21+        return key.reduce<Record<string, unknown>>((r, k) => {22+          r[k] = store[k];23+          return r;24+        }, {});25+      }),26+      set,27+      remove: remove ?? vi.fn(async () => undefined),28+    };29+  }30+31+  beforeEach(() => {32+    vi.resetModules();33+    local = {};34+    sync = {};35+    localSet = vi.fn(async (next: Record<string, unknown>) => {36+      Object.assign(local, next);37+    });38+    syncSet = vi.fn(async (next: Record<string, unknown>) => {39+      Object.assign(sync, next);40+    });41+    syncRemove = vi.fn(async (key: string) => {42+      delete sync[key];43+    });44+45+    const chromeMock = {46+      storage: {47+        local: makeArea(local, localSet),48+        sync: makeArea(sync, syncSet, syncRemove),49+      },50+    } as unknown as typeof chrome;51+    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;52+  });53+54+  it("migrates_legacy_sync_id_to_local_and_removes_from_sync", async () => {55+    sync[DEVICE_ID_KEY] = "legacy-roamed-id";56+    const { getOrCreateDeviceId } = await import("./device-identity");57+58+    const id = await getOrCreateDeviceId();59+60+    expect(id).toBe("legacy-roamed-id");61+    expect(local[DEVICE_ID_KEY]).toBe("legacy-roamed-id");62+    expect(syncRemove).toHaveBeenCalledWith(DEVICE_ID_KEY);63+    expect(sync[DEVICE_ID_KEY]).toBeUndefined();64+  });65+66+  it("fresh_install_writes_id_to_local_only", async () => {67+    const { getOrCreateDeviceId } = await import("./device-identity");68+69+    const id = await getOrCreateDeviceId();70+71+    expect(typeof id).toBe("string");72+    expect(id.length).toBeGreaterThan(0);73+    expect(local[DEVICE_ID_KEY]).toBe(id);74+    expect(syncSet).not.toHaveBeenCalled();75+  });76+77+  it("returns_a_stable_id_across_calls", async () => {78+    const { getOrCreateDeviceId } = await import("./device-identity");79+80+    const first = await getOrCreateDeviceId();81+    const second = await getOrCreateDeviceId();82+83+    expect(second).toBe(first);84+  });85+});

view file

src/background/device-identity.ts+146 −0
@@ -0,0 +1,146 @@1+// Local-device identity helpers for the sync architecture.2+//3+// `deviceId` is a stable UUID v4 generated on first call and persisted in4+// `chrome.storage.local`. It is deliberately device-local and must NOT roam:5+// it is the only symmetry-breaker in the sync-identity HKDF (see PCH-01-004),6+// so a shared id across profiles would collide derived sync keys. A first-read7+// migration moves any legacy value out of `chrome.storage.sync`.8+//9+// `deviceName` defaults to a short, friendly browser+OS description and10+// can be overridden by the user from Settings → Sync.11+//12+// The local sync identity itself is derived deterministically from the13+// vault master key (`deriveSyncIdentityKeypair`); the function below is a14+// thin coordinator that pulls the master key out of `vault.ts` and exposes15+// only the public half to callers that don't need to sign.16+17+import {18+  deriveSyncIdentityKeypair,19+  deriveSyncSigningKeypair,20+  pubkeyToHex,21+} from "../shared/sync-identity";22+import type { DeviceType } from "../shared/sync-types";23+24+// The storage keys keep their legacy `peach_chirp_*` names ON PURPOSE.25+// Renaming them would orphan every existing install's device identity — the26+// device would generate a fresh deviceId and every live pairing would break.27+const DEVICE_ID_KEY = "peach_chirp_device_id";28+const DEVICE_NAME_KEY = "peach_chirp_device_name";29+30+export interface LocalDeviceIdentity {31+  deviceId: string;32+  deviceName: string;33+  deviceType: DeviceType;34+  longTermPublicKeyHex: string;35+  signingPublicKeyHex: string;36+}37+38+export async function getOrCreateDeviceId(): Promise<string> {39+  // PCH-01-004: deviceId is the ONLY symmetry-breaker in the sync-identity HKDF40+  // (sync-identity.ts:54). It must NOT roam across profiles via chrome.storage.sync,41+  // or two profiles on the same browser account sharing a vault derive identical42+  // static + signing keys and collide in the device graph. Keep it device-local.43+  const local = await chrome.storage.local.get(DEVICE_ID_KEY);44+  const localId = (local as Record<string, unknown>)[DEVICE_ID_KEY];45+  if (typeof localId === "string" && localId.length > 0) return localId;46+47+  // First-read migration: adopt a value previously stored in sync, then evict it48+  // from sync so a sibling profile cannot re-roam it back. Idempotent — once the49+  // id lives in local, this branch never runs again.50+  const synced = await chrome.storage.sync.get(DEVICE_ID_KEY);51+  const syncedId = (synced as Record<string, unknown>)[DEVICE_ID_KEY];52+  if (typeof syncedId === "string" && syncedId.length > 0) {53+    await chrome.storage.local.set({ [DEVICE_ID_KEY]: syncedId });54+    await chrome.storage.sync.remove(DEVICE_ID_KEY);55+    return syncedId;56+  }57+58+  const fresh = generateUuidV4();59+  await chrome.storage.local.set({ [DEVICE_ID_KEY]: fresh });60+  return fresh;61+}62+63+export async function getDeviceName(): Promise<string> {64+  const result = await chrome.storage.sync.get(DEVICE_NAME_KEY);65+  const stored = (result as Record<string, unknown>)[DEVICE_NAME_KEY];66+  if (typeof stored === "string" && stored.length > 0) return stored;67+  return defaultDeviceName();68+}69+70+export async function setDeviceName(name: string): Promise<void> {71+  const trimmed = name.trim();72+  if (!trimmed) throw new Error("device name must not be empty");73+  if (trimmed.length > 64) throw new Error("device name must be 64 chars or fewer");74+  await chrome.storage.sync.set({ [DEVICE_NAME_KEY]: trimmed });75+}76+77+/**78+ * Build the public-facing identity record for the local extension. The79+ * private key half (used only for ongoing Noise XX sessions) is not80+ * exposed by this function — Phase-1 pairing uses ephemeral keys only.81+ */82+export async function buildLocalIdentity(83+  masterKey: Uint8Array,84+): Promise<LocalDeviceIdentity> {85+  const deviceId = await getOrCreateDeviceId();86+  const deviceName = await getDeviceName();87+  const kp = deriveSyncIdentityKeypair(masterKey, deviceId);88+  const signing = await deriveSyncSigningKeypair(masterKey, deviceId);89+  return {90+    deviceId,91+    deviceName,92+    deviceType: extensionDeviceType(),93+    longTermPublicKeyHex: pubkeyToHex(kp.publicKey),94+    signingPublicKeyHex: pubkeyToHex(signing.publicKeyRaw),95+  };96+}97+98+/**99+ * Return the local sync identity keypair (public and private). Reserved100+ * for the Phase-2 WebSocket sync coordinator that runs in the background.101+ * Never surface the private key over a message handler.102+ */103+export function deriveLocalSyncKeypair(masterKey: Uint8Array, deviceId: string) {104+  return deriveSyncIdentityKeypair(masterKey, deviceId);105+}106+107+function defaultDeviceName(): string {108+  // Best-effort browser + platform string. Avoids userAgent parsing — the109+  // user can rename via Settings.110+  const ua = (typeof navigator !== "undefined" ? navigator.userAgent : "") || "";111+  if (/Firefox/i.test(ua)) return "Firefox";112+  if (/Edg\//i.test(ua)) return "Edge";113+  if (/Chrome/i.test(ua)) return "Chrome";114+  if (/Safari/i.test(ua)) return "Safari";115+  return "Browser";116+}117+118+function extensionDeviceType(): DeviceType {119+  // The same module ships in both the Chrome extension and the Capacitor120+  // mobile build. On Capacitor, Peach acts as an iOS / Android sync host121+  // rather than an extension; the `isNativePlatform` check toggles the tag.122+  try {123+    const native = (globalThis as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor;124+    if (native?.isNativePlatform?.()) {125+      const platform = (globalThis as unknown as { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();126+      if (platform === "ios") return "ios";127+      return "android";128+    }129+  } catch { /* not on Capacitor */ }130+  return "extension";131+}132+133+function generateUuidV4(): string {134+  // crypto.randomUUID is available in MV3 service workers, popup pages,135+  // and modern Capacitor WebView. Falls back to a manual assembly only if136+  // a runtime is missing it (effectively unreachable for our targets).137+  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {138+    return crypto.randomUUID();139+  }140+  const bytes = new Uint8Array(16);141+  crypto.getRandomValues(bytes as Uint8Array<ArrayBuffer>);142+  bytes[6] = (bytes[6] & 0x0f) | 0x40;143+  bytes[8] = (bytes[8] & 0x3f) | 0x80;144+  const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");145+  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;146+}

view file

src/background/entry.ts+2 −2
@@ -8,7 +8,7 @@88 import { isRecoveryConfirmPending } from "./recovery-pending";99 import { denyAllPendingPasskeyRequests } from "./passkey";1010 import { hydratePersistedSyncState, setSyncRunner } from "./sync-trigger";11-import { productionSyncRunner } from "./chirp-sync-runner";11+import { productionSyncRunner } from "./sync-runner";1212 1313 // Keep alive for MV3 service worker1414 chrome.runtime.onInstalled.addListener(() => {});@@ -63,7 +63,7 @@6363   // a cold worker woken BY the alarm still handles it.6464   setupClipboardClearAlarm();6565   void setupPasskeyProxy();66-  // Wire the chirp-sync production runner into the dispatcher. Without this66+  // Wire the sync production runner into the dispatcher. Without this6767   // `triggerSyncIfReachable` is a no-op (the runner slot starts null) — the6868   // pairing/transport/coordinator stack ships dormant until this assignment.6969   setSyncRunner(productionSyncRunner);

view file

src/background/messages.ts+7 −7
@@ -67,7 +67,7 @@6767 import { isNativePlatform, nativePlugin } from "../shared/native";6868 import { predictBatch as mlPredictBatch } from "./ml";6969 import type { MLFieldFeatures } from "../shared/ml";70-import { buildLocalIdentity, setDeviceName as setDeviceNameV3 } from "./chirp-identity";70+import { buildLocalIdentity, setDeviceName as setDeviceNameV3 } from "./device-identity";7171 import {7272   listPairedDevices as listPairedDevicesV3,7373   removePairedDeviceById as removePairedDeviceByIdV3,@@ -202,7 +202,7 @@202202   nativeSyncRunnerBootstrapped = true;203203   try {204204     const { setSyncRunner, hydratePersistedSyncState } = await import("./sync-trigger");205-    const { productionSyncRunner } = await import("./chirp-sync-runner");205+    const { productionSyncRunner } = await import("./sync-runner");206206     setSyncRunner(productionSyncRunner);207207     await hydratePersistedSyncState();208208   } catch (err) {@@ -1331,11 +1331,11 @@13311331         return { ok: true, data: mlPredictBatch(featuresArray as MLFieldFeatures[]) };13321332       }13331333 1334-      // ── Chirp Sync (v3) ───────────────────────────────────────────────1334+      // ── Vault sync (v3) ───────────────────────────────────────────────13351335       // Returns the public-facing local identity needed by the popup to13361336       // drive Phase-1 audio pairing. The private long-term key never13371337       // leaves the background.1338-      case "CHIRP_GET_LOCAL_IDENTITY": {1338+      case "GET_LOCAL_IDENTITY": {13391339         // Not Pro-gated: a free device may attempt to pair so it can RECEIVE a13401340         // Pro license from an already-Pro peer (at least one device must be Pro).13411341         if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };@@ -1358,7 +1358,7 @@13581358         }13591359       }13601360 1361-      case "CHIRP_GET_ADOPTION_MATERIAL": {1361+      case "GET_ADOPTION_MATERIAL": {13621362         if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };13631363         const rootKeyHex = vault.getRootKeyHex();13641364         if (!rootKeyHex) return { ok: false, error: "vault_locked" };@@ -1378,8 +1378,8 @@13781378         }13791379       }13801380 1381-      case "CHIRP_COMPLETE_PAIRING": {1382-        // Not Pro-gated — see CHIRP_GET_LOCAL_IDENTITY.1381+      case "COMPLETE_PAIRING": {1382+        // Not Pro-gated — see GET_LOCAL_IDENTITY.13831383         if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };13841384         const paired = (msg.payload as { paired?: unknown })?.paired;13851385         if (typeof paired !== "object" || paired === null) {

view file

src/background/paired-devices.ts+1 −1
@@ -1,4 +1,4 @@1-// Paired-device registry — v3 chirp-sync.1+// Paired-device registry — v3 sync.22 //33 // Long-term records live in `chrome.storage.local`. Keys must not leave44 // the device via a third-party sync service — `longTermPublicKeyHex` and

view file

src/background/sync-coordinator.test.ts+305 −0
@@ -0,0 +1,305 @@1+import { describe, expect, it, vi } from "vitest";2+import { runSyncInitiator, type SyncVaultAdapter } from "./sync-coordinator";3+import type { SyncMessage, SignedDeviceManifest } from "../shared/sync-types";4+import type { SyncSession } from "../shared/sync-transport";5+import { buildSignedDeviceManifest } from "../shared/device-manifest";6+import { deriveSyncSigningKeypair } from "../shared/sync-identity";7+8+function inMemoryPair(): [SyncSession, FakePeer] {9+  const a: Uint8Array[] = [];10+  const b: Uint8Array[] = [];11+  const aR: Array<(v: Uint8Array) => void> = [];12+  const bR: Array<(v: Uint8Array) => void> = [];13+14+  const ourSession: SyncSession = {15+    send(bytes) {16+      const r = bR.shift();17+      if (r) r(bytes); else b.push(bytes);18+    },19+    receive(): Promise<Uint8Array> {20+      const v = a.shift();21+      if (v) return Promise.resolve(v);22+      return new Promise<Uint8Array>((res) => aR.push(res));23+    },24+    async close() { /* no-op */ },25+    remoteStaticPublicKey: new Uint8Array(32),26+  };27+28+  const peer: FakePeer = {29+    async send(msg: SyncMessage) {30+      const bytes = new TextEncoder().encode(JSON.stringify(msg));31+      const r = aR.shift();32+      if (r) r(bytes); else a.push(bytes);33+    },34+    async receive(): Promise<SyncMessage> {35+      const v = b.shift();36+      const bytes = v ?? await new Promise<Uint8Array>((res) => bR.push(res));37+      return JSON.parse(new TextDecoder().decode(bytes)) as SyncMessage;38+    },39+  };40+  return [ourSession, peer];41+}42+43+interface FakePeer {44+  send(msg: SyncMessage): Promise<void>;45+  receive(): Promise<SyncMessage>;46+}47+48+function makeVaultAdapter(opts: {49+  vaultId?: string;50+  deviceId?: string;51+  localVersion?: number;52+  entries?: Record<string, number>;53+  tombstones?: Record<string, number>;54+  ciphertext?: Record<string, string>;55+}): SyncVaultAdapter & {56+  appliedEntries: Record<string, string>[];57+  appliedTombstones: Record<string, number>[];58+} {59+  const appliedEntries: Record<string, string>[] = [];60+  const appliedTombstones: Record<string, number>[] = [];61+  return {62+    async computeVaultId() { return opts.vaultId ?? "vault-abc"; },63+    async getOurDeviceId() { return opts.deviceId ?? "device-init"; },64+    // Use a non-zero default so the equal-version short circuit doesn't fire in65+    // tests that aren't explicitly testing that path.66+    async getLocalVersion() { return opts.localVersion ?? 5; },67+    async buildEntryManifest() { return { ...(opts.entries ?? {}) }; },68+    async buildTombstoneManifest() { return { ...(opts.tombstones ?? {}) }; },69+    async getEncryptedEntries(ids) {70+      const out: Record<string, string> = {};71+      const cipher = opts.ciphertext ?? {};72+      for (const id of ids) if (cipher[id] !== undefined) out[id] = cipher[id];73+      return out;74+    },75+    async applyEntries(entries) { appliedEntries.push(entries); },76+    async applyTombstones(t) { appliedTombstones.push(t); },77+    appliedEntries,78+    appliedTombstones,79+  };80+}81+82+describe("runSyncInitiator — HELLO fields and version optimisation", () => {83+  it("sends HELLO with version and deviceTime", async () => {84+    const [session, peer] = inMemoryPair();85+    const vault = makeVaultAdapter({ localVersion: 7 });86+87+    const initPromise = runSyncInitiator(session, vault);88+89+    const sentHello = await peer.receive();90+    expect(sentHello.type).toBe("SYNC_HELLO");91+    if (sentHello.type === "SYNC_HELLO") {92+      expect(sentHello.version).toBe(7);93+      expect(typeof sentHello.deviceTime).toBe("number");94+      expect(sentHello.deviceTime).toBeGreaterThan(0);95+    }96+    // Send a mismatched vaultId to abort early so the test doesn't hang.97+    await peer.send({ type: "SYNC_HELLO", vaultId: "different-vault", deviceId: "p", version: 7, deviceTime: Date.now() });98+    await peer.receive(); // SYNC_ERROR99+    await initPromise;100+  });101+102+  it("skips manifest exchange and returns completed when versions are equal", async () => {103+    const [session, peer] = inMemoryPair();104+    const vault = makeVaultAdapter({ localVersion: 10 });105+106+    const initPromise = runSyncInitiator(session, vault);107+108+    await peer.receive(); // SYNC_HELLO109+    await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 10, deviceTime: Date.now() });110+111+    // Coordinator should send SYNC_DONE without any manifest exchange.112+    const done = await peer.receive();113+    expect(done.type).toBe("SYNC_DONE");114+115+    // No SYNC_MANIFEST was sent — peer's queue only has the DONE.116+    await peer.send({ type: "SYNC_DONE" });117+118+    const outcome = await initPromise;119+    expect(outcome.status).toBe("completed");120+    expect(outcome.entriesReceived).toBe(0);121+    expect(outcome.entriesSent).toBe(0);122+  });123+124+  it("logs a clock-skew warning but does not abort when clocks differ by > 120s", async () => {125+    const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);126+    const [session, peer] = inMemoryPair();127+    const vault = makeVaultAdapter({ localVersion: 5 });128+129+    const initPromise = runSyncInitiator(session, vault);130+131+    await peer.receive(); // SYNC_HELLO132+    // Peer reports a deviceTime 200 s in the past — beyond the 120 s threshold.133+    await peer.send({134+      type: "SYNC_HELLO",135+      vaultId: "vault-abc",136+      deviceId: "p",137+      version: 3, // different version so exchange proceeds138+      deviceTime: Date.now() - 200_000,139+    });140+    // Yield so the coordinator's async continuation can run the clock-skew check.141+    await new Promise((r) => setTimeout(r, 0));142+    expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("clock skew"));143+144+    // Drive the sync to completion so the test doesn't leak.145+    await peer.receive(); // SYNC_MANIFEST146+    await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });147+    await peer.receive(); // SYNC_REQUEST148+    await peer.send({ type: "SYNC_REQUEST", entryIds: [] });149+    await peer.receive(); // SYNC_ENTRIES150+    await peer.receive(); // SYNC_TOMBSTONES151+    await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });152+    await peer.receive(); // SYNC_DONE153+    await peer.send({ type: "SYNC_DONE" });154+155+    const outcome = await initPromise;156+    expect(outcome.status).toBe("completed");157+    warnSpy.mockRestore();158+  });159+});160+161+describe("runSyncInitiator", () => {162+  it("completes a full sync with bi-directional updates", async () => {163+    const [session, peer] = inMemoryPair();164+    const vault = makeVaultAdapter({165+      entries: { a: 100, b: 50 }, // we have 'a' at t=100, 'b' at t=50166+      tombstones: { x: 70 },167+      ciphertext: { a: "ct-a", b: "ct-b" },168+    });169+170+    const initPromise = runSyncInitiator(session, vault);171+172+    const peerProtocol = (async () => {173+      const hello = await peer.receive();174+      expect(hello.type).toBe("SYNC_HELLO");175+      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "device-peer", version: 3, deviceTime: Date.now() });176+177+      const ourManifest = await peer.receive();178+      expect(ourManifest.type).toBe("SYNC_MANIFEST");179+      await peer.send({180+        type: "SYNC_MANIFEST",181+        entries: { a: 80, b: 90, c: 200 }, // we have newer 'a'; peer has newer 'b' and 'c'182+        tombstones: { x: 60, y: 99 },        // peer has newer tombstone for 'y'183+      });184+185+      const ourRequest = await peer.receive();186+      expect(ourRequest.type).toBe("SYNC_REQUEST");187+      // We should request the entries the peer reported newer188+      if (ourRequest.type === "SYNC_REQUEST") {189+        expect(ourRequest.entryIds.sort()).toEqual(["b", "c"]);190+      }191+      // Peer asks us for entries the peer claimed it had older versions of192+      await peer.send({ type: "SYNC_REQUEST", entryIds: ["a"] });193+194+      const ourEntries = await peer.receive();195+      expect(ourEntries.type).toBe("SYNC_ENTRIES");196+      if (ourEntries.type === "SYNC_ENTRIES") {197+        expect(ourEntries.entries).toEqual({ a: "ct-a" });198+      }199+      await peer.send({ type: "SYNC_ENTRIES", entries: { b: "ct-b-new", c: "ct-c" } });200+201+      const ourTombs = await peer.receive();202+      expect(ourTombs.type).toBe("SYNC_TOMBSTONES");203+      if (ourTombs.type === "SYNC_TOMBSTONES") {204+        // We had x@70, peer had x@60, so we ship x@70205+        expect(ourTombs.tombstones).toEqual({ x: 70 });206+      }207+      await peer.send({ type: "SYNC_TOMBSTONES", tombstones: { y: 99 } });208+209+      const ourDone = await peer.receive();210+      expect(ourDone.type).toBe("SYNC_DONE");211+      await peer.send({ type: "SYNC_DONE" });212+    })();213+214+    const outcome = await initPromise;215+    await peerProtocol;216+217+    expect(outcome.status).toBe("completed");218+    expect(outcome.entriesSent).toBe(1);219+    expect(outcome.entriesReceived).toBe(2);220+    expect(outcome.tombstonesSent).toBe(1);221+    expect(outcome.tombstonesReceived).toBe(1);222+    expect(vault.appliedEntries).toEqual([{ b: "ct-b-new", c: "ct-c" }]);223+    expect(vault.appliedTombstones).toEqual([{ y: 99 }]);224+  });225+226+  it("aborts with vault_mismatch on a peer with a different vault_id", async () => {227+    const [session, peer] = inMemoryPair();228+    const vault = makeVaultAdapter({ vaultId: "vault-A" });229+230+    const initPromise = runSyncInitiator(session, vault);231+    const peerProtocol = (async () => {232+      const hello = await peer.receive();233+      expect(hello.type).toBe("SYNC_HELLO");234+      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-B", deviceId: "evil", version: 3, deviceTime: Date.now() });235+      // Peer should receive a SYNC_ERROR and we should stop.236+      const err = await peer.receive();237+      expect(err.type).toBe("SYNC_ERROR");238+    })();239+240+    const outcome = await initPromise;241+    await peerProtocol;242+    expect(outcome.status).toBe("vault_mismatch");243+  });244+245+  it("exchanges device manifests when both adapters opt in", async () => {246+    const [session, peer] = inMemoryPair();247+    const ourKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x11), "us");248+    const peerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x22), "them");249+250+    const outgoing = await buildSignedDeviceManifest("us", [], ourKp.privateKey);251+    const incomingFromPeer = await buildSignedDeviceManifest("them", [], peerKp.privateKey);252+    const applied: SignedDeviceManifest[] = [];253+254+    const base = makeVaultAdapter({});255+    const vault: SyncVaultAdapter = {256+      ...base,257+      async buildOutgoingDeviceManifest() { return outgoing; },258+      async applyIncomingDeviceManifest(m) { applied.push(m); },259+    };260+261+    const initPromise = runSyncInitiator(session, vault);262+    const peerProtocol = (async () => {263+      await peer.receive(); // hello264+      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now() });265+      await peer.receive(); // manifest266+      await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });267+      await peer.receive(); // request268+      await peer.send({ type: "SYNC_REQUEST", entryIds: [] });269+      await peer.receive(); // entries270+      await peer.receive(); // tombstones271+      await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });272+      const ourManifest = await peer.receive();273+      expect(ourManifest.type).toBe("SYNC_DEVICE_MANIFEST");274+      if (ourManifest.type === "SYNC_DEVICE_MANIFEST") {275+        expect(ourManifest.manifest.body.issuerDeviceId).toBe("us");276+      }277+      await peer.send({ type: "SYNC_DEVICE_MANIFEST", manifest: incomingFromPeer });278+      await peer.receive(); // done279+      await peer.send({ type: "SYNC_DONE" });280+    })();281+282+    const outcome = await initPromise;283+    await peerProtocol;284+    expect(outcome.status).toBe("completed");285+    expect(applied).toHaveLength(1);286+    expect(applied[0].body.issuerDeviceId).toBe("them");287+  });288+289+  it("returns errored when peer sends a wrong frame", async () => {290+    const [session, peer] = inMemoryPair();291+    const vault = makeVaultAdapter({ vaultId: "v" });292+293+    const initPromise = runSyncInitiator(session, vault);294+    void (async () => {295+      await peer.receive(); // hello296+      await peer.send({ type: "SYNC_HELLO", vaultId: "v", deviceId: "p", version: 3, deviceTime: Date.now() });297+      await peer.receive(); // our manifest298+      await peer.send({ type: "SYNC_DONE" }); // wrong frame299+    })();300+301+    const outcome = await initPromise;302+    expect(outcome.status).toBe("errored");303+    expect(outcome.error).toMatch(/SYNC_MANIFEST/);304+  });305+});

view file

src/background/sync-coordinator.ts+295 −0
@@ -0,0 +1,295 @@1+// Vault sync — Phase 2 manifest-diff coordinator.2+//3+// After Noise XX is open and the responder's static key has been verified4+// against the paired-device registry, this coordinator drives the5+// manifest exchange:6+//7+//   ->  SYNC_HELLO { vaultId, deviceId }8+//   <-  SYNC_HELLO { vaultId, deviceId }       (mismatch → SYNC_ERROR)9+//   <-> SYNC_MANIFEST { entries, tombstones }  (each side sends one)10+//   ->  SYNC_REQUEST { entryIds }              (entries newer on the peer)11+//   <-  SYNC_ENTRIES { entries }12+//   <-  SYNC_REQUEST { entryIds }              (entries newer on us)13+//   ->  SYNC_ENTRIES { entries }14+//   <-> SYNC_TOMBSTONES { tombstones }15+//   <-> SYNC_DONE16+//17+// Entries are opaque base64 ciphertext — the coordinator never decrypts.18+// Last-write-wins at the entry level by mtime; tombstones override updates19+// with a later deletion timestamp.20+21+import type { SyncMessage, SignedDeviceManifest } from "../shared/sync-types";22+import type { SyncSession } from "../shared/sync-transport";23+import { SYNC_LIMITS } from "../shared/sync-limits";24+25+const SESSION_TIMEOUT_MS = 30_000;26+27+// Sentinel signature used when an adapter can't build a real manifest (e.g.28+// the master key isn't accessible from the coordinator context). The peer29+// recognises this exact value and skips signature verification. 64 hex30+// chars of zero is impossible to produce as a real Ed25519 signature for a31+// fresh body, so collisions are negligible.32+const EMPTY_MANIFEST_PLACEHOLDER: SignedDeviceManifest = {33+  body: { manifestVersion: 1, issuerDeviceId: "", issuedAt: 0, devices: [] },34+  signatureHex: "0".repeat(128),35+};36+37+/**38+ * Vault-side adapter — the coordinator's dependency on the vault layer.39+ * The vault holds the canonical entry/tombstone tables; the coordinator40+ * pulls a manifest, requests entries by id, applies remote entries, and41+ * applies remote tombstones.42+ */43+export interface SyncVaultAdapter {44+  computeVaultId(): Promise<string>;45+  getOurDeviceId(): Promise<string>;46+  /** Returns the vault's monotonic version (`peach_last_modified_at`). */47+  getLocalVersion(): Promise<number>;48+  /** Returns the per-entry mtimes for our active entries. */49+  buildEntryManifest(): Promise<Record<string, number>>;50+  /** Returns the per-entry deletion timestamps for our tombstones. */51+  buildTombstoneManifest(): Promise<Record<string, number>>;52+  /** Returns base64 ciphertext for each requested entry id. */53+  getEncryptedEntries(entryIds: string[]): Promise<Record<string, string>>;54+  /** Apply incoming encrypted entries; vault decides per-entry overwrite. */55+  applyEntries(entries: Record<string, string>): Promise<void>;56+  /** Apply incoming tombstones; vault retains the latest deletion ts. */57+  applyTombstones(tombstones: Record<string, number>): Promise<void>;58+  /**59+   * Optional — v2 brief §4.3 device manifest exchange. The extension (as60+   * the trust anchor for the device graph) returns a signed manifest61+   * enumerating every paired device it knows about; the peer verifies it62+   * against the extension's known Ed25519 signing pubkey and upserts any63+   * newly-vouched devices into its own registry.64+   *65+   * When this method is undefined, the coordinator skips the manifest66+   * step entirely. Both ends must opt in for the exchange to occur.67+   */68+  buildOutgoingDeviceManifest?(): Promise<SignedDeviceManifest | null>;69+  /** v2 brief §4.3 — handle a manifest received from the peer. */70+  applyIncomingDeviceManifest?(manifest: SignedDeviceManifest): Promise<void>;71+}72+73+export interface SyncOutcome {74+  status: "completed" | "vault_mismatch" | "errored" | "timeout";75+  entriesReceived: number;76+  entriesSent: number;77+  tombstonesReceived: number;78+  tombstonesSent: number;79+  error?: string;80+}81+82+/**83+ * Run one sync round as the initiator. The extension is always the84+ * initiator (it is the WebSocket client). The peer's role is symmetric85+ * after SYNC_HELLO, so a corresponding responder helper exists for the86+ * mobile-side coordinator that calls this same protocol the other way.87+ */88+export async function runSyncInitiator(89+  session: SyncSession,90+  vault: SyncVaultAdapter,91+): Promise<SyncOutcome> {92+  const result: SyncOutcome = {93+    status: "errored",94+    entriesReceived: 0,95+    entriesSent: 0,96+    tombstonesReceived: 0,97+    tombstonesSent: 0,98+  };99+100+  const timer = scheduleTimeout(SESSION_TIMEOUT_MS);101+  try {102+    const ourVaultId = await vault.computeVaultId();103+    const ourDeviceId = await vault.getOurDeviceId();104+    const localVersion = await vault.getLocalVersion();105+    const localTime = Date.now();106+107+    await sendMessage(session, {108+      type: "SYNC_HELLO",109+      vaultId: ourVaultId,110+      deviceId: ourDeviceId,111+      version: localVersion,112+      deviceTime: localTime,113+    });114+    const helloIn = await receiveMessage(session, timer);115+    if (helloIn.type !== "SYNC_HELLO") {116+      return errorOutcome(result, `expected SYNC_HELLO, got ${helloIn.type}`);117+    }118+    if (helloIn.vaultId !== ourVaultId) {119+      await sendMessage(session, { type: "SYNC_ERROR", error: "vault_id_mismatch" });120+      result.status = "vault_mismatch";121+      return result;122+    }123+124+    if (Math.abs(localTime - (helloIn.deviceTime ?? 0)) > 120_000) {125+      console.warn(`[sync] clock skew detected: local=${localTime} remote=${helloIn.deviceTime ?? "unknown"}`);126+    }127+128+    // Equal versions — nothing to exchange.129+    if (typeof helloIn.version === "number" && helloIn.version === localVersion) {130+      await sendMessage(session, { type: "SYNC_DONE", entriesReceived: 0, entriesSent: 0 });131+      const done = await receiveMessage(session, timer);132+      if (done.type !== "SYNC_DONE") {133+        return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);134+      }135+      result.status = "completed";136+      return result;137+    }138+139+    const ourEntries = await vault.buildEntryManifest();140+    const ourTombstones = await vault.buildTombstoneManifest();141+    await sendMessage(session, {142+      type: "SYNC_MANIFEST",143+      entries: ourEntries,144+      tombstones: ourTombstones,145+    });146+    const manifestIn = await receiveMessage(session, timer);147+    if (manifestIn.type !== "SYNC_MANIFEST") {148+      return errorOutcome(result, `expected SYNC_MANIFEST, got ${manifestIn.type}`);149+    }150+151+    const wantFromPeer = diffNewer(manifestIn.entries, ourEntries);152+    await sendMessage(session, { type: "SYNC_REQUEST", entryIds: wantFromPeer });153+154+    const requestIn = await receiveMessage(session, timer);155+    if (requestIn.type !== "SYNC_REQUEST") {156+      return errorOutcome(result, `expected SYNC_REQUEST, got ${requestIn.type}`);157+    }158+    // Sanity: the peer's request must be a subset of what its manifest159+    // declared as newer. A malformed/over-eager request is a protocol160+    // violation but not a data-safety problem — we just send what they ask.161+162+    const ourPayload = await vault.getEncryptedEntries(requestIn.entryIds);163+    await sendMessage(session, { type: "SYNC_ENTRIES", entries: ourPayload });164+    result.entriesSent = Object.keys(ourPayload).length;165+166+    if (wantFromPeer.length > 0) {167+      const entriesIn = await receiveMessage(session, timer);168+      if (entriesIn.type !== "SYNC_ENTRIES") {169+        return errorOutcome(result, `expected SYNC_ENTRIES, got ${entriesIn.type}`);170+      }171+      await vault.applyEntries(entriesIn.entries);172+      result.entriesReceived = Object.keys(entriesIn.entries).length;173+    }174+175+    // Tombstones: each side ships the tombstones that the other does not176+    // yet have a newer record for.177+    const newerTombstonesForPeer: Record<string, number> = {};178+    for (const [id, ts] of Object.entries(ourTombstones)) {179+      const peerTs = manifestIn.tombstones[id] ?? 0;180+      if (ts > peerTs) newerTombstonesForPeer[id] = ts;181+    }182+    await sendMessage(session, { type: "SYNC_TOMBSTONES", tombstones: newerTombstonesForPeer });183+    result.tombstonesSent = Object.keys(newerTombstonesForPeer).length;184+185+    const tombstonesIn = await receiveMessage(session, timer);186+    if (tombstonesIn.type !== "SYNC_TOMBSTONES") {187+      return errorOutcome(result, `expected SYNC_TOMBSTONES, got ${tombstonesIn.type}`);188+    }189+    await vault.applyTombstones(tombstonesIn.tombstones);190+    result.tombstonesReceived = Object.keys(tombstonesIn.tombstones).length;191+192+    // v2 brief §5.6 / §4.3 — device manifest exchange. Optional: both ends193+    // must expose the adapter methods. Disabling on either side cleanly194+    // bypasses the step. The exchange is symmetric on the wire; the195+    // production adapter still treats the extension as the trust anchor and196+    // only applies extension-issued manifests.197+    if (vault.buildOutgoingDeviceManifest && vault.applyIncomingDeviceManifest) {198+      const ours = await vault.buildOutgoingDeviceManifest();199+      if (ours) {200+        await sendMessage(session, { type: "SYNC_DEVICE_MANIFEST", manifest: ours });201+      } else {202+        // No manifest to send (e.g. master key unavailable) — still emit a203+        // protocol frame so the peer's recv doesn't hang. Empty body is204+        // serialised by the adapter when present; if it returned null we205+        // synthesise a no-op shape the receiver can ignore.206+        await sendMessage(session, {207+          type: "SYNC_DEVICE_MANIFEST",208+          manifest: EMPTY_MANIFEST_PLACEHOLDER,209+        });210+      }211+      const manifestIn = await receiveMessage(session, timer);212+      if (manifestIn.type !== "SYNC_DEVICE_MANIFEST") {213+        return errorOutcome(result, `expected SYNC_DEVICE_MANIFEST, got ${manifestIn.type}`);214+      }215+      if (manifestIn.manifest && manifestIn.manifest.signatureHex !== EMPTY_MANIFEST_PLACEHOLDER.signatureHex) {216+        await vault.applyIncomingDeviceManifest(manifestIn.manifest);217+      }218+    }219+220+    await sendMessage(session, {221+      type: "SYNC_DONE",222+      entriesReceived: result.entriesReceived,223+      entriesSent: result.entriesSent,224+    });225+    const done = await receiveMessage(session, timer);226+    if (done.type !== "SYNC_DONE") {227+      return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);228+    }229+    result.status = "completed";230+    return result;231+  } catch (err) {232+    if ((err as Error).message === "sync_timeout") {233+      result.status = "timeout";234+      result.error = "session timed out after 30s";235+      return result;236+    }237+    return errorOutcome(result, (err as Error).message);238+  } finally {239+    // Session lifetime is owned by the runner (sync-runner.ts), which240+    // closes it in its own finally block. The coordinator must not close it241+    // here — the runner still needs to record the outcome after we return.242+    timer.cancel();243+  }244+}245+246+function diffNewer(remote: Record<string, number>, local: Record<string, number>): string[] {247+  const want: string[] = [];248+  for (const [id, ts] of Object.entries(remote)) {249+    const ours = local[id];250+    if (ours === undefined || ts > ours) want.push(id);251+  }252+  return want;253+}254+255+interface Timer {256+  signal: AbortSignal;257+  cancel(): void;258+}259+260+function scheduleTimeout(ms: number): Timer {261+  const ctrl = new AbortController();262+  const t = setTimeout(() => ctrl.abort(new Error("sync_timeout")), ms);263+  return {264+    signal: ctrl.signal,265+    cancel: () => clearTimeout(t),266+  };267+}268+269+async function sendMessage(session: SyncSession, msg: SyncMessage): Promise<void> {270+  session.send(new TextEncoder().encode(JSON.stringify(msg)));271+}272+273+async function receiveMessage(session: SyncSession, timer: Timer): Promise<SyncMessage> {274+  const pending = session.receive();275+  const aborted = new Promise<never>((_, reject) => {276+    if (timer.signal.aborted) reject(new Error("sync_timeout"));277+    timer.signal.addEventListener("abort", () => reject(new Error("sync_timeout")));278+  });279+  const bytes = await Promise.race([pending, aborted]);280+  // MV-3: decrypted plaintext can exceed the wire frame (and a non-WS281+  // transport may not have enforced anything) — re-check before JSON.parse282+  // materialises a multi-GB string/object graph.283+  if (bytes.length > SYNC_LIMITS.MAX_FRAME_BYTES) {284+    throw new Error(`sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes)`);285+  }286+  const parsed = JSON.parse(new TextDecoder().decode(bytes));287+  if (typeof parsed?.type !== "string") throw new Error("malformed SyncMessage");288+  return parsed as SyncMessage;289+}290+291+function errorOutcome(outcome: SyncOutcome, error: string): SyncOutcome {292+  outcome.status = "errored";293+  outcome.error = error;294+  return outcome;295+}

view file

src/background/sync-runner.test.ts+415 −0
@@ -0,0 +1,415 @@1+import { beforeEach, describe, expect, it, vi } from "vitest";2+import type { PairedDevice, SyncMessage } from "../shared/sync-types";3+import type {4+  SyncSession,5+  SyncEndpoint,6+} from "../shared/sync-transport";7+import type { SyncVaultAdapter } from "./sync-coordinator";8+import type { SyncRunnerDeps } from "./sync-runner";9+10+type StorageShape = Record<string, unknown>;11+12+function makeChromeMock(local: StorageShape, sync: StorageShape): typeof chrome {13+  const area = (store: StorageShape) => ({14+    get: vi.fn(async (key?: string | string[]) => {15+      if (!key) return store;16+      if (typeof key === "string") return { [key]: store[key] };17+      return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = store[k]; return acc; }, {});18+    }),19+    set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),20+    remove: vi.fn(async (keys: string | string[]) => {21+      const list = Array.isArray(keys) ? keys : [keys];22+      for (const k of list) delete store[k];23+    }),24+  });25+  return {26+    storage: { local: area(local), sync: area(sync) },27+    alarms: {28+      clear: vi.fn(),29+      create: vi.fn(),30+      onAlarm: { addListener: vi.fn() },31+    },32+    idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },33+    runtime: { sendMessage: vi.fn(async () => undefined) },34+  } as unknown as typeof chrome;35+}36+37+const REMOTE_PUBKEY_HEX = "ab".repeat(32);38+39+function makeDevice(overrides: Partial<PairedDevice> = {}): PairedDevice {40+  return {41+    deviceId: "pixel-xyz",42+    deviceName: "Pixel",43+    deviceType: "android",44+    longTermPublicKeyHex: REMOTE_PUBKEY_HEX,45+    wsPort: 8765,46+    pairedAt: 1700000000000,47+    ...overrides,48+  };49+}50+51+interface FakePeer {52+  send(msg: SyncMessage): Promise<void>;53+  receive(): Promise<SyncMessage>;54+}55+56+/**57+ * In-memory SyncSession ↔ FakePeer pair. The session is what the58+ * runner passes to runSyncInitiator; the peer is the other end of the59+ * wire, controlled by the test body. Mirrors the pattern in60+ * sync-coordinator.test.ts.61+ */62+function inMemoryPair(): { session: SyncSession; peer: FakePeer; closed: { v: boolean } } {63+  const inboundToSession: Uint8Array[] = [];64+  const outboundFromSession: Uint8Array[] = [];65+  const sessionReaders: Array<(v: Uint8Array) => void> = [];66+  const peerReaders: Array<(v: Uint8Array) => void> = [];67+  const closed = { v: false };68+69+  const session: SyncSession = {70+    send(bytes) {71+      const r = peerReaders.shift();72+      if (r) r(bytes); else outboundFromSession.push(bytes);73+    },74+    receive(): Promise<Uint8Array> {75+      const v = inboundToSession.shift();76+      if (v) return Promise.resolve(v);77+      return new Promise<Uint8Array>((res) => sessionReaders.push(res));78+    },79+    async close() { closed.v = true; },80+    remoteStaticPublicKey: new Uint8Array(32),81+  };82+  const peer: FakePeer = {83+    async send(msg) {84+      const bytes = new TextEncoder().encode(JSON.stringify(msg));85+      const r = sessionReaders.shift();86+      if (r) r(bytes); else inboundToSession.push(bytes);87+    },88+    async receive() {89+      const v = outboundFromSession.shift();90+      const bytes = v ?? await new Promise<Uint8Array>((res) => peerReaders.push(res));91+      return JSON.parse(new TextDecoder().decode(bytes)) as SyncMessage;92+    },93+  };94+  return { session, peer, closed };95+}96+97+/**98+ * Drive a happy-path SYNC_DONE handshake on the peer side, matching the99+ * coordinator's HELLO → MANIFEST → REQUEST → ENTRIES → TOMBSTONES → DONE100+ * sequence. `peerEntries` lets the peer send a SYNC_ENTRIES payload of any101+ * size for the entriesReceived assertion; `wantFromUs` requests N entries102+ * from our side (drives entriesSent).103+ */104+async function runPeer(105+  peer: FakePeer,106+  opts: {107+    vaultId?: string;108+    peerEntriesB64?: Record<string, string>;109+    wantFromUsIds?: string[];110+  } = {},111+): Promise<void> {112+  const hello = await peer.receive();113+  if (hello.type !== "SYNC_HELLO") throw new Error("expected HELLO from initiator");114+  await peer.send({115+    type: "SYNC_HELLO",116+    vaultId: opts.vaultId ?? hello.vaultId,117+    deviceId: "peer-device",118+    version: 0,119+    deviceTime: Date.now(),120+  });121+  const manifest = await peer.receive();122+  if (manifest.type !== "SYNC_MANIFEST") throw new Error("expected MANIFEST");123+124+  // Peer manifest declares the entries it will ship in SYNC_ENTRIES.125+  const peerEntryManifest: Record<string, number> = {};126+  for (const id of Object.keys(opts.peerEntriesB64 ?? {})) {127+    peerEntryManifest[id] = Date.now();128+  }129+  await peer.send({130+    type: "SYNC_MANIFEST",131+    entries: peerEntryManifest,132+    tombstones: {},133+  });134+135+  const ourRequest = await peer.receive();136+  if (ourRequest.type !== "SYNC_REQUEST") throw new Error("expected REQUEST from initiator");137+138+  // Peer asks for the ids it wants from us.139+  await peer.send({ type: "SYNC_REQUEST", entryIds: opts.wantFromUsIds ?? [] });140+141+  const ourEntries = await peer.receive();142+  if (ourEntries.type !== "SYNC_ENTRIES") throw new Error("expected ENTRIES from initiator");143+144+  // Only ship SYNC_ENTRIES when the initiator actually requested any. The145+  // coordinator only awaits this frame when wantFromPeer.length > 0.146+  if (Object.keys(peerEntryManifest).length > 0) {147+    await peer.send({ type: "SYNC_ENTRIES", entries: opts.peerEntriesB64 ?? {} });148+  }149+150+  const ourTombstones = await peer.receive();151+  if (ourTombstones.type !== "SYNC_TOMBSTONES") throw new Error("expected TOMBSTONES");152+  await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });153+154+  const done = await peer.receive();155+  if (done.type !== "SYNC_DONE") throw new Error("expected DONE");156+  await peer.send({ type: "SYNC_DONE" });157+}158+159+function emptyAdapter(): SyncVaultAdapter {160+  return {161+    async computeVaultId() { return "vault-abc"; },162+    async getOurDeviceId() { return "device-init"; },163+    // Non-zero so the equal-version short circuit doesn't fire in runner tests164+    // that exercise the full manifest-diff flow.165+    async getLocalVersion() { return 5; },166+    async buildEntryManifest() { return {}; },167+    async buildTombstoneManifest() { return {}; },168+    async getEncryptedEntries() { return {}; },169+    async applyEntries() { /* no-op */ },170+    async applyTombstones() { /* no-op */ },171+  };172+}173+174+describe("productionSyncRunner", () => {175+  let local: StorageShape;176+  let sync: StorageShape;177+178+  beforeEach(() => {179+    vi.resetModules();180+    local = {};181+    sync = {};182+    (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);183+    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));184+  });185+186+  async function bootVault(): Promise<typeof import("./vault")> {187+    const vault = await import("./vault");188+    await vault.createVault("correct horse battery staple");189+    return vault;190+  }191+192+  it("returns VAULT_LOCKED result (does not throw) when the vault is locked", async () => {193+    const vault = await bootVault();194+    vault.lock();195+    const { runWithDeps } = await import("./sync-runner");196+    const deps: SyncRunnerDeps = {197+      openSession: vi.fn(),198+      probeSubnet: vi.fn(),199+      getSubnet: vi.fn(async () => "192.168.1"),200+      createAdapter: emptyAdapter,201+    };202+    const result = await runWithDeps(makeDevice(), deps);203+    expect(result).toEqual({ ok: false, error: "VAULT_LOCKED" });204+    expect(deps.openSession).not.toHaveBeenCalled();205+  });206+207+  it("throws when the device has no longTermPublicKeyHex", async () => {208+    await bootVault();209+    const { runWithDeps } = await import("./sync-runner");210+    const deps: SyncRunnerDeps = {211+      openSession: vi.fn(),212+      probeSubnet: vi.fn(),213+      getSubnet: vi.fn(),214+      createAdapter: emptyAdapter,215+    };216+    await expect(217+      runWithDeps(makeDevice({ longTermPublicKeyHex: undefined }), deps),218+    ).rejects.toThrow("paired_device_missing_pubkey");219+  });220+221+  it("uses the cached last-known IP first and skips the subnet probe", async () => {222+    await bootVault();223+    const { setLastKnownIp } = await import("./paired-devices");224+    await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.42");225+226+    const { session, peer } = inMemoryPair();227+    const openSession = vi.fn(async (_e: SyncEndpoint) => session);228+    const probeSubnet = vi.fn();229+    const deps: SyncRunnerDeps = {230+      openSession,231+      probeSubnet,232+      getSubnet: vi.fn(async () => "192.168.1"),233+      createAdapter: emptyAdapter,234+    };235+236+    const { runWithDeps } = await import("./sync-runner");237+    const runPromise = runWithDeps(makeDevice(), deps);238+    await runPeer(peer);239+    const result = await runPromise;240+    expect(result).toEqual({ ok: true, entriesReceived: 0, entriesSent: 0 });241+    expect(openSession).toHaveBeenCalledTimes(1);242+    expect(openSession.mock.calls[0][0]).toEqual({ ip: "192.168.1.42", port: 8765 });243+    expect(probeSubnet).not.toHaveBeenCalled();244+  });245+246+  it("falls back to subnet probe when cached IP fails to connect", async () => {247+    await bootVault();248+    const { setLastKnownIp, getLastKnownIp } = await import("./paired-devices");249+    await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.13");250+251+    const { session, peer } = inMemoryPair();252+    const openSession = vi.fn()253+      .mockRejectedValueOnce(new Error("ECONNREFUSED"))254+      .mockResolvedValueOnce(session);255+    const probeSubnet = vi.fn(async () => ({ ip: "192.168.1.99", port: 8765 }));256+    const deps: SyncRunnerDeps = {257+      openSession,258+      probeSubnet,259+      getSubnet: vi.fn(async () => "192.168.1"),260+      createAdapter: emptyAdapter,261+    };262+263+    const { runWithDeps } = await import("./sync-runner");264+    const runPromise = runWithDeps(makeDevice(), deps);265+    await runPeer(peer);266+    await runPromise;267+    expect(openSession).toHaveBeenCalledTimes(2);268+    expect(openSession.mock.calls[1][0]).toEqual({ ip: "192.168.1.99", port: 8765 });269+    expect(probeSubnet).toHaveBeenCalledTimes(1);270+    expect(await getLastKnownIp(REMOTE_PUBKEY_HEX)).toBe("192.168.1.99");271+  });272+273+  it("throws host_unreachable when cached IP fails AND subnet probe returns null", async () => {274+    await bootVault();275+    const { setLastKnownIp } = await import("./paired-devices");276+    await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.13");277+278+    const deps: SyncRunnerDeps = {279+      openSession: vi.fn().mockRejectedValue(new Error("ECONNREFUSED")),280+      probeSubnet: vi.fn(async () => null),281+      getSubnet: vi.fn(async () => "192.168.1"),282+      createAdapter: emptyAdapter,283+    };284+    const { runWithDeps } = await import("./sync-runner");285+    await expect(runWithDeps(makeDevice(), deps)).rejects.toThrow("host_unreachable");286+  });287+288+  it("throws subnet_unknown when no cached IP and the local subnet can't be determined", async () => {289+    await bootVault();290+    const deps: SyncRunnerDeps = {291+      openSession: vi.fn(),292+      probeSubnet: vi.fn(),293+      getSubnet: vi.fn(async () => null),294+      createAdapter: emptyAdapter,295+    };296+    const { runWithDeps } = await import("./sync-runner");297+    await expect(runWithDeps(makeDevice(), deps)).rejects.toThrow("subnet_unknown");298+  });299+300+  it("surfaces entriesReceived from the peer's SYNC_ENTRIES payload", async () => {301+    await bootVault();302+    const { session, peer } = inMemoryPair();303+    const openSession = vi.fn(async () => session);304+    const deps: SyncRunnerDeps = {305+      openSession,306+      probeSubnet: vi.fn(async () => ({ ip: "192.168.1.1", port: 8765 })),307+      getSubnet: vi.fn(async () => "192.168.1"),308+      createAdapter: emptyAdapter,309+    };310+    const { runWithDeps } = await import("./sync-runner");311+    const runPromise = runWithDeps(makeDevice(), deps);312+    await runPeer(peer, {313+      peerEntriesB64: { "id-1": "AAAA", "id-2": "BBBB", "id-3": "CCCC" },314+    });315+    const result = await runPromise;316+    expect(result.ok).toBe(true);317+    if (result.ok) {318+      expect(result.entriesReceived).toBe(3);319+      expect(result.entriesSent).toBe(0);320+    }321+  });322+323+  it("surfaces entriesSent from the adapter's encrypted-entry response", async () => {324+    await bootVault();325+    const { session, peer } = inMemoryPair();326+    const adapter: SyncVaultAdapter = {327+      ...emptyAdapter(),328+      async getEncryptedEntries(ids) {329+        const out: Record<string, string> = {};330+        for (const id of ids) out[id] = "ENVELOPE_" + id;331+        return out;332+      },333+    };334+    const deps: SyncRunnerDeps = {335+      openSession: vi.fn(async () => session),336+      probeSubnet: vi.fn(async () => ({ ip: "192.168.1.1", port: 8765 })),337+      getSubnet: vi.fn(async () => "192.168.1"),338+      createAdapter: () => adapter,339+    };340+    const { runWithDeps } = await import("./sync-runner");341+    const runPromise = runWithDeps(makeDevice(), deps);342+    await runPeer(peer, { wantFromUsIds: ["a", "b"] });343+    const result = await runPromise;344+    expect(result.ok).toBe(true);345+    if (result.ok) expect(result.entriesSent).toBe(2);346+  });347+348+  it("translates a vault_id_mismatch coordinator outcome into an error throw", async () => {349+    await bootVault();350+    const { session, peer } = inMemoryPair();351+    const deps: SyncRunnerDeps = {352+      openSession: vi.fn(async () => session),353+      probeSubnet: vi.fn(async () => ({ ip: "192.168.1.1", port: 8765 })),354+      getSubnet: vi.fn(async () => "192.168.1"),355+      createAdapter: emptyAdapter,356+    };357+    const { runWithDeps } = await import("./sync-runner");358+    const runPromise = runWithDeps(makeDevice(), deps);359+    // Peer responds with a vault_id that does NOT match ours, forcing the360+    // coordinator to send SYNC_ERROR and return status:"vault_mismatch".361+    (async () => {362+      const hello = await peer.receive();363+      if (hello.type === "SYNC_HELLO") {364+        await peer.send({365+          type: "SYNC_HELLO",366+          vaultId: "wrong-vault-id",367+          deviceId: "peer-device",368+          version: 0,369+          deviceTime: Date.now(),370+        });371+      }372+    })().catch(() => undefined);373+    await expect(runPromise).rejects.toThrow("vault_id_mismatch");374+  });375+});376+377+describe("sync-trigger persistence", () => {378+  let local: StorageShape;379+  let sync: StorageShape;380+381+  beforeEach(() => {382+    vi.resetModules();383+    local = {};384+    sync = {};385+    (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);386+    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));387+  });388+389+  it("persists lastSyncAt to chrome.storage.local on a successful sync", async () => {390+    const trigger = await import("./sync-trigger");391+    const paired = await import("./paired-devices");392+    trigger._resetSyncTriggerForTest();393+    await paired.upsertPairedDevice({394+      deviceId: "P",395+      deviceName: "Pixel",396+      deviceType: "android",397+      longTermPublicKeyHex: "a".repeat(64),398+      trustSource: "direct",399+      pairedAt: 1700000000000,400+    });401+    trigger.setSyncRunner(async () => ({ ok: true, entriesReceived: 1, entriesSent: 0 }));402+    await trigger.triggerSyncIfReachable({ reason: "manual" });403+    expect(typeof local.peach_last_sync_at).toBe("number");404+    expect(local.peach_last_sync_at).toBeGreaterThan(0);405+  });406+407+  it("hydratePersistedSyncState fills lastSyncAt from storage when in-memory state is empty", async () => {408+    local.peach_last_sync_at = 1_700_005_000_000;409+    const trigger = await import("./sync-trigger");410+    trigger._resetSyncTriggerForTest();411+    expect(trigger.getSyncState().lastSyncAt).toBeUndefined();412+    await trigger.hydratePersistedSyncState();413+    expect(trigger.getSyncState().lastSyncAt).toBe(1_700_005_000_000);414+  });415+});

view file

src/background/sync-runner.ts+181 −0
@@ -0,0 +1,181 @@1+// Production sync runner — composes the live vault adapter, WebSocket2+// transport, and coordinator into the function the dispatcher injects via3+// `setSyncRunner`. The runner is responsible for opening one Noise XX4+// session against a single paired device, driving the sync protocol, and5+// surfacing a `SyncRunResult` (or throwing on failure). The dispatcher in6+// sync-trigger.ts iterates over hosts and aggregates results.7+//8+// Connection strategy:9+//   1. Try `getLastKnownIp(deviceLongTermPubkey)` if cached — fast path.10+//   2. Fall back to `probeSubnetForPeer` over the local /24. The subnet11+//      probe is itself rate-limited (60s per-pubkey cooldown) inside the12+//      transport, so we don't add another layer here.13+//   3. On a successful session, persist the IP we connected on so the next14+//      sync round skips the probe.15+//16+// We do NOT verify vault_id here — the coordinator's SYNC_HELLO exchange17+// does that (returns `status: "vault_mismatch"` if the peer holds a18+// different vault). The runner just translates the coordinator's outcome19+// into the throw-or-return contract the dispatcher expects.20+21+import { runSyncInitiator, type SyncVaultAdapter } from "./sync-coordinator";22+import { createSyncVaultAdapter } from "./sync-vault-adapter";23+import {24+  getLastKnownIp,25+  setLastKnownIp,26+} from "./paired-devices";27+import {28+  openSyncSession,29+  probeSubnetForPeer,30+  type SyncSession,31+  type SyncEndpoint,32+} from "../shared/sync-transport";33+import { deriveSyncIdentityKeypair, hexToPubkey } from "../shared/sync-identity";34+import { getLocalSubnet } from "../shared/local-network";35+import { getOrCreateDeviceId } from "./device-identity";36+import { getRootKeyHex, isUnlocked } from "./vault";37+import type { PairedDevice } from "../shared/sync-types";38+import type { SyncRunResult } from "./sync-trigger";39+40+const DEFAULT_PORT = 8765;41+42+/**43+ * Test/injection seam. Production wires in the real `openSyncSession`;44+ * unit tests inject a fake to avoid real WebSocket + Noise XX.45+ */46+export interface SyncRunnerDeps {47+  openSession: (48+    endpoint: SyncEndpoint,49+    ourPriv: Uint8Array,50+    ourPub: Uint8Array,51+    expectedRemoteHex: string,52+  ) => Promise<SyncSession>;53+  probeSubnet: (54+    subnet: string,55+    port: number,56+    ourPriv: Uint8Array,57+    ourPub: Uint8Array,58+    expectedRemoteHex: string,59+  ) => Promise<SyncEndpoint | null>;60+  getSubnet: () => Promise<string | null>;61+  createAdapter: (context?: { deviceName?: string }) => SyncVaultAdapter;62+}63+64+const productionDeps: SyncRunnerDeps = {65+  openSession: (endpoint, priv, pub, expected) =>66+    openSyncSession(endpoint, priv, pub, expected),67+  probeSubnet: (subnet, port, priv, pub, expected) =>68+    probeSubnetForPeer(subnet, port, priv, pub, expected),69+  getSubnet: getLocalSubnet,70+  createAdapter: (context) => createSyncVaultAdapter(context),71+};72+73+export async function productionSyncRunner(device: PairedDevice): Promise<SyncRunResult> {74+  return runWithDeps(device, productionDeps);75+}76+77+/**78+ * Inner runner with explicit deps — exported so tests can inject fakes.79+ */80+export async function runWithDeps(81+  device: PairedDevice,82+  deps: SyncRunnerDeps,83+): Promise<SyncRunResult> {84+  if (!isUnlocked()) return { ok: false, error: "VAULT_LOCKED" };85+  if (!device.longTermPublicKeyHex) {86+    throw new Error("paired_device_missing_pubkey");87+  }88+  const expectedRemoteHex = device.longTermPublicKeyHex;89+  const port = device.wsPort ?? DEFAULT_PORT;90+91+  // Sync identity derives from the stable root key, not the password-derived92+  // key — keeps the Noise XX static key constant across password changes (SEV-2-B).93+  const masterKeyHex = getRootKeyHex();94+  if (!masterKeyHex) return { ok: false, error: "VAULT_LOCKED" };95+  const masterKey = hexToBytesLocal(masterKeyHex);96+  const ourDeviceId = await getOrCreateDeviceId();97+  const ourKeypair = deriveSyncIdentityKeypair(masterKey, ourDeviceId);98+99+  // Sanity — the remote pubkey must be 32-byte hex so noise can compare it.100+  void hexToPubkey(expectedRemoteHex);101+102+  // HV-1b: pass the peer being synced so remote-mutation alerts can name it.103+  const adapter = deps.createAdapter({ deviceName: device.deviceName });104+  const session = await connect(device, expectedRemoteHex, port, ourKeypair, deps);105+106+  try {107+    const outcome = await runSyncInitiator(session, adapter);108+    if (outcome.status === "completed") {109+      return {110+        ok: true,111+        entriesReceived: outcome.entriesReceived,112+        entriesSent: outcome.entriesSent,113+      };114+    }115+    if (outcome.status === "vault_mismatch") {116+      throw new Error("vault_id_mismatch");117+    }118+    if (outcome.status === "timeout") {119+      throw new Error("sync_timeout");120+    }121+    throw new Error(outcome.error ?? "sync_errored");122+  } finally {123+    // The coordinator does NOT close the session (it returns and leaves the124+    // session open). This runner is the sole owner of session lifetime.125+    await session.close().catch(() => undefined);126+  }127+}128+129+async function connect(130+  _device: PairedDevice,131+  expectedRemoteHex: string,132+  port: number,133+  ourKeypair: { secretKey: Uint8Array; publicKey: Uint8Array },134+  deps: SyncRunnerDeps,135+): Promise<SyncSession> {136+  const cached = await getLastKnownIp(expectedRemoteHex);137+  if (cached) {138+    try {139+      const session = await deps.openSession(140+        { ip: cached, port },141+        ourKeypair.secretKey,142+        ourKeypair.publicKey,143+        expectedRemoteHex,144+      );145+      return session;146+    } catch {147+      // Cached IP stale — fall through to the subnet probe.148+    }149+  }150+151+  const subnet = await deps.getSubnet();152+  if (!subnet) {153+    throw new Error(cached ? "host_unreachable" : "subnet_unknown");154+  }155+156+  const found = await deps.probeSubnet(157+    subnet,158+    port,159+    ourKeypair.secretKey,160+    ourKeypair.publicKey,161+    expectedRemoteHex,162+  );163+  if (!found) throw new Error("host_unreachable");164+165+  const session = await deps.openSession(166+    found,167+    ourKeypair.secretKey,168+    ourKeypair.publicKey,169+    expectedRemoteHex,170+  );171+  await setLastKnownIp(expectedRemoteHex, found.ip).catch(() => undefined);172+  return session;173+}174+175+function hexToBytesLocal(hex: string): Uint8Array {176+  const out = new Uint8Array(hex.length / 2);177+  for (let i = 0; i < out.length; i++) {178+    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);179+  }180+  return out;181+}

view file

src/background/sync-static.test.ts+97 −0
@@ -0,0 +1,97 @@1+// IV.7 — Static safety assertions for vault sync.2+// These tests read source files to enforce structural invariants that3+// are cheaper to verify statically than to exercise at runtime.4+5+import { describe, expect, it } from "vitest";6+import { readFileSync } from "node:fs";7+import { resolve } from "node:path";8+9+const here = __dirname;10+const src = (rel: string) => readFileSync(resolve(here, rel), "utf8");11+12+describe("vault sync — static safety assertions", () => {13+  it("no WebSocket server in any browser-extension sync source file", () => {14+    const files = [15+      "./sync-trigger.ts",16+      "./sync-runner.ts",17+      "./sync-coordinator.ts",18+      "./sync-vault-adapter.ts",19+      "../shared/sync-transport.ts",20+    ];21+    const forbidden = /WebSocketServer|wss\.listen|createServer|http\.listen|net\.listen/;22+    for (const f of files) {23+      expect(src(f), `${f} must not start a WebSocket server`).not.toMatch(forbidden);24+    }25+  });26+27+  it("applyMergedRemoteEntries does not call fireSyncTrigger or TRIGGER_SYNC", () => {28+    const vaultSrc = src("./vault.ts");29+    const fnStart = vaultSrc.indexOf("applyMergedRemoteEntries");30+    expect(fnStart, "applyMergedRemoteEntries must exist in vault.ts").toBeGreaterThan(-1);31+    // Grab a generous slice starting from the function — large enough to cover32+    // the implementation but not so large that it bleeds into unrelated code.33+    const fnBody = vaultSrc.slice(fnStart, fnStart + 4000);34+    expect(fnBody).not.toMatch(/triggerSyncIfReachable|TRIGGER_SYNC|fireSyncTrigger/);35+  });36+37+  it("addEntryDirect / updateEntryDirect / deleteEntryDirect do not call fireSyncTrigger", () => {38+    const vaultSrc = src("./vault.ts");39+    for (const fn of ["addEntryDirect", "updateEntryDirect", "deleteEntryDirect"]) {40+      const start = vaultSrc.indexOf(`function ${fn}`);41+      if (start === -1) continue;42+      // Slice up to the start of the very next export declaration so we don't43+      // bleed into adjacent function bodies or their JSDoc comments.44+      const nextExport = vaultSrc.indexOf("\nexport ", start + fn.length);45+      const end = nextExport > start ? nextExport : start + 600;46+      const body = vaultSrc.slice(start, end);47+      expect(body, `${fn} must not call fireSyncTrigger`).not.toMatch(48+        /triggerSyncIfReachable|TRIGGER_SYNC|fireSyncTrigger/,49+      );50+    }51+  });52+53+  it("device registry is stored in chrome.storage.local, not chrome.storage.sync", () => {54+    const registrySrc = src("./paired-devices.ts");55+    expect(registrySrc).toContain("storage.local");56+    expect(registrySrc).not.toMatch(57+      /storage\.sync[^;]*peach_paired_devices|peach_paired_devices[^;]*storage\.sync/,58+    );59+  });60+61+  it("setSyncRunner and productionSyncRunner are called in entry.ts", () => {62+    const entrySrc = src("./entry.ts");63+    expect(entrySrc).toContain("setSyncRunner");64+    expect(entrySrc).toContain("productionSyncRunner");65+  });66+67+  it("sync-trigger syncs both direct and user-confirmed vouched hosts", () => {68+    const triggerSrc = src("./sync-trigger.ts");69+    expect(triggerSrc).toMatch(/trustSource === "direct"/);70+    // Mesh introduction is pointless if a device the user confirmed out of the71+    // MV-7 pending queue is then never dialled.72+    expect(triggerSrc).toMatch(/trustSource === "vouched"/);73+  });74+75+  it("the browser never listens — no pairing server or audio-chirp module survives", () => {76+    const appSrc = src("../popup/App.tsx");77+    expect(appSrc).not.toContain("startMDNSListener");78+    expect(appSrc).not.toContain("openPairingServer");79+    expect(appSrc).not.toContain("chirp-pair-capacitor");80+    // Adoption dials out like everything else now.81+    expect(appSrc).toContain("dialPairingChannel");82+  });83+84+  it("vault_id uses the full 64-char BLAKE3 hex digest", () => {85+    const identitySrc = src("../shared/sync-identity.ts");86+    expect(identitySrc).toContain("VAULT_ID_HEX_LENGTH = 64");87+    expect(identitySrc).not.toContain("VAULT_ID_HEX_LENGTH = 16");88+  });89+90+  it("per-entry key derivation uses entryId as part of HKDF info", () => {91+    const adapterSrc = src("./sync-vault-adapter.ts");92+    expect(adapterSrc).toContain("ENTRY_KEY_INFO_SUFFIX");93+    expect(adapterSrc).toContain("sync-entry-v1");94+    // The info must be derived from entryId — look for the concat pattern.95+    expect(adapterSrc).toMatch(/entryId.*ENTRY_KEY_INFO_SUFFIX|ENTRY_KEY_INFO_SUFFIX.*entryId/);96+  });97+});

view file

src/background/sync-trigger.desktop.test.ts+9 −9
@@ -15,9 +15,9 @@1515 } from "./sync-trigger";1616 import { upsertPairedDevice } from "./paired-devices";1717 import { computeVaultId } from "../shared/sync-identity";18-import { runChirpSyncInitiator, type ChirpSyncVaultAdapter } from "./chirp-sync-coordinator";19-import type { ChirpSyncSession } from "../shared/chirp-sync-transport";20-import type { ChirpSyncMessage } from "../shared/sync-types";18+import { runSyncInitiator, type SyncVaultAdapter } from "./sync-coordinator";19+import type { SyncSession } from "../shared/sync-transport";20+import type { SyncMessage } from "../shared/sync-types";2121 import type { PairedDevice } from "../shared/sync-types";2222 2323 // Minimal chrome.storage.local fake — paired-devices and sync-trigger both read@@ -90,19 +90,19 @@9090 9191     // Capture frames the coordinator sends; reply just enough to reach the9292     // equal-version SYNC_DONE shortcut so the round completes cleanly.93-    const sent: ChirpSyncMessage[] = [];94-    const inbound: ChirpSyncMessage[] = [93+    const sent: SyncMessage[] = [];94+    const inbound: SyncMessage[] = [9595       { type: "SYNC_HELLO", vaultId: ourVaultId, deviceId: "desktop-001", version: 7, deviceTime: Date.now() },9696       { type: "SYNC_DONE", entriesReceived: 0, entriesSent: 0 },9797     ];9898     let rx = 0;99-    const session: ChirpSyncSession = {99+    const session: SyncSession = {100100       send: (bytes: Uint8Array) => { sent.push(JSON.parse(new TextDecoder().decode(bytes))); },101101       receive: async () => new TextEncoder().encode(JSON.stringify(inbound[rx++])),102102       close: async () => {},103-    } as unknown as ChirpSyncSession;103+    } as unknown as SyncSession;104104 105-    const adapter: ChirpSyncVaultAdapter = {105+    const adapter: SyncVaultAdapter = {106106       computeVaultId: async () => ourVaultId,107107       getOurDeviceId: async () => "ext-local",108108       getLocalVersion: async () => 7, // equal version → SYNC_DONE shortcut@@ -113,7 +113,7 @@113113       applyTombstones: async () => {},114114     };115115 116-    const outcome = await runChirpSyncInitiator(session, adapter);116+    const outcome = await runSyncInitiator(session, adapter);117117 118118     const hello = sent.find((m) => m.type === "SYNC_HELLO") as119119       | { type: "SYNC_HELLO"; vaultId: string }

view file

src/background/sync-trigger.ts+3 −3
@@ -1,14 +1,14 @@11 // sync-trigger.ts — central sync dispatcher.22 //3-// SEV-1-A fix: the chirp-sync coordinator + transport were complete but3+// SEV-1-A fix: the sync coordinator + transport were complete but44 // never invoked from production code paths. This module is the single55 // entry point for the four trigger conditions (vault change, popup66 // foreground, manual "Sync Now", and mDNS peer discovery) and is the7-// only place that calls `runChirpSyncInitiator` outside tests.7+// only place that calls `runSyncInitiator` outside tests.88 //99 // The actual per-device session runner is injected so the orchestrator1010 // can be unit-tested without WebSocket/Noise transport. Production wires11-// in `runChirpSyncWithDevice` from `./sync-runner` at module load.11+// in `productionSyncRunner` from `./sync-runner` at module load.1212 1313 import {1414   getLastSyncAt,

view file

src/background/sync-vault-adapter.test.ts+596 −0
@@ -0,0 +1,596 @@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("SyncVaultAdapter", () => {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("./sync-coordinator").SyncVaultAdapter }> {43+    const vault = await import("./vault");44+    await vault.createVault("correct horse battery staple");45+    const { createSyncVaultAdapter } = await import("./sync-vault-adapter");46+    const adapter = createSyncVaultAdapter();47+    return { vault, adapter };48+  }49+50+  async function signingPubkeyHex(deviceId: string): Promise<string> {51+    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");52+    const kp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x42), deviceId);53+    return pubkeyToHex(kp.publicKeyRaw);54+  }55+56+  it("computeVaultId returns a stable 64-char hex string for the same masterKey", async () => {57+    const { adapter } = await setupVault();58+    const id1 = await adapter.computeVaultId();59+    const id2 = await adapter.computeVaultId();60+    expect(id1).toBe(id2);61+    expect(id1).toMatch(/^[0-9a-f]{64}$/);62+  });63+64+  it("vault_id and sync signing key are unchanged after a password change (SEV-2-B)", async () => {65+    const { vault, adapter } = await setupVault();66+    const deviceId = await adapter.getOurDeviceId();67+    const vaultIdBefore = await adapter.computeVaultId();68+69+    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");70+    const rootBefore = vault.getRootKeyHex()!;71+    const unhex = (h: string) => Uint8Array.from(h.match(/../g)!.map((b) => parseInt(b, 16)));72+    const signBefore = pubkeyToHex(73+      (await deriveSyncSigningKeypair(unhex(rootBefore), deviceId)).publicKeyRaw,74+    );75+76+    expect(await vault.changePassword("correct horse battery staple", "a different passphrase")).toBe(true);77+    vault.lock();78+    expect(await vault.unlock("a different passphrase")).toBe(true);79+80+    // vault_id derives from the stable root key, so it must not move.81+    expect(await adapter.computeVaultId()).toBe(vaultIdBefore);82+    const rootAfter = vault.getRootKeyHex()!;83+    expect(rootAfter).toBe(rootBefore);84+    const signAfter = pubkeyToHex(85+      (await deriveSyncSigningKeypair(unhex(rootAfter), deviceId)).publicKeyRaw,86+    );87+    expect(signAfter).toBe(signBefore);88+  });89+90+  it("getOurDeviceId is stable across calls and a fresh UUID v4 on first call", async () => {91+    const { adapter } = await setupVault();92+    const id1 = await adapter.getOurDeviceId();93+    const id2 = await adapter.getOurDeviceId();94+    expect(id1).toBe(id2);95+    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}$/);96+  });97+98+  it("buildEntryManifest reports {id: modifiedAt} for live entries only", async () => {99+    const { vault, adapter } = await setupVault();100+    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });101+    const b = await vault.addEntry({ type: "login", title: "Beta", password: "pw" });102+    await vault.deleteEntry(b.id);103+    const m = await adapter.buildEntryManifest();104+    expect(Object.keys(m).sort()).toEqual([a.id]);105+    expect(m[a.id]).toBe(a.modifiedAt);106+  });107+108+  it("buildTombstoneManifest reports {id: deletedAt} for soft-deleted entries", async () => {109+    const { vault, adapter } = await setupVault();110+    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });111+    await vault.deleteEntry(a.id);112+    const t = await adapter.buildTombstoneManifest();113+    expect(Object.keys(t)).toEqual([a.id]);114+    expect(typeof t[a.id]).toBe("number");115+    expect(t[a.id]).toBeGreaterThan(0);116+  });117+118+  it("getEncryptedEntries returns opaque base64 strings keyed by id", async () => {119+    const { vault, adapter } = await setupVault();120+    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });121+    const env = await adapter.getEncryptedEntries([a.id]);122+    expect(Object.keys(env)).toEqual([a.id]);123+    expect(env[a.id]).toMatch(/^[A-Za-z0-9+/]+=*$/);124+    // Plaintext title must not be visible in the envelope.125+    expect(env[a.id]).not.toContain("Alpha");126+  });127+128+  it("getEncryptedEntries silently skips unknown ids (peer may have a manifest item we dropped)", async () => {129+    const { adapter } = await setupVault();130+    const env = await adapter.getEncryptedEntries(["does-not-exist"]);131+    expect(env).toEqual({});132+  });133+134+  it("encryption uses a fresh nonce per call — same entry yields different envelopes", async () => {135+    const { vault, adapter } = await setupVault();136+    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });137+    const e1 = await adapter.getEncryptedEntries([a.id]);138+    const e2 = await adapter.getEncryptedEntries([a.id]);139+    expect(e1[a.id]).not.toBe(e2[a.id]);140+  });141+142+  it("round-trips: encrypt on one adapter, apply on a fresh import of the same vault", async () => {143+    const { vault, adapter } = await setupVault();144+    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "secret", url: "https://a.example" });145+    const envelopes = await adapter.getEncryptedEntries([a.id]);146+    // Simulate a remote vault by bumping modifiedAt on the envelope's source147+    // and then applying it back — we can't easily fork the module's storage,148+    // so we just verify decrypt path by overwriting modifiedAt locally.149+    // Delete the local entry first, then apply the envelope — applyMergedRemoteEntries150+    // will re-add it because the local copy is tombstoned with an OLDER ts (since151+    // we set modifiedAt forward by 1s in the new envelope payload).152+    // Instead, simpler proof: apply the envelope as-is to a fresh adapter import.153+    vault.lock();154+    await vault.unlock("correct horse battery staple");155+    const { createSyncVaultAdapter } = await import("./sync-vault-adapter");156+    const adapter2 = createSyncVaultAdapter();157+    // applyEntries should be a no-op when local mtime >= envelope mtime158+    await adapter2.applyEntries(envelopes);159+    expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Alpha");160+  });161+162+  it("applyEntries decrypts and merges newer remote entries (last-write-wins)", async () => {163+    const { vault, adapter } = await setupVault();164+    const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" });165+    // Manually produce a fresher remote envelope by encrypting an updated entry shape.166+    // We can't easily synthesise this without the entry key — so we use a second167+    // adapter instance backed by the same vault, after mutating the underlying entry.168+    await vault.updateEntry(local.id, { title: "Updated" });169+    const newer = vault.getEntries().find((e) => e.id === local.id);170+    expect(newer?.title).toBe("Updated");171+    const envelopes = await adapter.getEncryptedEntries([local.id]);172+    // Now revert the local entry to an older mtime by re-creating from scratch via173+    // applyMergedRemoteEntries — but we'd loop. The most reliable proof of the174+    // decrypt+merge path is that applyEntries on a fresher envelope keeps the175+    // newer state, and on an older envelope is a no-op. We've covered the no-op176+    // path above; for the apply path, simulate by replacing the in-memory entry177+    // with an older copy and re-running applyEntries.178+    const older: typeof local = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" };179+    await vault.restoreEntries([older], "replace");180+    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale");181+    await adapter.applyEntries(envelopes);182+    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated");183+  });184+185+  it("applyEntries rejects envelopes whose decrypted id differs from the map key", async () => {186+    const { vault, adapter } = await setupVault();187+    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });188+    const env = await adapter.getEncryptedEntries([a.id]);189+    // Re-key the envelope under a different id — adapter must refuse to merge it.190+    const swapped: Record<string, string> = { "wrong-id": env[a.id] };191+    await adapter.applyEntries(swapped);192+    // Local entry remains unchanged; no new entry under "wrong-id"193+    expect(vault.getEntries().map((e) => e.id)).toEqual([a.id]);194+  });195+196+  it("entry_envelope_roundtrips_with_separated_info (HKDF separator)", async () => {197+    const { vault, adapter } = await setupVault();198+    const a = await vault.addEntry({ type: "login", title: "Original", password: "pw" });199+    await vault.updateEntry(a.id, { title: "Fresh" });200+    const envelopes = await adapter.getEncryptedEntries([a.id]);201+    // Roll local back to an older copy, then apply: encrypt+decrypt both use the202+    // null-byte-separated info, so the round-trip must succeed.203+    const stale = { ...a, modifiedAt: a.modifiedAt - 1000, title: "Stale" };204+    await vault.restoreEntries([stale], "replace");205+    await adapter.applyEntries(envelopes);206+    expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Fresh");207+  });208+209+  it("legacy_info_envelope_fails_closed (HKDF separator wire break)", async () => {210+    // Mint an envelope under the OLD, unseparated info (`entryId + suffix`) and211+    // assert the patched adapter cannot decrypt it — it drops silently. Locks in212+    // the conscious wire break and serves as the fixture for any future migration.213+    const { vault, adapter } = await setupVault();214+    const { HKDF } = await import("@stablelib/hkdf");215+    const { SHA256 } = await import("@stablelib/sha256");216+217+    const masterHex = vault.getRootKeyHex()!;218+    const masterKey = Uint8Array.from(masterHex.match(/../g)!.map((b) => parseInt(b, 16)));219+    const entryId = crypto.randomUUID();220+    const legacyInfo = new TextEncoder().encode(entryId + "sync-entry-v1"); // no \x00221+    const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), legacyInfo).expand(32);222+    const key = await crypto.subtle.importKey("raw", raw.buffer as ArrayBuffer, { name: "AES-GCM" }, false, ["encrypt"]);223+224+    const entry = { id: entryId, type: "login", title: "LegacyOnly", password: "pw", createdAt: Date.now(), modifiedAt: Date.now() };225+    const nonce = crypto.getRandomValues(new Uint8Array(12));226+    const aad = new TextEncoder().encode("peach-sync-entry-v1");227+    const ct = new Uint8Array(228+      await crypto.subtle.encrypt(229+        { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, additionalData: aad.buffer as ArrayBuffer, tagLength: 128 },230+        key,231+        new TextEncoder().encode(JSON.stringify(entry)).buffer as ArrayBuffer,232+      ),233+    );234+    const envelope = new Uint8Array(nonce.length + ct.length);235+    envelope.set(nonce, 0);236+    envelope.set(ct, nonce.length);237+    const legacyB64 = Buffer.from(envelope).toString("base64");238+239+    await adapter.applyEntries({ [entryId]: legacyB64 });240+    // Decrypt fails under the new separated info → envelope dropped, entry absent.241+    expect(vault.getEntries().some((e) => e.id === entryId)).toBe(false);242+  });243+244+  it("applyTombstones soft-deletes locally-live entries with newer deletedAt", async () => {245+    const { vault, adapter } = await setupVault();246+    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });247+    await adapter.applyTombstones({ [a.id]: a.modifiedAt + 10_000 });248+    expect(vault.getEntries().find((e) => e.id === a.id)).toBeUndefined();249+    expect(vault.getDeletedEntries().find((e) => e.id === a.id)).toBeDefined();250+  });251+252+  it("applyTombstones leaves entries alone when local modifiedAt is newer", async () => {253+    const { vault, adapter } = await setupVault();254+    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });255+    await adapter.applyTombstones({ [a.id]: a.modifiedAt - 10_000 });256+    expect(vault.getEntries().find((e) => e.id === a.id)).toBeDefined();257+  });258+259+  // --- MV-3: sync receive bounds ---260+261+  it("MV-3: applyEntries throws when the batch exceeds MAX_ENTRY_COUNT", async () => {262+    const { adapter } = await setupVault();263+    const { SYNC_LIMITS } = await import("../shared/sync-limits");264+    const tooMany: Record<string, string> = {};265+    for (let i = 0; i <= SYNC_LIMITS.MAX_ENTRY_COUNT; i++) tooMany[`id-${i}`] = "AAAA";266+    await expect(adapter.applyEntries(tooMany)).rejects.toThrow(/exceeds limit/);267+  });268+269+  it("MV-3: applyEntries skips an oversized envelope but still applies valid ones", async () => {270+    const { vault, adapter } = await setupVault();271+    const { SYNC_LIMITS } = await import("../shared/sync-limits");272+    const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" });273+    await vault.updateEntry(local.id, { title: "Updated" });274+    const envelopes = await adapter.getEncryptedEntries([local.id]);275+    // Make the local copy stale so the valid envelope would win on merge.276+    const older = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" };277+    await vault.restoreEntries([older], "replace");278+    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale");279+280+    const oversized = "A".repeat(281+      Math.ceil((SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3) + 128,282+    );283+    await adapter.applyEntries({ ...envelopes, "oversized-id": oversized });284+285+    // The valid envelope applied; the oversized one was skipped (never decrypted).286+    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated");287+    expect(vault.getEntries().some((e) => e.id === "oversized-id")).toBe(false);288+  });289+290+  it("MV-3: applyTombstones throws when the batch exceeds MAX_TOMBSTONE_COUNT", async () => {291+    const { adapter } = await setupVault();292+    const { SYNC_LIMITS } = await import("../shared/sync-limits");293+    const tooMany: Record<string, number> = {};294+    for (let i = 0; i <= SYNC_LIMITS.MAX_TOMBSTONE_COUNT; i++) tooMany[`id-${i}`] = i + 1;295+    await expect(adapter.applyTombstones(tooMany)).rejects.toThrow(/exceeds limit/);296+  });297+298+  it("buildOutgoingDeviceManifest signs manifestable paired devices and skips legacy entries without signing keys", async () => {299+    const { adapter } = await setupVault();300+    const { upsertPairedDevice } = await import("./paired-devices");301+    await upsertPairedDevice({302+      deviceId: "android-1",303+      deviceName: "Pixel",304+      deviceType: "android",305+      longTermPublicKeyHex: "a".repeat(64),306+      signingPublicKeyHex: await signingPubkeyHex("android-1"),307+      wsPort: 49152,308+      pairedAt: 1_700_000_000_000,309+      trustSource: "direct",310+    });311+    await upsertPairedDevice({312+      deviceId: "legacy-android",313+      deviceName: "Legacy Pixel",314+      deviceType: "android",315+      longTermPublicKeyHex: "b".repeat(64),316+      pairedAt: 1_700_000_000_100,317+      trustSource: "direct",318+    });319+320+    const manifest = await adapter.buildOutgoingDeviceManifest?.();321+    expect(manifest).toBeDefined();322+    expect(manifest?.signatureHex).toMatch(/^[0-9a-f]{128}$/);323+    expect(manifest?.body.devices).toHaveLength(1);324+    expect(manifest?.body.devices[0]).toMatchObject({325+      deviceId: "android-1",326+      deviceName: "Pixel",327+      deviceType: "android",328+      longTermPublicKeyHex: "a".repeat(64),329+      wsPort: 49152,330+    });331+  });332+333+  it("MV-7: applyIncomingDeviceManifest queues unknown vouched devices for confirmation and updates existing ones in place", async () => {334+    const { adapter } = await setupVault();335+    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");336+    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");337+    const {338+      findPairedDeviceById,339+      listPairedDevices,340+      upsertPairedDevice,341+    } = await import("./paired-devices");342+    const { listPendingVouchedDevices } = await import(343+      "./pending-vouched-devices"344+    );345+346+    const issuerId = "extension-issuer";347+    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);348+    await upsertPairedDevice({349+      deviceId: issuerId,350+      deviceName: "Firefox",351+      deviceType: "extension",352+      longTermPublicKeyHex: "c".repeat(64),353+      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),354+      pairedAt: 1_700_000_000_000,355+      trustSource: "direct",356+    });357+    await upsertPairedDevice({358+      deviceId: "android-existing",359+      deviceName: "Existing Pixel",360+      deviceType: "android",361+      longTermPublicKeyHex: "d".repeat(64),362+      signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),363+      pairedAt: 1_700_000_000_010,364+      trustSource: "direct",365+    });366+367+    const manifest = await buildSignedDeviceManifest(368+      issuerId,369+      [370+        {371+          deviceId: "android-new",372+          deviceName: "New Pixel",373+          deviceType: "android",374+          longTermPublicKeyHex: "e".repeat(64),375+          signingPublicKeyHex: await signingPubkeyHex("android-new"),376+          wsPort: 51234,377+          pairedAt: 1_700_000_000_020,378+        },379+        {380+          deviceId: "android-existing",381+          deviceName: "Existing Pixel renamed",382+          deviceType: "android",383+          longTermPublicKeyHex: "f".repeat(64),384+          signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),385+          wsPort: 52345,386+          pairedAt: 1_700_000_000_030,387+        },388+      ],389+      issuerKp.privateKey,390+    );391+392+    await adapter.applyIncomingDeviceManifest?.(manifest);393+394+    // MV-7: the never-seen device is NOT auto-paired; it waits in the pending395+    // queue for explicit user confirmation.396+    expect(await findPairedDeviceById("android-new")).toBeNull();397+    const pending = await listPendingVouchedDevices();398+    expect(pending.map((d) => d.deviceId)).toEqual(["android-new"]);399+    expect(pending[0]).toMatchObject({400+      deviceName: "New Pixel",401+      longTermPublicKeyHex: "e".repeat(64),402+      vouchedByDeviceId: "extension-issuer",403+    });404+405+    // The already-paired device is still updated in place.406+    const existing = await findPairedDeviceById("android-existing");407+    expect(existing).toMatchObject({408+      deviceName: "Existing Pixel renamed",409+      longTermPublicKeyHex: "f".repeat(64),410+      trustSource: "direct",411+      wsPort: 52345,412+    });413+    expect((await listPairedDevices()).map((d) => d.deviceId).sort()).toEqual([414+      "android-existing",415+      "extension-issuer",416+    ]);417+  });418+419+  it("MV-8d: records a DEVICE_KEY_ROTATED alert when an existing device's key changes in a manifest", async () => {420+    const { adapter } = await setupVault();421+    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");422+    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");423+    const { upsertPairedDevice } = await import("./paired-devices");424+    const { getPendingSyncAlerts } = await import("./sync-alerts");425+426+    const issuerId = "extension-issuer";427+    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);428+    await upsertPairedDevice({429+      deviceId: issuerId,430+      deviceName: "Firefox",431+      deviceType: "extension",432+      longTermPublicKeyHex: "c".repeat(64),433+      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),434+      pairedAt: 1,435+      trustSource: "direct",436+    });437+    await upsertPairedDevice({438+      deviceId: "android-existing",439+      deviceName: "Pixel",440+      deviceType: "android",441+      longTermPublicKeyHex: "d".repeat(64),442+      signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),443+      pairedAt: 2,444+      trustSource: "direct",445+    });446+447+    const manifest = await buildSignedDeviceManifest(448+      issuerId,449+      [450+        {451+          deviceId: "android-existing",452+          deviceName: "Pixel",453+          deviceType: "android",454+          longTermPublicKeyHex: "f".repeat(64),455+          signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),456+          wsPort: 52345,457+          pairedAt: 3,458+        },459+      ],460+      issuerKp.privateKey,461+    );462+463+    await adapter.applyIncomingDeviceManifest?.(manifest);464+    // recordSyncAlerts is fire-and-forget — flush microtasks.465+    for (let i = 0; i < 25; i++) await Promise.resolve();466+467+    const alerts = await getPendingSyncAlerts();468+    expect(469+      alerts.some(470+        (a) => a.type === "DEVICE_KEY_ROTATED" && a.deviceName === "Pixel",471+      ),472+    ).toBe(true);473+  });474+475+  it("accepts a manifest from the phone it paired with — mesh introduction runs in that direction", async () => {476+    // The browser can never listen, so it joins the mesh THROUGH a native peer;477+    // that peer is the one holding the peer list. A phone-issued manifest must478+    // therefore be honoured. MV-7 still applies: the introduced device lands in479+    // the pending queue, not the registry.480+    const { adapter } = await setupVault();481+    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");482+    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");483+    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");484+    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");485+486+    const mobileIssuerId = "android-issuer";487+    const mobileIssuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), mobileIssuerId);488+    await upsertPairedDevice({489+      deviceId: mobileIssuerId,490+      deviceName: "Issuer Pixel",491+      deviceType: "android",492+      longTermPublicKeyHex: "1".repeat(64),493+      signingPublicKeyHex: pubkeyToHex(mobileIssuerKp.publicKeyRaw),494+      pairedAt: 1_700_000_000_000,495+      trustSource: "direct",496+    });497+    const mobileManifest = await buildSignedDeviceManifest(498+      mobileIssuerId,499+      [{500+        deviceId: "mesh-peer",501+        deviceName: "Other Pixel",502+        deviceType: "android",503+        longTermPublicKeyHex: "2".repeat(64),504+        signingPublicKeyHex: await signingPubkeyHex("mesh-peer"),505+        pairedAt: 1_700_000_000_010,506+      }],507+      mobileIssuerKp.privateKey,508+    );509+    await adapter.applyIncomingDeviceManifest?.(mobileManifest);510+511+    expect(await findPairedDeviceById("mesh-peer")).toBeNull();512+    expect((await listPendingVouchedDevices()).map((d) => d.deviceId)).toEqual(["mesh-peer"]);513+  });514+515+  it("applyIncomingDeviceManifest ignores vouched issuers and bad signatures", async () => {516+    const { adapter } = await setupVault();517+    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");518+    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");519+    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");520+    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");521+522+    // Trust does not travel two hops: a device we only know because someone523+    // else vouched for it cannot go on to vouch for further devices.524+    const vouchedIssuerId = "android-vouched-issuer";525+    const vouchedKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), vouchedIssuerId);526+    await upsertPairedDevice({527+      deviceId: vouchedIssuerId,528+      deviceName: "Second-hand Pixel",529+      deviceType: "android",530+      longTermPublicKeyHex: "1".repeat(64),531+      signingPublicKeyHex: pubkeyToHex(vouchedKp.publicKeyRaw),532+      pairedAt: 1_700_000_000_000,533+      trustSource: "vouched",534+    });535+    const onward = await buildSignedDeviceManifest(536+      vouchedIssuerId,537+      [{538+        deviceId: "should-not-add",539+        deviceName: "Other Pixel",540+        deviceType: "android",541+        longTermPublicKeyHex: "2".repeat(64),542+        signingPublicKeyHex: await signingPubkeyHex("should-not-add"),543+        pairedAt: 1_700_000_000_010,544+      }],545+      vouchedKp.privateKey,546+    );547+    await adapter.applyIncomingDeviceManifest?.(onward);548+    expect(await findPairedDeviceById("should-not-add")).toBeNull();549+    expect(await listPendingVouchedDevices()).toEqual([]);550+551+    const extensionIssuerId = "extension-bad-sig";552+    const extensionKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), extensionIssuerId);553+    await upsertPairedDevice({554+      deviceId: extensionIssuerId,555+      deviceName: "Chrome",556+      deviceType: "extension",557+      longTermPublicKeyHex: "3".repeat(64),558+      signingPublicKeyHex: pubkeyToHex(extensionKp.publicKeyRaw),559+      pairedAt: 1_700_000_000_020,560+      trustSource: "direct",561+    });562+    const good = await buildSignedDeviceManifest(563+      extensionIssuerId,564+      [{565+        deviceId: "forged-add",566+        deviceName: "Forged Pixel",567+        deviceType: "android",568+        longTermPublicKeyHex: "4".repeat(64),569+        signingPublicKeyHex: await signingPubkeyHex("forged-add"),570+        pairedAt: 1_700_000_000_030,571+      }],572+      extensionKp.privateKey,573+    );574+    const forged = {575+      ...good,576+      signatureHex:577+        good.signatureHex.slice(0, 2) +578+        ((parseInt(good.signatureHex.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0")) +579+        good.signatureHex.slice(4),580+    };581+    await adapter.applyIncomingDeviceManifest?.(forged);582+    expect(await findPairedDeviceById("forged-add")).toBeNull();583+  });584+585+  it("methods throw VaultLockedError when the vault is locked", async () => {586+    const { vault, adapter } = await setupVault();587+    vault.lock();588+    await expect(adapter.computeVaultId()).rejects.toMatchObject({ message: "vault_locked" });589+    await expect(adapter.buildEntryManifest()).rejects.toMatchObject({ message: "vault_locked" });590+    await expect(adapter.buildTombstoneManifest()).rejects.toMatchObject({ message: "vault_locked" });591+    await expect(adapter.getEncryptedEntries(["x"])).rejects.toMatchObject({ message: "vault_locked" });592+    await expect(adapter.applyEntries({ x: "AAAA" })).rejects.toMatchObject({ message: "vault_locked" });593+    await expect(adapter.applyTombstones({ x: 1 })).rejects.toMatchObject({ message: "vault_locked" });594+    await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toMatchObject({ message: "vault_locked" });595+  });596+});

view file

src/background/sync-vault-adapter.ts+453 −0

not shown here: too long for one page

view file

src/background/vault.test.ts+3 −3
@@ -1378,10 +1378,10 @@13781378     it("changes the derived sync vault_id so an old device fails the vault-id gate", async () => {13791379       const vault = await import("./vault");13801380       await vault.createVault("correct horse battery staple");1381-      const { createChirpSyncVaultAdapter } = await import(1382-        "./chirp-sync-vault-adapter"1381+      const { createSyncVaultAdapter } = await import(1382+        "./sync-vault-adapter"13831383       );1384-      const adapter = createChirpSyncVaultAdapter();1384+      const adapter = createSyncVaultAdapter();13851385       const vidBefore = await adapter.computeVaultId();13861386 13871387       await vault.rotateSyncIdentity();

view file

src/background/vault.ts+2 −2
@@ -1458,7 +1458,7 @@14581458 14591459     // PCH-01-009: the biometric path skips Argon2id but must still rehydrate the14601460     // root key, exactly like attemptUnlockWithDerive and restoreFromSession.1461-    // Without it, rootKey stays null after a biometric unlock and chirp sync +1461+    // Without it, rootKey stays null after a biometric unlock and sync +14621462     // GET_RECOVERY_WORDS report the open vault as locked. Guarded and non-fatal:14631463     // a missing/legacy escrow must not fail the unlock itself.14641464     if (stored.recovery_salt && stored.recovery_wrapped_root_key) {@@ -2127,7 +2127,7 @@21272127 }21282128 21292129 /**2130- * Apply a batch of remote entries + tombstones from a chirp-sync peer.2130+ * Apply a batch of remote entries + tombstones from a sync peer.21312131  * Last-write-wins by `modifiedAt`; a tombstone supersedes a live entry when21322132  * `deletedAt > modifiedAt` on the local side. Mutates `entries` directly and21332133  * persists with reason `sync_received` — does NOT fire the sync dispatcher, so

view file

src/drafts/TEST-EXPANSION/chirp-transport.probe.test.ts+0 −242

not shown here: too long for one page

view file at the parent

src/drafts/TEST-EXPANSION/sync.disconnect.test.ts+8 −8

not shown here: too long for one page

view file

src/popup/App.tsx+13 −13

not shown here: too long for one page

view file

src/popup/views/PairDevice.test.tsx+20 −20

not shown here: too long for one page

view file

src/popup/views/PairDevice.tsx+2 −3
@@ -1,4 +1,4 @@1-// ChirpSync — the pairing surface, opened from the Settings → Sync card.1+// PairDevice — the pairing surface, opened from the Settings → Sync card.22 //33 // Settings owns the sync "hub" (paired-device list, revoke, sync-now); this44 // view is purely the pairing flow, returning to Settings (`onBack`) when done@@ -7,8 +7,7 @@77 // from there (enter code → connecting → SAS → paired).88 //99 // There is one path because there can only be one: the browser cannot listen,10-// so it is always the side that dials. The audio-chirp flow this file is named11-// after has been removed.10+// so it is always the side that dials.1211 //1312 // The pairing itself runs in the background (code-pairing.ts) and reaches1413 // this view via the PAIR_WITH_CODE / DESKTOP_PAIR_* messages.

view file

src/popup/views/Settings.tsx+1 −1
@@ -219,7 +219,7 @@219219       id: "sync",220220       num: "IV · ECHO",221221       title: "Sync",222-      lede: isPro() ? "pair by chirp, keep in step" : "Pro · encrypted device pairing",222+      lede: isPro() ? "pair with a code, keep in step" : "Pro · encrypted device pairing",223223       bgVar: "--stack-sync-bg",224224       fgVar: "--stack-sync-fg",225225       icon: "sync",

view file

src/shared/chirp-adopt.test.ts+0 −246

not shown here: too long for one page

view file at the parent

src/shared/chirp-adopt.ts+0 −214

not shown here: too long for one page

view file at the parent

src/shared/chirp-handshake.test.ts+0 −187

not shown here: too long for one page

view file at the parent

src/shared/chirp-handshake.ts+0 −386

not shown here: too long for one page

view file at the parent

src/shared/chirp-pair-identity-vault-id.test.ts+0 −115

not shown here: too long for one page

view file at the parent

src/shared/chirp-sync-transport.test.ts+0 −91

not shown here: too long for one page

view file at the parent

src/shared/chirp-sync-transport.ts+0 −292

not shown here: too long for one page

view file at the parent

src/shared/device-manifest.ts+1 −1
@@ -3,7 +3,7 @@33 // A device manifest is a list of paired-device entries, signed by the44 // issuer's long-term Ed25519 signing key (`deriveSyncSigningKeypair`).55 // Manifests are exchanged after the entry sync completes during a normal6-// `ChirpSyncSession`, encrypted under Noise XX along with everything else.6+// `SyncSession`, encrypted under Noise XX along with everything else.77 //88 // Purpose: propagate trust between mobile devices without requiring99 // additional audio pairing sessions. The extension is the trust anchor and

view file

src/shared/local-network.ts+4 −4

not shown here: too long for one page

view file

src/shared/noise-xx.ts+1 −1

not shown here: too long for one page

view file

src/shared/pairing-dial.ts+1 −1

not shown here: too long for one page

view file

src/shared/pairing-handshake.test.ts+187 −0

not shown here: too long for one page

view file

src/shared/pairing-handshake.ts+373 −0

not shown here: too long for one page

view file

src/shared/pairing-handshake.vault-id.test.ts+115 −0

not shown here: too long for one page

view file

src/shared/sync-limits.ts+2 −2

not shown here: too long for one page

view file

src/shared/sync-transport.test.ts+91 −0

not shown here: too long for one page

view file

src/shared/sync-transport.ts+292 −0

not shown here: too long for one page

view file

src/shared/sync-types.ts+14 −47

not shown here: too long for one page

view file

src/shared/sync.disconnect.test.ts+8 −8

not shown here: too long for one page

view file

src/shared/types.ts+3 −3

not shown here: too long for one page

view file

src/shared/vault-adopt.test.ts+246 −0

not shown here: too long for one page

view file

src/shared/vault-adopt.ts+214 −0

not shown here: too long for one page

view file