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
↓ peach-browser-b8256418.tar.gzatlas at this save
files changed60 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 −0manifest.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": {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",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",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-});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-}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-});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-}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-});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-}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-});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-});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-}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);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 ────────────────────────────────────────────────────────────────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(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?"; thesrc/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+});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+}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);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) {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` andsrc/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+});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+}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+});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+}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+});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 }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,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+});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();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, sosrc/drafts/TEST-EXPANSION/chirp-transport.probe.test.ts+0 −242
not shown here: too long for one page
src/drafts/TEST-EXPANSION/sync.disconnect.test.ts+8 −8
not shown here: too long for one page
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.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",src/shared/chirp-handshake.test.ts+0 −187
not shown here: too long for one page
src/shared/chirp-pair-identity-vault-id.test.ts+0 −115
not shown here: too long for one page
src/shared/chirp-sync-transport.test.ts+0 −91
not shown here: too long for one page
src/shared/chirp-sync-transport.ts+0 −292
not shown here: too long for one page
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 andsrc/shared/pairing-handshake.vault-id.test.ts+115 −0
not shown here: too long for one page