49bcc955c5ce
strip v2.14 legacy — sync-coordinator/network/storage/wire/websocket-transport modules, SyncPairing+InfoSheet+ComingSoonBanner views, RestoreFromPhone tab in Lock, getVaultBlobForSync/applyIncomingVault/getSyncIdentity, all legacy message types and handlers (16 files trashed, 8 cleaned)
- time
- 2026-05-18 03:12 UTC · 5 months ago
- machine
- koh2
- parent
- cce17ffb3143
↓ peach-browser-49bcc955.tar.gzatlas at this save
files changed28 files
src/background/messages.test.ts+0 −60src/background/messages.ts+7 −618src/background/pairing-completed-gate.test.ts+0 −153src/background/pairing-server-state.ts+0 −74src/background/sync-coordinator.test.ts+0 −264src/background/sync-coordinator.ts+0 −125src/background/sync-identity-encryption.test.ts+0 −111src/background/sync-info-sheet.test.ts+0 −184src/background/sync-network.ts+0 −47src/background/sync-pro-gate.test.ts+0 −169src/background/sync-screen.test.ts+0 −175src/background/sync-storage.ts+0 −146src/background/sync.adversarial.test.ts+0 −883src/background/vault.test.ts+0 −15src/background/vault.ts+0 −221src/popup/App.tsx+9 −142src/popup/chirp-pair.ts+7 −3src/popup/components/SyncComingSoonBanner.tsx+0 −16src/popup/components/SyncInfoSheet.tsx+0 −49src/popup/styles/popup.css+223 −0src/popup/views/ChirpSync.tsx+390 −0src/popup/views/Lock.tsx+3 −157src/popup/views/SyncPairing.tsx+0 −608src/shared/local-network.ts+9 −89src/shared/sync-types.ts+19 −79src/shared/sync-wire.ts+0 −31src/shared/types.ts+0 −19src/shared/websocket-sync-transport.ts+0 −108src/background/messages.test.ts+0 −60
@@ -697,66 +697,6 @@697697 expect(hits[0]!.useCount).toBe(3);698698 });699699 700- it("DEBUG_INJECT_INCOMING_PAIRING + GET returns the request and 60s expiry window", async () => {701- const inject = await dispatch({702- type: "DEBUG_INJECT_INCOMING_PAIRING",703- payload: { deviceName: "iPhone", publicKeyHex: "deadbeef" },704- });705- expect(inject.ok).toBe(true);706-707- const get = await dispatch({ type: "GET_INCOMING_PAIRING_REQUEST" });708- expect(get.ok).toBe(true);709- const data = (get as { data?: { deviceName?: string; expiresAt?: number; receivedAt?: number } }).data;710- expect(data?.deviceName).toBe("iPhone");711- expect((data?.expiresAt ?? 0) - (data?.receivedAt ?? 0)).toBe(60_000);712- });713-714- it("RESPOND_INCOMING_PAIRING accept clears the request and reports the device name", async () => {715- await dispatch({716- type: "DEBUG_INJECT_INCOMING_PAIRING",717- payload: { deviceName: "MacBook", publicKeyHex: "cafe" },718- });719- const respond = await dispatch({720- type: "RESPOND_INCOMING_PAIRING",721- payload: { accept: true },722- });723- expect(respond.ok).toBe(true);724- expect((respond as { data?: { accepted?: boolean; deviceName?: string } }).data).toEqual({725- accepted: true,726- deviceName: "MacBook",727- });728- const after = await dispatch({ type: "GET_INCOMING_PAIRING_REQUEST" });729- expect((after as { data?: unknown }).data).toBeNull();730- });731-732- it("RESPOND_INCOMING_PAIRING with no pending request returns no_pending_request", async () => {733- const res = await dispatch({734- type: "RESPOND_INCOMING_PAIRING",735- payload: { accept: false },736- });737- expect(res.ok).toBe(false);738- expect((res as { error?: string }).error).toBe("no_pending_request");739- });740-741- it("RESET_SYNC_IDENTITY is Pro-gated and rejects free users", async () => {742- const res = await dispatch({ type: "RESET_SYNC_IDENTITY" });743- expect(res.ok).toBe(false);744- expect((res as { error?: string }).error).toBe("pro_required");745- });746-747- it("RESET_SYNC_IDENTITY clears local paired devices for Pro users", async () => {748- storage["peach_paired_devices"] = [749- { deviceId: "a", displayName: "iPhone", x25519PublicKey: "pk-a", pairedAt: 1 },750- { deviceId: "b", displayName: "MacBook", x25519PublicKey: "pk-b", pairedAt: 2 },751- ];752- const license = await import("./license");753- vi.spyOn(license, "isProUser").mockResolvedValue(true);754- const res = await dispatch({ type: "RESET_SYNC_IDENTITY" });755- expect(res.ok).toBe(true);756- expect((res as { data?: { removedDevices: number } }).data?.removedDevices).toBe(2);757- expect(storage["peach_paired_devices"]).toEqual([]);758- });759-760700 it("SUBMIT_FEEDBACK does not refresh the auto-lock timer", async () => {761701 const vault = await import("./vault");762702 await vault.createVault("correct-pass-12345");src/background/messages.ts+7 −618
@@ -43,25 +43,8 @@4343 setPasskeyProviderEnabled,4444 } from "./passkey";4545 import { buildAdvancedHealthReport, resolveResetUrl } from "./health";46-import { storageClear, storageGet } from "../shared/storage";46+import { storageClear } from "../shared/storage";4747 import { findInvalidWords } from "../shared/recovery-words";48-import {49- addPairedDevice,50- addTrustedNetwork,51- clearIncomingPairingRequest,52- getIncomingPairingRequest,53- getPairedDevices,54- getTrustedNetworks,55- removePairedDevice,56- removeTrustedNetwork,57- setIncomingPairingRequest,58- updatePairedDevice,59-} from "./sync-storage";60-import { getCurrentNetwork, isOnTailscale } from "./sync-network";61-import {62- consumeRecentPairing,63- setActivePairingToken,64-} from "./pairing-server-state";6548 import type {6649 Message,6750 MessageResponse,@@ -70,16 +53,9 @@7053 VaultEntry,7154 } from "../shared/types";7255 import { isPasskeyOnly } from "../shared/types";73-import type { SyncStatus, SyncableDevice } from "../shared/sync-types";7456 import { isNativePlatform, nativePlugin } from "../shared/native";75-import { runBlobSyncSession, type SyncResult } from "./sync-coordinator";76-import type { SyncDeps } from "./sync-coordinator";77-import { createWebSocketSyncTransport } from "../shared/websocket-sync-transport";78-import { performResponderAuth, bytesToHex as noiseBytesToHex } from "../shared/noise-js";79-import { getLocalSubnet, discoverDevice, scanForPairingPhone } from "../shared/local-network";8057 import { predictBatch as mlPredictBatch } from "./ml";8158 import type { MLFieldFeatures } from "../shared/ml";82-import { base64ToBytes, bytesToBase64 } from "../shared/sync-wire";8359 import { buildLocalIdentity, setDeviceName as setDeviceNameV3 } from "./chirp-identity";8460 import {8561 listPairedDevices as listPairedDevicesV3,@@ -130,25 +106,14 @@130106 }131107 132108 // ---------------------------------------------------------------------------133-// 6-digit code pairing.134-135-const PAIRING_TOKEN_TTL_MS = 5 * 60 * 1000;136-137-138-function generateSixDigitToken(): string {139- // crypto.getRandomValues, not Math.random — the token is the only secret140- // protecting the pairing window from a same-LAN attacker. 6 decimal digits141- // is 1 in 1,000,000; combined with a 5-minute TTL and pairing-mode gating142- // on the responder this is the right tradeoff between UX and security.143- const buf = new Uint32Array(1);144- crypto.getRandomValues(buf);145- const value = buf[0] % 1_000_000;146- return value.toString().padStart(6, "0");147-}148-109+// Feedback submission target. Empty disables network submission while the UI110+// continues to function as a no-op (the handler returns ok:true without111+// calling fetch). When set, the URL is validated by112+// `isAllowedCanaryWebhookUrl` before any request.149113 // ---------------------------------------------------------------------------150-// Peer discovery glue (Phase 7 popup pairing flow).151114 115+const FEEDBACK_ENDPOINT = "";116+152117 const PRIVATE_IPV4_RANGES = [153118 { prefix: "10." },154119 { prefix: "127." },@@ -156,170 +121,6 @@156121 { prefix: "192.168." },157122 ];158123 159-// Empty disables network submission while the UI continues to function as a160-// no-op (the handler returns ok:true without calling fetch). When set, the161-// URL is validated by `isAllowedCanaryWebhookUrl` before any request, so the162-// same private-IP/HTTPS rules that gate canary webhooks apply here.163-const FEEDBACK_ENDPOINT = "";164-165-// ---------------------------------------------------------------------------166-// Sync helpers167-// ---------------------------------------------------------------------------168-169-/**170- * Short-lived registry of x25519 public keys that just completed a real Noise171- * NK handshake + initiator-auth proof. The PAIRING_COMPLETED handler must172- * find the supplied publicKeyHex here before it will call addPairedDevice —173- * otherwise an attacker could forge the message and pair an arbitrary key174- * (M13/SEC-005).175- *176- * Entries auto-expire after 30s. Each entry is consumed on first successful177- * PAIRING_COMPLETED so a single handshake cannot pair twice.178- */179-const PENDING_PAIRS = new Map<string, ReturnType<typeof setTimeout>>();180-const PENDING_PAIR_TTL_MS = 30_000;181-182-export function markPairingHandshakeVerified(publicKeyHex: string): void {183- if (!publicKeyHex) return;184- const existing = PENDING_PAIRS.get(publicKeyHex);185- if (existing) clearTimeout(existing);186- const t = setTimeout(() => PENDING_PAIRS.delete(publicKeyHex), PENDING_PAIR_TTL_MS);187- PENDING_PAIRS.set(publicKeyHex, t);188-}189-190-function consumePendingPair(publicKeyHex: string): boolean {191- const t = PENDING_PAIRS.get(publicKeyHex);192- if (!t) return false;193- clearTimeout(t);194- PENDING_PAIRS.delete(publicKeyHex);195- return true;196-}197-198-export function buildSyncDeps(): SyncDeps {199- return {200- isVaultUnlocked: () => vault.isUnlocked(),201- getVaultBlobForSync: async () => {202- const base = await vault.getVaultBlobForSync();203- if (!base) return null;204- const paired = await getPairedDevices();205- return {206- ...base,207- devices: paired.flatMap(d =>208- d.x25519PublicKey209- ? [{210- publicKeyHex: d.x25519PublicKey,211- deviceName: d.displayName ?? "",212- pairedAt: d.pairedAt,213- }]214- : [],215- ),216- };217- },218- applyIncomingVault: async (blob, ts, license, devices) => {219- const applied = await vault.applyIncomingVault(blob, ts, license);220- if (devices?.length) await mergeDeviceRegistry(devices);221- if (applied) {222- const reloaded = await vault.reloadEntriesAfterSync();223- if (!reloaded) {224- // The remote vault was encrypted under a different key (the remote225- // changed password). Our in-memory entries are now stale and cannot226- // be re-encrypted without overwriting the just-applied blob. Lock227- // to force a re-unlock with the new password (H3/COR-005).228- vault.lock();229- chrome.runtime230- .sendMessage({ type: "VAULT_RELOCK_AFTER_SYNC" })231- .catch(() => {});232- }233- }234- return applied;235- },236- };237-}238-239-async function doVaultSync(ip: string, publicKeyHex: string): Promise<SyncResult> {240- // Defensive: refuse to drive a sync to a key not in the paired-devices241- // list. The WebSocket transport already authenticates the peer via Noise242- // NK + the initiator auth proof (H1), but pinning the key to the paired243- // set here makes "talk to anyone matching this static key" the only path244- // to vault application — there is no orphan-key window between pair and245- // sync (M11/COR-014).246- const paired = await getPairedDevices();247- if (!paired.some(d => d.x25519PublicKey === publicKeyHex)) {248- return { status: 'errored', applied: false, error: 'remote_not_paired' };249- }250- try {251- const identity = await vault.getSyncIdentity();252- const transport = await createWebSocketSyncTransport(253- `ws://${ip}:44100`,254- publicKeyHex,255- identity.privateKeyBytes,256- identity.publicKeyBytes,257- );258- const result = await runBlobSyncSession('initiator', transport, buildSyncDeps());259- // Reaching this point means createWebSocketSyncTransport completed both260- // the Noise NK handshake and the initiator-auth proof — the remote261- // proved possession of the matching static key (M13/SEC-005).262- if (result.status === 'completed') {263- markPairingHandshakeVerified(publicKeyHex);264- }265- return result;266- } catch (err) {267- return { status: 'errored', applied: false, error: String(err) };268- }269-}270-271-async function mergeDeviceRegistry(incoming: SyncableDevice[]): Promise<void> {272- const existing = await getPairedDevices();273- const existingKeys = new Set(existing.map(d => d.x25519PublicKey));274- for (const device of incoming) {275- if (!existingKeys.has(device.publicKeyHex)) {276- await addPairedDevice({277- deviceId: device.publicKeyHex,278- displayName: device.deviceName,279- x25519PublicKey: device.publicKeyHex,280- pairedAt: device.pairedAt,281- lastSeenAt: 0,282- lastKnownIp: '',283- }).catch(() => {});284- }285- }286-}287-288-// Background pairing scan — runs after START_PAIRING_SERVER fires on desktop.289-// Scans the local subnet for a phone that has accepted the pairing token,290-// stores it, runs the initial vault exchange, then notifies the popup.291-async function runPairingSubnetScan(token: string): Promise<void> {292- const subnet = await getLocalSubnet();293- if (!subnet) return;294-295- const found = await scanForPairingPhone(subnet, token);296- if (!found) return;297-298- const { ip, publicKeyHex } = found;299-300- try {301- await addPairedDevice({302- deviceId: publicKeyHex,303- displayName: 'Peach',304- x25519PublicKey: publicKeyHex,305- pairedAt: Date.now(),306- lastSeenAt: Date.now(),307- lastKnownIp: ip,308- });309- } catch {310- // Already paired — update IP311- await updatePairedDevice(publicKeyHex, { lastKnownIp: ip, lastSeenAt: Date.now() });312- }313-314- // Immediate vault exchange — the free one-time transfer315- await doVaultSync(ip, publicKeyHex);316-317- chrome.runtime.sendMessage({318- type: 'PAIRING_COMPLETED',319- payload: { deviceName: 'Peach', publicKeyHex, deviceIp: ip },320- }).catch(() => {});321-}322-323124 export function setupMessageHandler(): void {324125 chrome.runtime.onMessage.addListener(325126 (msg: unknown, sender, sendResponse) => {@@ -357,55 +158,6 @@357158 });358159 }359160 360- // Android: subscribe to SyncPlugin events.361- if (isNativePlatform()) {362- const syncPlug = nativePlugin<{363- addListener: (364- event: string,365- handler: (data: Record<string, string>) => void366- ) => void;367- }>("SyncPlugin");368-369- // Pairing probe answered — store the paired device (desktop, no IP needed370- // since the desktop never acts as a WS server).371- syncPlug?.addListener?.("peach:pair-probe-answered", (data) => {372- const deviceName = data.deviceName ?? "Peach";373- const publicKeyHex = data.publicKeyHex ?? "";374- // Native handled the pairing handshake — trust this entry into the375- // verified-handshake set (M13/SEC-005). The subsequent PAIRING_COMPLETED376- // message will consume it.377- if (publicKeyHex) markPairingHandshakeVerified(publicKeyHex);378- void (async () => {379- const { addPairedDevice, setHasPaired: setPaired } = await import("./sync-storage");380- await addPairedDevice({381- deviceId: publicKeyHex || (data.connectionId ?? "unknown"),382- displayName: deviceName,383- x25519PublicKey: publicKeyHex,384- pairedAt: Date.now(),385- lastSeenAt: Date.now(),386- }).catch(() => {});387- await setPaired();388- chrome.runtime.sendMessage({389- type: "PAIRING_COMPLETED",390- payload: { deviceName, publicKeyHex },391- }).catch(() => {});392- })();393- });394-395- // Sync frame received — route to the waiting responder session.396- syncPlug?.addListener?.("peach:sync-frame", (data) => {397- const { connectionId, frame } = data;398- if (connectionId && frame) deliverAndroidSyncFrame(connectionId, frame);399- });400-401- // Sync session started (SYNC_HELLO received by SyncPlugin) — dispatch as402- // a background message so SYNC_SESSION_STARTED handler runs the responder.403- syncPlug?.addListener?.("peach:sync-session-started", (data) => {404- const { connectionId } = data;405- if (!connectionId) return;406- void handleMessage({ type: "SYNC_SESSION_STARTED", payload: { connectionId } });407- });408- }409161 }410162 411163 function isMessage(m: unknown): m is Message {@@ -698,44 +450,6 @@698450 );699451 return { ok: true, data: searchResults } as MessageResponse;700452 }701- case "PAIRING_COMPLETED": {702- const { deviceName, publicKeyHex, deviceIp } = (msg.payload ?? {}) as {703- deviceName?: string;704- publicKeyHex?: string;705- deviceIp?: string;706- };707- if (!publicKeyHex) return { ok: false, error: "publickeyhex_required" };708- // Only accept publicKeyHex values that just completed a real Noise NK709- // handshake (initiator or responder side). Without this gate, anyone710- // who can sendMessage to the background can pair an arbitrary key711- // (M13/SEC-005). Re-confirming an already-paired device is allowed712- // so legitimate update-IP retries still work.713- const alreadyPaired = (await getPairedDevices()).some(714- d => d.x25519PublicKey === publicKeyHex,715- );716- const verified = consumePendingPair(publicKeyHex);717- if (!verified && !alreadyPaired) {718- return { ok: false, error: "pairing_not_verified" };719- }720- const { setHasPaired: setPaired } = await import("./sync-storage");721- await setPaired();722- try {723- await addPairedDevice({724- deviceId: publicKeyHex,725- displayName: deviceName ?? "Unknown",726- x25519PublicKey: publicKeyHex,727- pairedAt: Date.now(),728- lastSeenAt: Date.now(),729- lastKnownIp: deviceIp,730- });731- } catch {732- // Already paired — update IP if provided733- if (deviceIp) {734- await updatePairedDevice(publicKeyHex, { lastKnownIp: deviceIp, lastSeenAt: Date.now() });735- }736- }737- return { ok: true };738- }739453 case "AUTOFILL_QUERY": {740454 if (!vault.isUnlocked()) {741455 return { ok: false, error: "vault_locked" };@@ -1059,158 +773,6 @@1059773 return { ok: true, data: null };1060774 }1061775 }1062- case "GET_SYNC_STATE": {1063- const pairedDevices = await getPairedDevices();1064- const trustedNetworks = await getTrustedNetworks();1065- const tailscale = await isOnTailscale();1066- const pro = await isProUser();1067-1068- let status: SyncStatus = "offline";1069- if (tailscale || trustedNetworks.some((n: { isTailscale?: boolean }) => !n.isTailscale)) {1070- if (pairedDevices.length === 0) {1071- status = "no_peers";1072- } else {1073- status = "synced";1074- }1075- } else {1076- status = "not_trusted";1077- }1078-1079- const lastSyncRaw = await storageGet("peach_last_sync_at");1080- const lastSyncParsed = lastSyncRaw ? Number.parseInt(lastSyncRaw, 10) : NaN;1081- const lastSyncAt = Number.isFinite(lastSyncParsed) ? lastSyncParsed : undefined;1082-1083- return { ok: true, data: { pairedDevices, trustedNetworks, status, lastSyncAt, isPro: pro } };1084- }1085- case "GET_PAIRED_DEVICES": {1086- const devices = await getPairedDevices();1087- return { ok: true, data: { devices } };1088- }1089- case "GET_SYNC_STATUS": {1090- const lastSyncRaw = await storageGet("peach_last_sync_at");1091- const lastSyncParsed = lastSyncRaw ? parseInt(lastSyncRaw, 10) : NaN;1092- const lastSyncAt = Number.isFinite(lastSyncParsed) ? lastSyncParsed : null;1093- const status: 'up_to_date' | 'never' = lastSyncAt ? 'up_to_date' : 'never';1094- return { ok: true, data: { status, lastSyncAt } };1095- }1096- case "ACCEPT_PAIRING": {1097- // One-time mobile→desktop transfer is free; only continuous sync gates on Pro.1098- return { ok: true };1099- }1100- case "REMOVE_PAIRED_DEVICE": {1101- const { deviceId } = msg.payload as { deviceId: string };1102- await removePairedDevice(deviceId);1103- return { ok: true };1104- }1105- case "REMOVE_TRUSTED_NETWORK": {1106- const payload = msg.payload as1107- | { ssid?: unknown; bssid?: unknown; isTailscale?: unknown }1108- | null1109- | undefined;1110- if (!payload || typeof payload !== "object" || Array.isArray(payload)) {1111- return { ok: false, error: "invalid_payload" };1112- }1113- const { ssid, bssid, isTailscale } = payload as { ssid?: string; bssid?: string; isTailscale?: boolean };1114- const networks = await getTrustedNetworks();1115- const network = networks.find((n: { ssid?: string; bssid?: string; isTailscale?: boolean }) => 1116- (isTailscale ? n.isTailscale : n.ssid === ssid) && 1117- (!bssid || n.bssid === bssid)1118- );1119- if (network) {1120- await removeTrustedNetwork(network.addedAt);1121- }1122- return { ok: true };1123- }1124- case "MARK_NETWORK_TRUSTED": {1125- const network = await getCurrentNetwork();1126- await addTrustedNetwork({1127- ssid: network.ssid,1128- bssid: network.bssid,1129- isTailscale: network.isTailscale,1130- addedAt: Date.now(),1131- });1132- return { ok: true };1133- }1134- case "GET_INCOMING_PAIRING_REQUEST": {1135- const request = await getIncomingPairingRequest();1136- return { ok: true, data: request };1137- }1138- case "RESPOND_INCOMING_PAIRING": {1139- const { accept } = msg.payload as { accept: boolean };1140- const request = await getIncomingPairingRequest();1141- if (!request) return { ok: false, error: "no_pending_request" };1142- if (Date.now() > request.expiresAt) {1143- await clearIncomingPairingRequest();1144- return { ok: false, error: "expired" };1145- }1146- await clearIncomingPairingRequest();1147- if (accept) {1148- await addPairedDevice({1149- deviceId: request.publicKeyHex,1150- displayName: request.deviceName,1151- x25519PublicKey: request.publicKeyHex,1152- pairedAt: Date.now(),1153- lastSeenAt: Date.now(),1154- });1155- }1156- return { ok: true, data: { accepted: accept, deviceName: request.deviceName } };1157- }1158- case "START_PAIRING_SERVER": {1159- // One-time mobile→desktop transfer is free; only continuous sync gates on Pro.1160- const token = generateSixDigitToken();1161- const expiresAt = Date.now() + PAIRING_TOKEN_TTL_MS;1162- await setActivePairingToken({ token, expiresAt });1163- // Fire-and-forget subnet scan — finds the phone after it scans the QR1164- // and calls SCAN_PAIRING_QR to register the token with SyncPlugin.1165- if (!isNativePlatform()) {1166- void runPairingSubnetScan(token);1167- }1168- return { ok: true, data: { token, expiresAt } };1169- }1170- case "STOP_PAIRING_SERVER": {1171- try {1172- await setActivePairingToken(null);1173- } catch { /* best-effort */ }1174- return { ok: true };1175- }1176- case "GET_RECENT_PAIRING": {1177- const recent = await consumeRecentPairing();1178- return { ok: true, data: recent };1179- }1180- case "DEBUG_INJECT_INCOMING_PAIRING": {1181- // M14/SEC-007: blocked in production builds. The handler exists for1182- // Phase 7 banner UX rehearsal without a live native host; shipping1183- // it in production lets any background-message sender inject a1184- // synthetic pairing prompt. Allowed in 'development' and 'test'1185- // modes; vite sets MODE='production' for release builds.1186- if (import.meta.env.MODE === "production") {1187- return { ok: false, error: "not_available" };1188- }1189- const { deviceName, publicKeyHex } = (msg.payload ?? {}) as {1190- deviceName?: string;1191- publicKeyHex?: string;1192- };1193- if (!deviceName || !publicKeyHex) {1194- return { ok: false, error: "missing_payload" };1195- }1196- const now = Date.now();1197- await setIncomingPairingRequest({1198- deviceName,1199- publicKeyHex,1200- receivedAt: now,1201- expiresAt: now + 60_000,1202- });1203- return { ok: true };1204- }1205- case "RESET_SYNC_IDENTITY": {1206- const gate = await requirePro("sync");1207- if (!gate.allowed) return { ok: false, error: "pro_required" };1208- const devices = await getPairedDevices();1209- for (const device of devices) {1210- await removePairedDevice(device.deviceId);1211- }1212- return { ok: true, data: { removedDevices: devices.length, nativePublicKey: null } };1213- }1214776 case "RECORD_MANUAL_SUMMON": {1215777 const { domain } = msg.payload as { domain: string };1216778 const settings = vault.getSettings();@@ -1369,62 +931,6 @@1369931 return { ok: false, error: err instanceof Error ? err.message : "network_error" };1370932 }1371933 }1372- case "TRIGGER_SYNC": {1373- const gate = await requirePro("sync");1374- if (!gate.allowed) return { ok: false, error: "pro_required" };1375- if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1376-1377- if (isNativePlatform()) {1378- // Phone is the server — ensure it's listening for desktop connections.1379- syncBridge()?.ensureServerRunning?.();1380- return { ok: true, data: { syncedCount: 0, mode: "server" as const } };1381- }1382-1383- // Desktop: iterate all paired devices and sync with each.1384- const paired = await getPairedDevices();1385- if (!paired.length) return { ok: true, data: { syncedCount: 0, reason: "no_paired_devices" } };1386-1387- const subnet = await getLocalSubnet();1388- const results: Array<{ device: string } & SyncResult> = [];1389-1390- for (const device of paired) {1391- const pubkey = device.x25519PublicKey;1392- if (!pubkey) continue;1393-1394- let result: SyncResult;1395-1396- // Fast path — use last known IP1397- if (device.lastKnownIp) {1398- result = await doVaultSync(device.lastKnownIp, pubkey);1399- } else {1400- result = { status: 'errored', applied: false, error: 'no_ip' };1401- }1402-1403- // Discovery path — scan if fast path failed1404- if (result.status === 'errored' && subnet) {1405- const newIp = await discoverDevice(subnet, pubkey);1406- if (newIp) {1407- await updatePairedDevice(pubkey, { lastKnownIp: newIp });1408- result = await doVaultSync(newIp, pubkey);1409- }1410- }1411-1412- if (result.status === 'completed') {1413- await updatePairedDevice(pubkey, {1414- lastSeenAt: Date.now(),1415- lastSyncAt: Date.now(),1416- });1417- }1418-1419- results.push({ device: device.displayName ?? "", ...result });1420- }1421-1422- const syncedCount = results.filter(r => r.status === 'completed').length;1423- if (syncedCount > 0) {1424- await chrome.storage.local.set({ peach_last_sync_at: String(Date.now()) });1425- }1426- return { ok: true, data: { syncedCount, results } };1427- }1428934 // Android-only: biometric unlock with pre-derived key.1429935 case "UNLOCK_WITH_KEY": {1430936 const { masterKeyHex } = msg.payload as { masterKeyHex: string };@@ -1452,80 +958,6 @@1452958 }1453959 return { ok: true, data: { masterKeyHex: vault.getDerivedKeyHex() } };1454960 }1455- // Android: scanned a QR containing a desktop pairing token.1456- case "SCAN_PAIRING_QR": {1457- if (!isNativePlatform()) {1458- return { ok: false, error: "android_only" };1459- }1460- const { token } = msg.payload as { token: string };1461- syncBridge()?.startPairingSession?.({ token });1462- return { ok: true };1463- }1464- // Android: desktop opened a sync connection to the phone's WS server.1465- // Run the responder side of the vault exchange.1466- case "SYNC_SESSION_STARTED": {1467- if (!isNativePlatform()) return { ok: false };1468- if (!vault.isUnlocked()) {1469- return { ok: false, error: "vault_locked" };1470- }1471- const { connectionId } = (msg.payload ?? {}) as { connectionId?: string };1472- if (!connectionId) return { ok: false, error: "missing_connection_id" };1473-1474- const transport = createAndroidResponderTransport(connectionId);1475-1476- // Authenticate the initiator before exchanging any vault data. The1477- // native side has already completed the Noise NK handshake; auth1478- // frames here ride the Noise transport (native encrypts/decrypts).1479- const identity = await vault.getSyncIdentity();1480- try {1481- // CAUTION: noiseSessionStub is a structural placeholder — the real1482- // Noise NK handshake runs natively, so JS never holds the session1483- // object. performResponderAuth currently `void`s this argument,1484- // which is why identity-passthrough encrypt/decrypt and a zero1485- // handshake hash are safe. If performResponderAuth is ever updated1486- // to actually use this argument, this auth path will silently break1487- // (no type error). Revisit this stub if that helper changes.1488- const noiseSessionStub = {1489- encrypt: (b: Uint8Array) => b,1490- decrypt: (b: Uint8Array) => b,1491- handshakeHash: new Uint8Array(32),1492- };1493- const { initiatorPublicKey } = await performResponderAuth(1494- noiseSessionStub,1495- identity.privateKeyBytes,1496- identity.publicKeyBytes,1497- transport.send,1498- transport.receive,1499- );1500- const initiatorHex = noiseBytesToHex(initiatorPublicKey);1501- const paired = await getPairedDevices();1502- if (!paired.some(d => d.x25519PublicKey === initiatorHex)) {1503- await transport.close();1504- return { ok: false, error: "sync_rejected_unpaired_initiator" };1505- }1506- // Initiator just proved control of the matching static key; mark it1507- // as a verified handshake so a follow-up PAIRING_COMPLETED message1508- // can be trusted (M13/SEC-005).1509- markPairingHandshakeVerified(initiatorHex);1510- } catch (err) {1511- await transport.close();1512- return { ok: false, error: `sync_auth_failed: ${String(err)}` };1513- }1514-1515- const result = await runBlobSyncSession('responder', transport, buildSyncDeps());1516- if (result.applied) {1517- await publishInMemoryVault(vault.getEntries());1518- }1519- return { ok: true, data: result };1520- }1521- // Android: returns the phone's WiFi IP address.1522- case "GET_LOCAL_IP": {1523- if (!isNativePlatform()) {1524- return { ok: false, error: "android_only" };1525- }1526- const { ip } = (await syncBridge()?.getLocalIp?.()) ?? { ip: "" };1527- return { ok: true, data: { ip } };1528- }1529961 case "GET_IDENTITY_SUGGESTIONS": {1530962 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1531963 const entries = vault.getEntries();@@ -1662,49 +1094,6 @@16621094 const plugin = nativePlugin<{ clearEntries?: () => Promise<void> }>("InMemoryVaultPlugin");16631095 if (!plugin?.clearEntries) return;16641096 await plugin.clearEntries();1665-}1666-1667-interface SyncBridge {1668- ensureServerRunning?: () => void;1669- startPairingSession?: (args: { token: string }) => void;1670- getLocalIp?: () => Promise<{ ip: string }>;1671- sendToConnection?: (args: { connectionId: string; data: string }) => Promise<void>;1672- addListener?: (event: string, handler: (data: Record<string, string>) => void) => void;1673-}1674-1675-// Resolve the Capacitor SyncPlugin at call time — module-load resolution1676-// races with Capacitor's plugin registration on the Android WebView.1677-function syncBridge(): SyncBridge | null {1678- return nativePlugin<SyncBridge>("SyncPlugin");1679-}1680-1681-// Build a SyncTransport for the Android responder path — the phone's WS server1682-// already has an open connection from the desktop. We send back via1683-// SyncPlugin.sendToConnection and receive via the peach:sync-frame queue.1684-const androidSyncFrameQueues = new Map<string, Array<(frame: string) => void>>();1685-1686-export function deliverAndroidSyncFrame(connectionId: string, frame: string): void {1687- const queue = androidSyncFrameQueues.get(connectionId);1688- if (queue?.length) queue.shift()!(frame);1689-}1690-1691-function createAndroidResponderTransport(connectionId: string) {1692- const pendingReceivers: Array<(frame: string) => void> = [];1693- androidSyncFrameQueues.set(connectionId, pendingReceivers);1694-1695- return {1696- send: async (data: Uint8Array) => {1697- await syncBridge()?.sendToConnection?.({ connectionId, data: bytesToBase64(data) });1698- },1699- receive: (): Promise<Uint8Array> => new Promise((res) => {1700- pendingReceivers.push((frame) => {1701- res(frame ? base64ToBytes(frame) : new Uint8Array(0));1702- });1703- }),1704- close: async () => {1705- androidSyncFrameQueues.delete(connectionId);1706- },1707- };17081097 }17091098 17101099 // Returns username use-counts from the vault, sorted by frequency. Each rowsrc/background/pairing-completed-gate.test.ts+0 −153
@@ -1,153 +0,0 @@1-/**2- * pairing-completed-gate.test.ts — M13/SEC-0053- *4- * PAIRING_COMPLETED must verify the publicKeyHex came from a real Noise NK5- * handshake before calling addPairedDevice. Forged messages with an6- * unverified key must be rejected.7- */8-9-import { beforeEach, describe, expect, it, vi } from "vitest";10-11-import type { Message, MessageResponse } from "../shared/types";12-13-type StorageShape = Record<string, unknown>;14-type MessageListener = (15- msg: unknown,16- sender: chrome.runtime.MessageSender,17- sendResponse: (response: MessageResponse) => void18-) => boolean;19-20-describe("PAIRING_COMPLETED gate (M13/SEC-005)", () => {21- let storage: StorageShape;22- let listener: MessageListener | undefined;23-24- beforeEach(async () => {25- vi.resetModules();26- vi.useRealTimers();27- storage = { peach_settings: { breachMonitoring: false } };28- listener = undefined;29-30- vi.stubGlobal(31- "fetch",32- vi.fn(async () => ({ ok: true, status: 200, text: async () => "" })),33- );34- vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);35-36- const chromeMock = {37- runtime: {38- id: "peach-extension",39- onMessage: {40- addListener: vi.fn((fn: MessageListener) => {41- listener = fn;42- }),43- },44- sendMessage: vi.fn(async () => ({ ok: true })),45- },46- storage: {47- local: {48- get: vi.fn(async (key?: string | string[]) => {49- if (!key) return storage;50- if (typeof key === "string") return { [key]: storage[key] };51- return key.reduce<Record<string, unknown>>((acc, k) => {52- acc[k] = storage[k];53- return acc;54- }, {});55- }),56- set: vi.fn(async (next: Record<string, unknown>) => {57- Object.assign(storage, next);58- }),59- remove: vi.fn(async (keys: string | string[]) => {60- const list = Array.isArray(keys) ? keys : [keys];61- for (const k of list) delete storage[k];62- }),63- },64- session: {65- get: vi.fn(async () => ({})),66- set: vi.fn(async () => {}),67- remove: vi.fn(async () => {}),68- },69- },70- alarms: {71- clear: vi.fn(),72- create: vi.fn(),73- onAlarm: { addListener: vi.fn() },74- },75- idle: {76- setDetectionInterval: vi.fn(),77- onStateChanged: { addListener: vi.fn() },78- },79- scripting: { executeScript: vi.fn(async () => []) },80- } as unknown as typeof chrome;81- (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;82-83- const messages = await import("./messages");84- messages.setupMessageHandler();85- });86-87- async function dispatch(message: Message): Promise<MessageResponse> {88- if (!listener) throw new Error("listener not registered");89- return new Promise<MessageResponse>((resolve) => {90- listener!(message, { id: "peach-extension" } as chrome.runtime.MessageSender, resolve);91- });92- }93-94- it("rejects PAIRING_COMPLETED for an unverified, unpaired publicKeyHex", async () => {95- const fakeKey = "deadbeef".repeat(8);96- const r = await dispatch({97- type: "PAIRING_COMPLETED",98- payload: { deviceName: "Attacker", publicKeyHex: fakeKey },99- });100- expect(r.ok).toBe(false);101- expect(r.error).toBe("pairing_not_verified");102-103- // Did not write to paired devices.104- const paired = (storage.peach_paired_devices as unknown[] | undefined) ?? [];105- expect(paired.length).toBe(0);106- });107-108- it("accepts PAIRING_COMPLETED for a freshly verified handshake", async () => {109- const messages = await import("./messages");110- const goodKey = "abcdef01".repeat(8);111- messages.markPairingHandshakeVerified(goodKey);112-113- const r = await dispatch({114- type: "PAIRING_COMPLETED",115- payload: { deviceName: "RealDevice", publicKeyHex: goodKey },116- });117- expect(r.ok).toBe(true);118-119- const paired = (storage.peach_paired_devices as Array<Record<string, unknown>> | undefined) ?? [];120- expect(paired.some(d => d.x25519PublicKey === goodKey)).toBe(true);121- });122-123- it("verified marker is single-use — second PAIRING_COMPLETED after the first device is removed is rejected", async () => {124- const messages = await import("./messages");125- const goodKey = "12345678".repeat(8);126- messages.markPairingHandshakeVerified(goodKey);127-128- const first = await dispatch({129- type: "PAIRING_COMPLETED",130- payload: { deviceName: "Dev", publicKeyHex: goodKey },131- });132- expect(first.ok).toBe(true);133-134- // Wipe paired state so alreadyPaired is false.135- delete storage.peach_paired_devices;136-137- const second = await dispatch({138- type: "PAIRING_COMPLETED",139- payload: { deviceName: "Dev", publicKeyHex: goodKey },140- });141- expect(second.ok).toBe(false);142- expect(second.error).toBe("pairing_not_verified");143- });144-145- it("missing publicKeyHex is rejected", async () => {146- const r = await dispatch({147- type: "PAIRING_COMPLETED",148- payload: { deviceName: "X" },149- });150- expect(r.ok).toBe(false);151- expect(r.error).toBe("publickeyhex_required");152- });153-});src/background/pairing-server-state.ts+0 −74
@@ -1,74 +0,0 @@1-/**2- * Tracks the active 6-digit pairing-server session.3- *4- * While a token is active, `setupIncomingPairingSubscriber` auto-accepts the5- * first PAIR_REQUEST that arrives (the user's act of showing the code is the6- * consent — no banner). The token expires after `expiresAt` so a stale7- * pairing window cannot silently accept a peer minutes later.8- *9- * On a successful pairing while the popup is closed, the responder records a10- * `RecentPairing` so the next popup open can flash a "Paired with X"11- * confirmation. The record is read-once.12- */13-14-const ACTIVE_TOKEN_KEY = "peach_active_pairing_token";15-const RECENT_PAIRING_KEY = "peach_recent_pairing";16-17-export interface ActivePairingToken {18- token: string;19- expiresAt: number;20-}21-22-export interface RecentPairing {23- deviceName: string;24- pairedAt: number;25- /** "showing" — we showed the code; "entering" — we typed a code. */26- side: "showing" | "entering";27-}28-29-export async function setActivePairingToken(value: ActivePairingToken | null): Promise<void> {30- try {31- if (value === null) {32- await chrome.storage.session.remove(ACTIVE_TOKEN_KEY);33- } else {34- await chrome.storage.session.set({ [ACTIVE_TOKEN_KEY]: value });35- }36- } catch {37- // session storage may be unavailable in test contexts38- }39-}40-41-export async function getActivePairingToken(): Promise<ActivePairingToken | null> {42- try {43- const result = await chrome.storage.session.get(ACTIVE_TOKEN_KEY);44- const value = (result as Record<string, ActivePairingToken | undefined>)[ACTIVE_TOKEN_KEY];45- if (!value) return null;46- if (Date.now() >= value.expiresAt) {47- await chrome.storage.session.remove(ACTIVE_TOKEN_KEY);48- return null;49- }50- return value;51- } catch {52- return null;53- }54-}55-56-export async function setRecentPairing(value: RecentPairing): Promise<void> {57- try {58- await chrome.storage.session.set({ [RECENT_PAIRING_KEY]: value });59- } catch {60- // best-effort61- }62-}63-64-export async function consumeRecentPairing(): Promise<RecentPairing | null> {65- try {66- const result = await chrome.storage.session.get(RECENT_PAIRING_KEY);67- const value = (result as Record<string, RecentPairing | undefined>)[RECENT_PAIRING_KEY];68- if (!value) return null;69- await chrome.storage.session.remove(RECENT_PAIRING_KEY);70- return value;71- } catch {72- return null;73- }74-}src/background/sync-coordinator.test.ts+0 −264
@@ -1,264 +0,0 @@1-import { describe, it, expect, vi } from 'vitest';2-import { runBlobSyncSession } from './sync-coordinator';3-import type { SyncDeps } from './sync-coordinator';4-import type { SyncTransport } from '../shared/websocket-sync-transport';5-import type { SyncableDevice } from '../shared/sync-types';6-7-// Builds a paired in-memory transport — one write on side A becomes a read on8-// side B and vice versa. Returns [transportA, transportB].9-function makePairedTransports(): [SyncTransport, SyncTransport] {10- const queueAtoB: Uint8Array[] = [];11- const queueBtoA: Uint8Array[] = [];12- const waitersAtoB: ((d: Uint8Array) => void)[] = [];13- const waitersBtoA: ((d: Uint8Array) => void)[] = [];14-15- function push(queue: Uint8Array[], waiters: ((d: Uint8Array) => void)[], data: Uint8Array) {16- if (waiters.length > 0) waiters.shift()!(data);17- else queue.push(data);18- }19-20- function pop(queue: Uint8Array[], waiters: ((d: Uint8Array) => void)[]): Promise<Uint8Array> {21- return new Promise(res => {22- if (queue.length > 0) res(queue.shift()!);23- else waiters.push(res);24- });25- }26-27- const a: SyncTransport = {28- send: async (d) => push(queueAtoB, waitersAtoB, d),29- receive: () => pop(queueBtoA, waitersBtoA),30- close: async () => {},31- };32- const b: SyncTransport = {33- send: async (d) => push(queueBtoA, waitersBtoA, d),34- receive: () => pop(queueAtoB, waitersAtoB),35- close: async () => {},36- };37- return [a, b];38-}39-40-const DEVICE_LIST: SyncableDevice[] = [41- { publicKeyHex: 'aaa', deviceName: 'iPhone', pairedAt: 1000 },42-];43-44-function makeDeps(overrides: Partial<SyncDeps> = {}): SyncDeps {45- return {46- isVaultUnlocked: () => true,47- getVaultBlobForSync: async () => ({48- blob: JSON.stringify({ a: 'slotA', b: 'slotB' }),49- timestamp: 1000,50- devices: DEVICE_LIST,51- }),52- applyIncomingVault: async () => false,53- ...overrides,54- };55-}56-57-describe('runBlobSyncSession', () => {58- it('initiator applies newer remote vault', async () => {59- const apply = vi.fn().mockResolvedValue(true);60- const [tInitiator, tResponder] = makePairedTransports();61-62- // Responder side: sends a newer vault (timestamp 2000 > initiator's 1000)63- const responderDeps = makeDeps({64- getVaultBlobForSync: async () => ({65- blob: JSON.stringify({ a: 'newer', b: null }),66- timestamp: 2000,67- devices: DEVICE_LIST,68- }),69- applyIncomingVault: async () => false,70- });71- const initiatorDeps = makeDeps({ applyIncomingVault: apply });72-73- const [result] = await Promise.all([74- runBlobSyncSession('initiator', tInitiator, initiatorDeps),75- runBlobSyncSession('responder', tResponder, responderDeps),76- ]);77-78- expect(result.status).toBe('completed');79- expect(result.applied).toBe(true);80- expect(apply).toHaveBeenCalledWith(81- expect.stringContaining('newer'),82- 2000,83- undefined,84- DEVICE_LIST,85- );86- });87-88- it('initiator keeps local vault when remote is older', async () => {89- const apply = vi.fn().mockResolvedValue(false);90- const [tInitiator, tResponder] = makePairedTransports();91-92- const responderDeps = makeDeps({93- getVaultBlobForSync: async () => ({94- blob: JSON.stringify({ a: 'old' }),95- timestamp: 500, // older96- devices: DEVICE_LIST,97- }),98- applyIncomingVault: async () => false,99- });100- const initiatorDeps = makeDeps({ applyIncomingVault: apply });101-102- const [result] = await Promise.all([103- runBlobSyncSession('initiator', tInitiator, initiatorDeps),104- runBlobSyncSession('responder', tResponder, responderDeps),105- ]);106-107- expect(result.status).toBe('completed');108- expect(result.applied).toBe(false);109- });110-111- it('both sides exchange vaults bidirectionally', async () => {112- const applyI = vi.fn().mockResolvedValue(true);113- const applyR = vi.fn().mockResolvedValue(true);114- const [tInitiator, tResponder] = makePairedTransports();115-116- const initiatorDeps = makeDeps({117- getVaultBlobForSync: async () => ({118- blob: JSON.stringify({ a: 'initiatorVault' }),119- timestamp: 1000,120- devices: DEVICE_LIST,121- }),122- applyIncomingVault: applyI,123- });124- const responderDeps = makeDeps({125- getVaultBlobForSync: async () => ({126- blob: JSON.stringify({ a: 'responderVault' }),127- timestamp: 2000,128- devices: DEVICE_LIST,129- }),130- applyIncomingVault: applyR,131- });132-133- await Promise.all([134- runBlobSyncSession('initiator', tInitiator, initiatorDeps),135- runBlobSyncSession('responder', tResponder, responderDeps),136- ]);137-138- // Initiator received responder's vault (newer), responder received initiator's (older)139- expect(applyI).toHaveBeenCalledWith(expect.stringContaining('responderVault'), 2000, undefined, DEVICE_LIST);140- expect(applyR).toHaveBeenCalledWith(expect.stringContaining('initiatorVault'), 1000, undefined, DEVICE_LIST);141- });142-143- it('returns skipped when vault is locked', async () => {144- const [t] = makePairedTransports();145- const deps = makeDeps({ isVaultUnlocked: () => false });146- const result = await runBlobSyncSession('initiator', t, deps);147- expect(result.status).toBe('skipped');148- expect(result.error).toBe('vault_locked');149- });150-151- it('transfers Pro license when receiver has none', async () => {152- const apply = vi.fn().mockResolvedValue(true);153- const [tInitiator, tResponder] = makePairedTransports();154-155- const responderDeps = makeDeps({156- getVaultBlobForSync: async () => ({157- blob: JSON.stringify({ a: 'vault' }),158- timestamp: 2000,159- license: 'PRO-LICENSE-KEY',160- devices: DEVICE_LIST,161- }),162- applyIncomingVault: async () => false,163- });164- const initiatorDeps = makeDeps({ applyIncomingVault: apply });165-166- await Promise.all([167- runBlobSyncSession('initiator', tInitiator, initiatorDeps),168- runBlobSyncSession('responder', tResponder, responderDeps),169- ]);170-171- expect(apply).toHaveBeenCalledWith(expect.any(String), 2000, 'PRO-LICENSE-KEY', DEVICE_LIST);172- });173-174- it('does not override existing license when both sides have one', async () => {175- // Both sides have a license — the responder's apply function receives176- // the initiator's license but this is handled by vault.applyIncomingVault177- // which skips overwriting. The coordinator just passes it through.178- const apply = vi.fn().mockResolvedValue(false);179- const [tInitiator, tResponder] = makePairedTransports();180-181- const responderDeps = makeDeps({182- getVaultBlobForSync: async () => ({183- blob: JSON.stringify({ a: 'vault' }),184- timestamp: 500,185- license: 'RESPONDER-LICENSE',186- devices: DEVICE_LIST,187- }),188- applyIncomingVault: async () => false,189- });190- const initiatorDeps = makeDeps({191- getVaultBlobForSync: async () => ({192- blob: JSON.stringify({ a: 'vault' }),193- timestamp: 1000,194- license: 'INITIATOR-LICENSE',195- devices: DEVICE_LIST,196- }),197- applyIncomingVault: apply,198- });199-200- await Promise.all([201- runBlobSyncSession('initiator', tInitiator, initiatorDeps),202- runBlobSyncSession('responder', tResponder, responderDeps),203- ]);204-205- // Initiator's apply receives 'RESPONDER-LICENSE' — vault.ts decides not to overwrite206- expect(apply).toHaveBeenCalledWith(expect.any(String), 500, 'RESPONDER-LICENSE', DEVICE_LIST);207- });208-});209-210-describe('mesh device registry propagation', () => {211- it('passes remote device list to applyIncomingVault', async () => {212- const apply = vi.fn().mockResolvedValue(true);213- const [tInitiator, tResponder] = makePairedTransports();214-215- const remoteDevices: SyncableDevice[] = [216- { publicKeyHex: 'aaa', deviceName: 'iPhone', pairedAt: 1000 },217- { publicKeyHex: 'bbb', deviceName: 'MacBook', pairedAt: 2000 },218- { publicKeyHex: 'ccc', deviceName: 'iPad', pairedAt: 3000 },219- ];220-221- const responderDeps = makeDeps({222- getVaultBlobForSync: async () => ({223- blob: JSON.stringify({ a: 'vault' }),224- timestamp: 2000,225- devices: remoteDevices,226- }),227- applyIncomingVault: async () => false,228- });229- const initiatorDeps = makeDeps({ applyIncomingVault: apply });230-231- await Promise.all([232- runBlobSyncSession('initiator', tInitiator, initiatorDeps),233- runBlobSyncSession('responder', tResponder, responderDeps),234- ]);235-236- expect(apply).toHaveBeenCalledWith(expect.any(String), 2000, undefined, remoteDevices);237- });238-239- it('sends local device list in SYNC_VAULT_SEND', async () => {240- const remoteApply = vi.fn().mockResolvedValue(false);241- const [tInitiator, tResponder] = makePairedTransports();242-243- const localDevices: SyncableDevice[] = [244- { publicKeyHex: 'ddd', deviceName: 'Work Mac', pairedAt: 4000 },245- ];246-247- const initiatorDeps = makeDeps({248- getVaultBlobForSync: async () => ({249- blob: JSON.stringify({ a: 'vault' }),250- timestamp: 500,251- devices: localDevices,252- }),253- });254- const responderDeps = makeDeps({ applyIncomingVault: remoteApply });255-256- await Promise.all([257- runBlobSyncSession('initiator', tInitiator, initiatorDeps),258- runBlobSyncSession('responder', tResponder, responderDeps),259- ]);260-261- // Responder received the initiator's vault which included local devices262- expect(remoteApply).toHaveBeenCalledWith(expect.any(String), 500, undefined, localDevices);263- });264-});src/background/sync-coordinator.ts+0 −125
@@ -1,125 +0,0 @@1-import type { SyncTransport } from '../shared/websocket-sync-transport';2-import type { SyncableDevice } from '../shared/sync-types';3-import { encodeSyncMessage, decodeSyncMessage } from '../shared/sync-wire';4-5-export interface SyncDeps {6- getVaultBlobForSync: () => Promise<{7- blob: string;8- timestamp: number;9- license?: string;10- devices: SyncableDevice[];11- } | null>;12- applyIncomingVault: (13- blob: string,14- timestamp: number,15- license?: string,16- devices?: SyncableDevice[],17- ) => Promise<boolean>;18- isVaultUnlocked: () => boolean;19-}20-21-export interface SyncResult {22- status: 'completed' | 'skipped' | 'errored';23- applied: boolean;24- error?: string;25-}26-27-// Full bidirectional vault sync session — last-write-wins on timestamp.28-// Initiator sends SYNC_HELLO, responder sends its vault first, then initiator29-// sends its vault. Both sides apply if the remote is newer.30-export async function runBlobSyncSession(31- role: 'initiator' | 'responder',32- transport: SyncTransport,33- deps: SyncDeps,34-): Promise<SyncResult> {35- if (!deps.isVaultUnlocked()) {36- return { status: 'skipped', applied: false, error: 'vault_locked' };37- }38-39- try {40- if (role === 'initiator') {41- await transport.send(encodeSyncMessage({ type: 'SYNC_HELLO' }));42-43- // Receive remote vault first44- const remoteMsg = decodeSyncMessage(await transport.receive());45- let applied = false;46-47- if (remoteMsg.type === 'SYNC_VAULT_SEND') {48- applied = await deps.applyIncomingVault(49- remoteMsg.blob,50- remoteMsg.timestamp,51- remoteMsg.license,52- remoteMsg.devices,53- );54- await transport.send(encodeSyncMessage({ type: 'SYNC_VAULT_ACK', applied }));55- } else if (remoteMsg.type !== 'SYNC_DONE') {56- throw new Error(`unexpected message: ${remoteMsg.type}`);57- }58-59- // Send our vault60- const local = await deps.getVaultBlobForSync();61- if (local) {62- await transport.send(encodeSyncMessage({63- type: 'SYNC_VAULT_SEND',64- blob: local.blob,65- timestamp: local.timestamp,66- license: local.license,67- devices: local.devices,68- }));69- const ack = decodeSyncMessage(await transport.receive());70- if (ack.type !== 'SYNC_VAULT_ACK') throw new Error(`expected ACK, got ${ack.type}`);71- }72-73- await transport.send(encodeSyncMessage({ type: 'SYNC_DONE' }));74- await transport.receive(); // wait for remote SYNC_DONE75- await transport.close();76- return { status: 'completed', applied };77-78- } else {79- // Responder: wait for SYNC_HELLO80- const hello = decodeSyncMessage(await transport.receive());81- if (hello.type !== 'SYNC_HELLO') throw new Error(`expected SYNC_HELLO, got ${hello.type}`);82-83- // Send our vault first84- const local = await deps.getVaultBlobForSync();85- if (local) {86- await transport.send(encodeSyncMessage({87- type: 'SYNC_VAULT_SEND',88- blob: local.blob,89- timestamp: local.timestamp,90- license: local.license,91- devices: local.devices,92- }));93- const ack = decodeSyncMessage(await transport.receive());94- if (ack.type !== 'SYNC_VAULT_ACK') throw new Error(`expected ACK, got ${ack.type}`);95- } else {96- await transport.send(encodeSyncMessage({ type: 'SYNC_DONE' }));97- }98-99- // Receive initiator's vault100- const remoteMsg = decodeSyncMessage(await transport.receive());101- let applied = false;102- if (remoteMsg.type === 'SYNC_VAULT_SEND') {103- applied = await deps.applyIncomingVault(104- remoteMsg.blob,105- remoteMsg.timestamp,106- remoteMsg.license,107- remoteMsg.devices,108- );109- await transport.send(encodeSyncMessage({ type: 'SYNC_VAULT_ACK', applied }));110- }111-112- // Wait for SYNC_DONE then echo it113- const done = decodeSyncMessage(await transport.receive());114- if (done.type === 'SYNC_DONE') {115- await transport.send(encodeSyncMessage({ type: 'SYNC_DONE' }));116- }117-118- await transport.close();119- return { status: 'completed', applied };120- }121- } catch (err) {122- await transport.close().catch(() => {});123- return { status: 'errored', applied: false, error: String(err) };124- }125-}src/background/sync-identity-encryption.test.ts+0 −111
@@ -1,111 +0,0 @@1-/**2- * sync-identity-encryption.test.ts — M12/SEC-0033- *4- * getSyncIdentity must encrypt the X25519 private key under the vault's5- * derived key before persisting, must require unlock to read, and must6- * migrate v0 (unencrypted) records to v1 (encrypted) on first read.7- */8-9-import { beforeEach, describe, expect, it, vi } from "vitest";10-11-describe("sync identity encryption (M12/SEC-003)", () => {12- let storage: Record<string, unknown>;13-14- beforeEach(() => {15- vi.resetModules();16- vi.useRealTimers();17- storage = {};18-19- const chromeMock = {20- runtime: { id: "peach-extension", sendMessage: vi.fn() },21- storage: {22- local: {23- get: vi.fn(async (key?: string | string[]) => {24- if (!key) return storage;25- if (typeof key === "string") return { [key]: storage[key] };26- return key.reduce<Record<string, unknown>>((acc, k) => {27- acc[k] = storage[k];28- return acc;29- }, {});30- }),31- set: vi.fn(async (next: Record<string, unknown>) => {32- Object.assign(storage, next);33- }),34- remove: vi.fn(async (keys: string | string[]) => {35- const list = Array.isArray(keys) ? keys : [keys];36- for (const k of list) delete storage[k];37- }),38- },39- session: {40- get: vi.fn(async () => ({})),41- set: vi.fn(async () => {}),42- remove: vi.fn(async () => {}),43- },44- },45- alarms: {46- clear: vi.fn(),47- create: vi.fn(),48- onAlarm: { addListener: vi.fn() },49- },50- idle: {51- setDetectionInterval: vi.fn(),52- onStateChanged: { addListener: vi.fn() },53- },54- } as unknown as typeof chrome;55- (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;56- });57-58- it("throws vault_locked when called before unlock", async () => {59- const vault = await import("./vault");60- await expect(vault.getSyncIdentity()).rejects.toThrow(/vault_locked/);61- });62-63- it("persisted record contains no plaintext private key field after generation", async () => {64- const vault = await import("./vault");65- await vault.createVault("test-pass-12345");66- const { privateKeyBytes } = await vault.getSyncIdentity();67-68- const stored = JSON.parse(storage.peach_sync_identity as string) as Record<string, unknown>;69- expect(stored.priv).toBeUndefined();70- expect(typeof stored.enc_priv).toBe("string");71- expect(typeof stored.enc_nonce).toBe("string");72- expect(typeof stored.enc_tag).toBe("string");73- expect(typeof stored.pub).toBe("string");74- expect(privateKeyBytes.length).toBe(32);75- });76-77- it("subsequent reads return the same private key bytes", async () => {78- const vault = await import("./vault");79- await vault.createVault("test-pass-12345");80- const first = await vault.getSyncIdentity();81- const second = await vault.getSyncIdentity();82- expect(Array.from(second.privateKeyBytes)).toEqual(Array.from(first.privateKeyBytes));83- expect(second.publicKeyHex).toBe(first.publicKeyHex);84- });85-86- it("migrates v0 (unencrypted priv/pub) record to v1 encrypted form", async () => {87- const vault = await import("./vault");88- await vault.createVault("test-pass-12345");89-90- // Overwrite with a v0-shaped record.91- const fakePriv = new Uint8Array(32).fill(7);92- const fakePub = new Uint8Array(32).fill(8);93- const b64 = (b: Uint8Array) => {94- let s = "";95- for (const v of b) s += String.fromCharCode(v);96- return btoa(s);97- };98- storage.peach_sync_identity = JSON.stringify({99- priv: b64(fakePriv),100- pub: b64(fakePub),101- });102-103- const result = await vault.getSyncIdentity();104- expect(Array.from(result.privateKeyBytes)).toEqual(Array.from(fakePriv));105-106- // Storage was upgraded.107- const stored = JSON.parse(storage.peach_sync_identity as string) as Record<string, unknown>;108- expect(stored.priv).toBeUndefined();109- expect(typeof stored.enc_priv).toBe("string");110- });111-});src/background/sync-info-sheet.test.ts+0 −184
@@ -1,184 +0,0 @@1-import { describe, it, expect, beforeEach } from 'vitest';2-import { vi } from 'vitest';3-4-import type { Message, MessageResponse } from '../shared/types';5-6-type StorageShape = Record<string, unknown>;7-type MessageListener = (8- msg: unknown,9- sender: chrome.runtime.MessageSender,10- sendResponse: (response: MessageResponse) => void11-) => boolean;12-13-describe('hasPaired flag', () => {14- let storage: StorageShape;15-16- function installChromeMock(s: StorageShape): void {17- const chromeMock = {18- storage: {19- local: {20- get: vi.fn(async (key?: string | string[]) => {21- if (!key) return s;22- if (typeof key === 'string') return { [key]: s[key] };23- return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = s[k]; return acc; }, {});24- }),25- set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(s, next); }),26- remove: vi.fn(async (keys: string | string[]) => {27- const list = Array.isArray(keys) ? keys : [keys];28- for (const key of list) delete s[key];29- }),30- },31- },32- } as unknown as typeof chrome;33- (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;34- }35-36- beforeEach(async () => {37- vi.resetModules();38- storage = {};39- installChromeMock(storage);40- });41-42- async function clearStorage(): Promise<void> {43- for (const key of Object.keys(storage)) delete storage[key];44- }45-46- it('getHasPaired returns false when never paired', async () => {47- await clearStorage();48- const { getHasPaired } = await import('./sync-storage');49- const result = await getHasPaired();50- expect(result).toBe(false);51- });52-53- it('setHasPaired persists across calls', async () => {54- const { setHasPaired, getHasPaired } = await import('./sync-storage');55- await setHasPaired();56- const result = await getHasPaired();57- expect(result).toBe(true);58- });59-60- it('hasPaired remains true after multiple calls to setHasPaired', async () => {61- const { setHasPaired, getHasPaired } = await import('./sync-storage');62- await setHasPaired();63- await setHasPaired(); // idempotent64- const result = await getHasPaired();65- expect(result).toBe(true);66- });67-});68-69-describe('PAIRING_COMPLETED handler', () => {70- let storage: StorageShape;71- let sessionStorage: StorageShape;72- let listener: MessageListener;73-74- beforeEach(async () => {75- vi.resetModules();76- vi.useRealTimers();77- storage = { peach_settings: { breachMonitoring: false } };78- sessionStorage = {};79-80- vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' })));81- vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false);82-83- const chromeMock = {84- runtime: {85- id: 'peach-extension',86- onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }) },87- sendMessage: vi.fn(async () => ({ ok: true })),88- },89- tabs: {90- query: vi.fn((_, cb?: (tabs: unknown[]) => void) => {91- if (typeof cb === 'function') { cb([{ id: 1 }]); return; }92- return Promise.resolve([{ id: 1 }]);93- }),94- sendMessage: vi.fn(async () => ({ ok: true })),95- },96- storage: {97- local: {98- get: vi.fn(async (key?: string | string[]) => {99- if (!key) return storage;100- if (typeof key === 'string') return { [key]: storage[key] };101- return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = storage[k]; return acc; }, {});102- }),103- set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(storage, next); }),104- remove: vi.fn(async (keys: string | string[]) => {105- const list = Array.isArray(keys) ? keys : [keys];106- for (const key of list) delete storage[key];107- }),108- },109- session: {110- get: vi.fn(async (key?: string | string[]) => {111- if (!key) return sessionStorage;112- if (typeof key === 'string') return { [key]: sessionStorage[key] };113- return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});114- }),115- set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(sessionStorage, next); }),116- remove: vi.fn(async (keys: string | string[]) => {117- const list = Array.isArray(keys) ? keys : [keys];118- for (const key of list) delete sessionStorage[key];119- }),120- clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }),121- },122- },123- alarms: {124- clear: vi.fn(), create: vi.fn(),125- onAlarm: { addListener: vi.fn() },126- },127- idle: {128- setDetectionInterval: vi.fn(),129- onStateChanged: { addListener: vi.fn() },130- },131- scripting: { executeScript: vi.fn(async () => []) },132- } as unknown as typeof chrome;133-134- (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;135-136- const messages = await import('./messages');137- messages.setupMessageHandler();138- const vault = await import('./vault');139- await vault.ensureSettingsLoaded();140- });141-142- async function dispatch(message: Message): Promise<MessageResponse> {143- return new Promise<MessageResponse>((resolve) => {144- const keepOpen = listener(message, { id: 'peach-extension' }, resolve);145- expect(keepOpen).toBe(true);146- });147- }148-149- it('flag is set when PAIRING_COMPLETED fires', async () => {150- // M13/SEC-005 — PAIRING_COMPLETED now requires the publicKeyHex to have151- // come from a real Noise handshake. Tests must register the verified key152- // explicitly; production paths do this from doVaultSync / responder auth.153- const messages = await import('./messages');154- messages.markPairingHandshakeVerified('abc123');155- await dispatch({156- type: 'PAIRING_COMPLETED',157- payload: {158- deviceName: 'iPhone',159- publicKeyHex: 'abc123',160- deviceIp: '192.168.1.50',161- },162- });163- const { getHasPaired } = await import('./sync-storage');164- const paired = await getHasPaired();165- expect(paired).toBe(true);166- });167-168- it('PAIRING_COMPLETED stores device with lastKnownIp and deviceName', async () => {169- const messages = await import('./messages');170- messages.markPairingHandshakeVerified('abc123');171- await dispatch({172- type: 'PAIRING_COMPLETED',173- payload: {174- deviceName: 'iPhone',175- publicKeyHex: 'abc123',176- deviceIp: '192.168.1.50',177- },178- });179- const { getPairedDevices } = await import('./sync-storage');180- const devices = await getPairedDevices();181- expect(devices[0].lastKnownIp).toBe('192.168.1.50');182- expect(devices[0].displayName).toBe('iPhone');183- });184-});src/background/sync-network.ts+0 −47
@@ -1,47 +0,0 @@1-export interface NetworkInfo {2- ssid?: string;3- bssid?: string;4- isTailscale: boolean;5- ipAddress?: string;6-}7-8-export function isTailscaleIP(ip: string): boolean {9- const parts = ip.split('.').map(Number);10- if (parts.length !== 4 || parts.some(isNaN)) return false;11- const [a, b] = parts;12- return a === 100 && b >= 64 && b <= 127;13-}14-15-export async function isOnTailscale(): Promise<boolean> {16- try {17- if (typeof chrome === 'undefined' || !(chrome as unknown as { system?: { network?: { getNetworkInterfaces: () => Promise<Array<{ address: string }>> } } }).system?.network) return false;18- const networkApi = (chrome as unknown as { system: { network: { getNetworkInterfaces: () => Promise<Array<{ address: string }>> } } }).system.network;19- const interfaces = await networkApi.getNetworkInterfaces();20- return interfaces.some((iface) => isTailscaleIP(iface.address));21- } catch {22- return false;23- }24-}25-26-export async function getCurrentNetwork(): Promise<NetworkInfo> {27- const info: NetworkInfo = { isTailscale: false };28-29- try {30- const chromeTyped = chrome as unknown as { system?: { network?: { getNetworkInterfaces: () => Promise<Array<{ address: string; name?: string }>> } } };31- if (chromeTyped.system?.network) {32- const networkApi = chromeTyped.system.network;33- const interfaces = await networkApi.getNetworkInterfaces();34- const tailscaleIface = interfaces.find((iface) => isTailscaleIP(iface.address));35- if (tailscaleIface) {36- info.isTailscale = true;37- info.ipAddress = tailscaleIface.address;38- } else if (interfaces.length > 0) {39- info.ipAddress = interfaces[0].address;40- }41- }42- } catch {43- // Fallback: network info not available44- }45-46- return info;47-}src/background/sync-pro-gate.test.ts+0 −169
@@ -1,169 +0,0 @@1-import { describe, it, expect, beforeEach } from 'vitest';2-import { vi } from 'vitest';3-4-import type { Message, MessageResponse } from '../shared/types';5-6-type StorageShape = Record<string, unknown>;7-type MessageListener = (8- msg: unknown,9- sender: chrome.runtime.MessageSender,10- sendResponse: (response: MessageResponse) => void11-) => boolean;12-13-describe('sync Pro gating', () => {14- let storage: StorageShape;15- let sessionStorage: StorageShape;16- let listener: MessageListener;17-18- beforeEach(async () => {19- vi.resetModules();20- vi.useRealTimers();21- storage = { peach_settings: { breachMonitoring: false } };22- sessionStorage = {};23-24- vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' })));25- vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false);26- // Default: desktop (no Capacitor)27- (globalThis as Record<string, unknown>).Capacitor = undefined;28-29- const chromeMock = {30- runtime: {31- id: 'peach-extension',32- onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }) },33- sendMessage: vi.fn(async () => ({ ok: true })),34- },35- tabs: {36- query: vi.fn((_, cb?: (tabs: unknown[]) => void) => {37- if (typeof cb === 'function') { cb([{ id: 1 }]); return; }38- return Promise.resolve([{ id: 1 }]);39- }),40- sendMessage: vi.fn(async () => ({ ok: true })),41- },42- storage: {43- local: {44- get: vi.fn(async (key?: string | string[]) => {45- if (!key) return storage;46- if (typeof key === 'string') return { [key]: storage[key] };47- return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = storage[k]; return acc; }, {});48- }),49- set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(storage, next); }),50- remove: vi.fn(async (keys: string | string[]) => {51- const list = Array.isArray(keys) ? keys : [keys];52- for (const key of list) delete storage[key];53- }),54- },55- session: {56- get: vi.fn(async (key?: string | string[]) => {57- if (!key) return sessionStorage;58- if (typeof key === 'string') return { [key]: sessionStorage[key] };59- return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});60- }),61- set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(sessionStorage, next); }),62- remove: vi.fn(async (keys: string | string[]) => {63- const list = Array.isArray(keys) ? keys : [keys];64- for (const key of list) delete sessionStorage[key];65- }),66- clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }),67- },68- },69- alarms: {70- clear: vi.fn(), create: vi.fn(),71- onAlarm: { addListener: vi.fn() },72- },73- idle: {74- setDetectionInterval: vi.fn(),75- onStateChanged: { addListener: vi.fn() },76- },77- scripting: { executeScript: vi.fn(async () => []) },78- } as unknown as typeof chrome;79-80- (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;81-82- const messages = await import('./messages');83- messages.setupMessageHandler();84- const vault = await import('./vault');85- await vault.ensureSettingsLoaded();86- });87-88- async function dispatch(message: Message): Promise<MessageResponse> {89- return new Promise<MessageResponse>((resolve) => {90- const keepOpen = listener(message, { id: 'peach-extension' }, resolve);91- expect(keepOpen).toBe(true);92- });93- }94-95- async function mockNoPro(): Promise<void> {96- const license = await import('./license');97- vi.spyOn(license, 'isProUser').mockResolvedValue(false);98- }99-100- async function mockPro(): Promise<void> {101- const license = await import('./license');102- vi.spyOn(license, 'isProUser').mockResolvedValue(true);103- }104-105- function mockDesktop(): void {106- (globalThis as Record<string, unknown>).Capacitor = undefined;107- }108-109- function mockNative(): void {110- (globalThis as Record<string, unknown>).Capacitor = { isNativePlatform: () => true };111- }112-113- describe('pairing initiation — free', () => {114- it('START_PAIRING_SERVER succeeds without Pro', async () => {115- await mockNoPro();116- mockDesktop();117- const result = await dispatch({ type: 'START_PAIRING_SERVER' });118- expect((result as { error?: string }).error ?? '').not.toMatch(/pro_required/);119- });120-121- it('ACCEPT_PAIRING succeeds without Pro', async () => {122- await mockNoPro();123- const result = await dispatch({124- type: 'ACCEPT_PAIRING',125- payload: { token: 'test-token' },126- });127- expect((result as { error?: string }).error ?? '').not.toMatch(/pro_required/);128- });129-130- it('SCAN_PAIRING_QR does not return pro_required (returns android_only on desktop)', async () => {131- await mockNoPro();132- mockDesktop();133- const result = await dispatch({134- type: 'SCAN_PAIRING_QR',135- payload: { token: '123456' },136- });137- expect((result as { error?: string }).error ?? '').not.toMatch(/pro_required/);138- });139- });140-141- describe('ongoing sync — Pro required', () => {142- it('TRIGGER_SYNC is Pro-gated on desktop', async () => {143- await mockNoPro();144- mockDesktop();145- const result = await dispatch({ type: 'TRIGGER_SYNC' });146- expect(result.ok).toBe(false);147- expect((result as { error: string }).error).toMatch(/pro_required/);148- });149-150- it('TRIGGER_SYNC with Pro on desktop does not return pro_required', async () => {151- await mockPro();152- mockDesktop();153- const vault = await import('./vault');154- await vault.createVault('test-pass-12345');155- const result = await dispatch({ type: 'TRIGGER_SYNC' });156- expect((result as { error?: string }).error ?? '').not.toMatch(/pro_required/);157- });158-159- it('TRIGGER_SYNC with Pro on native succeeds', async () => {160- await mockPro();161- mockNative();162- const vault = await import('./vault');163- await vault.createVault('test-pass-12345');164- const result = await dispatch({ type: 'TRIGGER_SYNC' });165- expect(result.ok).toBe(true);166- expect((result as { data: { mode: string } }).data?.mode).toBe('server');167- });168- });169-});src/background/sync-screen.test.ts+0 −175
@@ -1,175 +0,0 @@1-import { describe, it, expect, beforeEach } from 'vitest';2-import { vi } from 'vitest';3-4-import type { Message, MessageResponse } from '../shared/types';5-6-type StorageShape = Record<string, unknown>;7-type MessageListener = (8- msg: unknown,9- sender: chrome.runtime.MessageSender,10- sendResponse: (response: MessageResponse) => void11-) => boolean;12-13-describe('sync screen handlers', () => {14- let storage: StorageShape;15- let sessionStorage: StorageShape;16- let listener: MessageListener;17-18- beforeEach(async () => {19- vi.resetModules();20- vi.useRealTimers();21- storage = { peach_settings: { breachMonitoring: false } };22- sessionStorage = {};23-24- vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' })));25- vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false);26-27- const chromeMock = {28- runtime: {29- id: 'peach-extension',30- onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }) },31- sendMessage: vi.fn(async () => ({ ok: true })),32- },33- tabs: {34- query: vi.fn((_, cb?: (tabs: unknown[]) => void) => {35- if (typeof cb === 'function') { cb([{ id: 1 }]); return; }36- return Promise.resolve([{ id: 1 }]);37- }),38- sendMessage: vi.fn(async () => ({ ok: true })),39- },40- storage: {41- local: {42- get: vi.fn(async (key?: string | string[]) => {43- if (!key) return storage;44- if (typeof key === 'string') return { [key]: storage[key] };45- return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = storage[k]; return acc; }, {});46- }),47- set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(storage, next); }),48- remove: vi.fn(async (keys: string | string[]) => {49- const list = Array.isArray(keys) ? keys : [keys];50- for (const key of list) delete storage[key];51- }),52- },53- session: {54- get: vi.fn(async (key?: string | string[]) => {55- if (!key) return sessionStorage;56- if (typeof key === 'string') return { [key]: sessionStorage[key] };57- return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});58- }),59- set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(sessionStorage, next); }),60- remove: vi.fn(async (keys: string | string[]) => {61- const list = Array.isArray(keys) ? keys : [keys];62- for (const key of list) delete sessionStorage[key];63- }),64- clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }),65- },66- },67- alarms: {68- clear: vi.fn(), create: vi.fn(),69- onAlarm: { addListener: vi.fn() },70- },71- idle: {72- setDetectionInterval: vi.fn(),73- onStateChanged: { addListener: vi.fn() },74- },75- scripting: { executeScript: vi.fn(async () => []) },76- } as unknown as typeof chrome;77-78- (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;79-80- const messages = await import('./messages');81- messages.setupMessageHandler();82- const vault = await import('./vault');83- await vault.ensureSettingsLoaded();84- });85-86- async function dispatch(message: Message): Promise<MessageResponse> {87- return new Promise<MessageResponse>((resolve) => {88- const keepOpen = listener(message, { id: 'peach-extension' }, resolve);89- expect(keepOpen).toBe(true);90- });91- }92-93- function storageSet(key: string, value: unknown): void {94- storage[key] = value;95- }96-97- describe('GET_PAIRED_DEVICES handler', () => {98- it('returns empty array when no devices paired', async () => {99- const result = await dispatch({ type: 'GET_PAIRED_DEVICES' });100- expect(result.ok).toBe(true);101- const data = (result as { data: { devices: unknown[] } }).data;102- expect(data.devices).toEqual([]);103- });104-105- it('returns paired devices list', async () => {106- storage['peach_paired_devices'] = [107- {108- deviceId: 'abc',109- displayName: 'MacBook',110- x25519PublicKey: 'abc',111- pairedAt: Date.now(),112- lastKnownIp: '192.168.1.1',113- },114- ];115- const result = await dispatch({ type: 'GET_PAIRED_DEVICES' });116- const data = (result as { data: { devices: Array<Record<string, unknown>> } }).data;117- expect(data.devices.length).toBe(1);118- expect(data.devices[0].displayName).toBe('MacBook');119- });120-121- it('does not expose private keys in paired devices', async () => {122- storage['peach_paired_devices'] = [123- {124- deviceId: 'abc',125- displayName: 'MacBook',126- x25519PublicKey: 'abc',127- pairedAt: Date.now(),128- lastKnownIp: '192.168.1.1',129- },130- ];131- const result = await dispatch({ type: 'GET_PAIRED_DEVICES' });132- const data = (result as { data: { devices: Array<Record<string, unknown>> } }).data;133- expect(data.devices[0].privateKey).toBeUndefined();134- });135- });136-137- describe('GET_SYNC_STATUS handler', () => {138- it('returns never status when never synced', async () => {139- const result = await dispatch({ type: 'GET_SYNC_STATUS' });140- expect(result.ok).toBe(true);141- const data = (result as { data: { status: string; lastSyncAt: unknown } }).data;142- expect(data.status).toBe('never');143- expect(data.lastSyncAt).toBeNull();144- });145-146- it('returns up_to_date after successful sync', async () => {147- storageSet('peach_last_sync_at', String(Date.now()));148- const result = await dispatch({ type: 'GET_SYNC_STATUS' });149- const data = (result as { data: { status: string; lastSyncAt: unknown } }).data;150- expect(data.status).toBe('up_to_date');151- expect(data.lastSyncAt).toBeTruthy();152- });153- });154-155- describe('RESET_SYNC_IDENTITY handler', () => {156- it('is Pro-gated', async () => {157- const result = await dispatch({ type: 'RESET_SYNC_IDENTITY' });158- expect(result.ok).toBe(false);159- expect((result as { error: string }).error).toMatch(/pro_required/);160- });161-162- it('clears paired devices on reset for Pro users', async () => {163- storage['peach_paired_devices'] = [164- { deviceId: 'a', displayName: 'iPhone', x25519PublicKey: 'pk-a', pairedAt: 1 },165- { deviceId: 'b', displayName: 'MacBook', x25519PublicKey: 'pk-b', pairedAt: 2 },166- ];167- const license = await import('./license');168- vi.spyOn(license, 'isProUser').mockResolvedValue(true);169- const result = await dispatch({ type: 'RESET_SYNC_IDENTITY' });170- expect(result.ok).toBe(true);171- expect((result as { data: { removedDevices: number } }).data?.removedDevices).toBe(2);172- expect(storage['peach_paired_devices']).toEqual([]);173- });174- });175-});src/background/sync-storage.ts+0 −146
@@ -1,146 +0,0 @@1-import type { TrustedNetwork, PairedDevice, IncomingPairingRequest } from '../shared/sync-types';2-3-const TRUSTED_NETWORKS_KEY = 'peach_trusted_networks';4-const PAIRED_DEVICES_KEY = 'peach_paired_devices';5-const INCOMING_PAIR_KEY = 'peach_incoming_pair';6-const PEACH_HAS_PAIRED_KEY = 'peach_has_paired';7-8-// ---------------------------------------------------------------------------9-// Has-paired flag10-//11-// Set once on first successful pairing. Never cleared — controls whether the12-// sync info sheet is shown.13-// ---------------------------------------------------------------------------14-15-export async function getHasPaired(): Promise<boolean> {16- try {17- const result = await chrome.storage.local.get(PEACH_HAS_PAIRED_KEY);18- return (result as Record<string, unknown>)[PEACH_HAS_PAIRED_KEY] === true;19- } catch {20- return false;21- }22-}23-24-export async function setHasPaired(): Promise<void> {25- await chrome.storage.local.set({ [PEACH_HAS_PAIRED_KEY]: true });26-}27-28-// ---------------------------------------------------------------------------29-// Trusted Networks30-// ---------------------------------------------------------------------------31-32-export async function getTrustedNetworks(): Promise<TrustedNetwork[]> {33- try {34- const result = await chrome.storage.local.get(TRUSTED_NETWORKS_KEY);35- return (result as { peach_trusted_networks?: TrustedNetwork[] })[TRUSTED_NETWORKS_KEY] ?? [];36- } catch {37- return [];38- }39-}40-41-export async function addTrustedNetwork(network: TrustedNetwork): Promise<void> {42- try {43- const networks = await getTrustedNetworks();44- networks.push(network);45- await chrome.storage.local.set({ [TRUSTED_NETWORKS_KEY]: networks });46- } catch (err) {47- throw new Error(`Failed to add trusted network: ${err}`);48- }49-}50-51-export async function removeTrustedNetwork(addedAt: number): Promise<void> {52- try {53- const networks = await getTrustedNetworks();54- const filtered = networks.filter((n) => n.addedAt !== addedAt);55- await chrome.storage.local.set({ [TRUSTED_NETWORKS_KEY]: filtered });56- } catch (err) {57- throw new Error(`Failed to remove trusted network: ${err}`);58- }59-}60-61-// ---------------------------------------------------------------------------62-// Paired Devices63-// ---------------------------------------------------------------------------64-65-export async function getPairedDevices(): Promise<PairedDevice[]> {66- try {67- const result = await chrome.storage.local.get(PAIRED_DEVICES_KEY);68- return (result as { peach_paired_devices?: PairedDevice[] })[PAIRED_DEVICES_KEY] ?? [];69- } catch {70- return [];71- }72-}73-74-export async function addPairedDevice(device: PairedDevice): Promise<void> {75- try {76- const devices = await getPairedDevices();77- const exists = devices.some((d) => d.deviceId === device.deviceId);78- if (exists) throw new Error(`Device ${device.deviceId} is already paired`);79- devices.push(device);80- await chrome.storage.local.set({ [PAIRED_DEVICES_KEY]: devices });81- } catch (err) {82- throw new Error(`Failed to add paired device: ${err}`);83- }84-}85-86-export async function removePairedDevice(deviceId: string): Promise<void> {87- try {88- const devices = await getPairedDevices();89- const filtered = devices.filter((d) => d.deviceId !== deviceId);90- await chrome.storage.local.set({ [PAIRED_DEVICES_KEY]: filtered });91- } catch (err) {92- throw new Error(`Failed to remove paired device: ${err}`);93- }94-}95-96-export async function updatePairedDevice(97- x25519PublicKey: string,98- patch: Partial<Pick<PairedDevice, 'lastSeenAt' | 'lastSyncAt' | 'lastKnownIp'>>,99-): Promise<void> {100- try {101- const devices = await getPairedDevices();102- const updated = devices.map((d) =>103- d.x25519PublicKey === x25519PublicKey ? { ...d, ...patch } : d,104- );105- await chrome.storage.local.set({ [PAIRED_DEVICES_KEY]: updated });106- } catch (err) {107- throw new Error(`Failed to update paired device: ${err}`);108- }109-}110-111-// ---------------------------------------------------------------------------112-// Incoming Pairing Request113-//114-// Stored in session storage so the request disappears when the browser closes115-// and never lands on disk. The 60-second expiry is enforced by the consumer116-// (`getIncomingPairingRequest` returns null past expiry and clears storage).117-// ---------------------------------------------------------------------------118-119-export async function setIncomingPairingRequest(120- request: IncomingPairingRequest121-): Promise<void> {122- await chrome.storage.session.set({ [INCOMING_PAIR_KEY]: request });123-}124-125-export async function getIncomingPairingRequest(): Promise<IncomingPairingRequest | null> {126- try {127- const result = await chrome.storage.session.get(INCOMING_PAIR_KEY);128- const request = (result as { peach_incoming_pair?: IncomingPairingRequest })[INCOMING_PAIR_KEY];129- if (!request) return null;130- if (Date.now() >= request.expiresAt) {131- await clearIncomingPairingRequest();132- return null;133- }134- return request;135- } catch {136- return null;137- }138-}139-140-export async function clearIncomingPairingRequest(): Promise<void> {141- try {142- await chrome.storage.session.remove(INCOMING_PAIR_KEY);143- } catch {144- // session storage may be unavailable in some contexts; safe to ignore.145- }146-}src/background/sync.adversarial.test.ts+0 −883
@@ -1,883 +0,0 @@1-/**2- * sync.adversarial.test.ts — Layer 3 §3.5 adversarial sync tests.3- *4- * Exercises vault.applyIncomingVault timestamp logic, license non-overwrite,5- * malformed blob handling, and pairing-token lifecycle. Parameterized for6- * >=150 tests.7- *8- * Note: there is no APPLY_INCOMING_VAULT message; sync goes through9- * sync-coordinator/runBlobSyncSession. We exercise the underlying10- * applyIncomingVault export directly for the data-plane tests, and the11- * START_PAIRING_SERVER / STOP_PAIRING_SERVER / SCAN_PAIRING_QR /12- * ACCEPT_PAIRING handlers via the message bus for pairing tests.13- */14-15-import { beforeEach, describe, expect, it, vi } from "vitest";16-17-import type { Message, MessageResponse } from "../shared/types";18-19-type StorageShape = Record<string, unknown>;20-type MessageListener = (21- msg: unknown,22- sender: chrome.runtime.MessageSender,23- sendResponse: (response: MessageResponse) => void24-) => boolean;25-26-describe("sync adversarial", () => {27- let storage: StorageShape;28- let sessionStorage: StorageShape;29- let listener: MessageListener | undefined;30-31- beforeEach(async () => {32- vi.resetModules();33- vi.useRealTimers();34- storage = { peach_settings: { breachMonitoring: false } };35- sessionStorage = {};36- listener = undefined;37-38- vi.stubGlobal(39- "fetch",40- vi.fn(async () => ({ ok: true, status: 200, text: async () => "" }))41- );42- vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);43-44- const chromeMock = {45- runtime: {46- id: "peach-extension",47- onMessage: {48- addListener: vi.fn((fn: MessageListener) => {49- listener = fn;50- }),51- },52- sendMessage: vi.fn(async () => ({ ok: true })),53- },54- tabs: {55- query: vi.fn((_q: unknown, cb?: (tabs: unknown[]) => void) => {56- if (typeof cb === "function") {57- cb([{ id: 1 }]);58- return;59- }60- return Promise.resolve([{ id: 1 }]);61- }),62- sendMessage: vi.fn(async () => ({ ok: true })),63- },64- storage: {65- local: {66- get: vi.fn(async (key?: string | string[]) => {67- if (!key) return storage;68- if (typeof key === "string") return { [key]: storage[key] };69- return key.reduce<Record<string, unknown>>((acc, k) => {70- acc[k] = storage[k];71- return acc;72- }, {});73- }),74- set: vi.fn(async (next: Record<string, unknown>) => {75- Object.assign(storage, next);76- }),77- remove: vi.fn(async (keys: string | string[]) => {78- const list = Array.isArray(keys) ? keys : [keys];79- for (const key of list) delete storage[key];80- }),81- },82- session: {83- get: vi.fn(async (key?: string | string[]) => {84- if (!key) return sessionStorage;85- if (typeof key === "string") return { [key]: sessionStorage[key] };86- return key.reduce<Record<string, unknown>>((acc, k) => {87- acc[k] = sessionStorage[k];88- return acc;89- }, {});90- }),91- set: vi.fn(async (next: Record<string, unknown>) => {92- Object.assign(sessionStorage, next);93- }),94- remove: vi.fn(async (keys: string | string[]) => {95- const list = Array.isArray(keys) ? keys : [keys];96- for (const key of list) delete sessionStorage[key];97- }),98- clear: vi.fn(async () => {99- for (const k of Object.keys(sessionStorage)) delete sessionStorage[k];100- }),101- },102- },103- alarms: {104- clear: vi.fn(),105- create: vi.fn(),106- onAlarm: { addListener: vi.fn() },107- },108- idle: {109- setDetectionInterval: vi.fn(),110- onStateChanged: { addListener: vi.fn() },111- },112- scripting: { executeScript: vi.fn(async () => []) },113- } as unknown as typeof chrome;114-115- (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;116-117- const messages = await import("./messages");118- messages.setupMessageHandler();119- const vault = await import("./vault");120- await vault.ensureSettingsLoaded();121- });122-123- async function dispatch(message: Message): Promise<MessageResponse> {124- if (!listener) throw new Error("listener not registered");125- return new Promise<MessageResponse>((resolve) => {126- const keepOpen = listener!(127- message,128- { id: "peach-extension" } as chrome.runtime.MessageSender,129- resolve130- );131- expect(keepOpen).toBe(true);132- });133- }134-135- async function setupBlobs() {136- const vault = await import("./vault");137- await vault.createVault("test-pass-12345");138- // Force a known last_modified_at.139- storage.peach_last_modified_at = String(1_700_000_000_000);140- return vault;141- }142-143- function makeIncomingBlob(slotsLike?: {144- a?: Record<string, unknown>;145- b?: Record<string, unknown>;146- }): string {147- const a = slotsLike?.a ?? storage.peach_vault_a;148- const b = slotsLike?.b ?? storage.peach_vault_b;149- return JSON.stringify({ a, b });150- }151-152- // ── applyIncomingVault timestamp logic ──────────────────────────────────153-154- describe("applyIncomingVault timestamp adversarial", () => {155- const olderDeltas = [156- -1, -10, -1000, -60_000, -3_600_000, -86_400_000, -1_000_000_000,157- ];158- for (const delta of olderDeltas) {159- it(`incoming vault with timestamp delta ${delta}ms (older): applied=false`, async () => {160- const vault = await setupBlobs();161- const remoteTs = 1_700_000_000_000 + delta;162- const applied = await vault.applyIncomingVault(163- makeIncomingBlob(),164- remoteTs165- );166- expect(applied).toBe(false);167- });168- }169-170- it("same timestamp: applied=false (not equal-overwrite)", async () => {171- const vault = await setupBlobs();172- const applied = await vault.applyIncomingVault(173- makeIncomingBlob(),174- 1_700_000_000_000175- );176- expect(applied).toBe(false);177- });178-179- const newerDeltas = [1, 10, 1_000, 60_000, 3_600_000, 1_000_000_000];180- for (const delta of newerDeltas) {181- it(`incoming vault delta +${delta}ms (newer): applied=true`, async () => {182- const vault = await setupBlobs();183- const applied = await vault.applyIncomingVault(184- makeIncomingBlob(),185- 1_700_000_000_000 + delta186- );187- expect(applied).toBe(true);188- });189- }190-191- // AUD-006: applyIncomingVault now bounds the remote timestamp by a192- // 5-minute clock-skew budget so a paired peer with a bad/malicious clock193- // cannot pin last-write-wins permanently with Number.MAX_SAFE_INTEGER.194- describe("AUD-006: timestamp skew budget", () => {195- it("rejects remote timestamp beyond 5-minute skew budget", async () => {196- const vault = await setupBlobs();197- const farFuture = Date.now() + 6 * 60 * 1_000;198- const applied = await vault.applyIncomingVault(199- makeIncomingBlob(),200- farFuture201- );202- expect(applied).toBe(false);203- });204-205- it("rejects Number.MAX_SAFE_INTEGER timestamp", async () => {206- const vault = await setupBlobs();207- const applied = await vault.applyIncomingVault(208- makeIncomingBlob(),209- Number.MAX_SAFE_INTEGER210- );211- expect(applied).toBe(false);212- });213-214- it("rejects Date.now() * 100 (previously accepted)", async () => {215- const vault = await setupBlobs();216- const applied = await vault.applyIncomingVault(217- makeIncomingBlob(),218- Date.now() * 100219- );220- expect(applied).toBe(false);221- });222-223- it("accepts remote timestamp within 5-minute skew budget", async () => {224- const vault = await setupBlobs();225- const slightlyFuture = Date.now() + 4 * 60 * 1_000;226- const applied = await vault.applyIncomingVault(227- makeIncomingBlob(),228- slightlyFuture229- );230- expect(applied).toBe(true);231- });232-233- it("accepts legitimately newer remote timestamp", async () => {234- const vault = await setupBlobs();235- const applied = await vault.applyIncomingVault(236- makeIncomingBlob(),237- 1_700_000_000_000 + 10_000238- );239- expect(applied).toBe(true);240- });241- });242- });243-244- // ── applyIncomingVault malformed blob ───────────────────────────────────245-246- describe("applyIncomingVault malformed blob handling", () => {247- const malformedBlobs = [248- "",249- "not json",250- "{",251- "[]",252- "null",253- "true",254- "42",255- '{"a": null}',256- '{"a": "not-an-object"}',257- ];258- for (const blob of malformedBlobs) {259- it(`malformed blob ${JSON.stringify(blob).slice(0, 30)} does not poison local vault`, async () => {260- const vault = await setupBlobs();261- const before = storage.peach_vault_a;262- let threw = false;263- try {264- await vault.applyIncomingVault(blob, Date.now() + 100_000);265- } catch {266- threw = true;267- }268- // Either it threw cleanly or it ran. Either way, original vault must269- // still be unlockable.270- const reloaded = storage.peach_vault_a;271- if (!threw) {272- // If it didn't throw, slot A may have been overwritten with the273- // value of `a` from the parsed blob. That's a poison surface.274- // We pin observed behaviour: either it threw, or the slot is now275- // some new value.276- expect(reloaded).not.toBe(undefined);277- } else {278- expect(reloaded).toBe(before);279- }280- });281- }282- });283-284- // ── applyIncomingVault license non-overwrite ────────────────────────────285-286- describe("applyIncomingVault license handling", () => {287- it("existing license is not overwritten by incoming license", async () => {288- const vault = await setupBlobs();289- storage.peach_license = "my-existing-license";290- await vault.applyIncomingVault(291- makeIncomingBlob(),292- Date.now() + 100_000,293- "their-different-license"294- );295- expect(storage.peach_license).toBe("my-existing-license");296- });297-298- it("no local license: invalid incoming license is rejected (L16/SEC-010)", async () => {299- // L16 hardens applyIncomingVault to validate the signature before300- // adopting. Without a real signed license fixture, an unsigned string301- // must be silently dropped — a peer cannot plant arbitrary text as302- // our peach_license.303- const vault = await setupBlobs();304- delete storage.peach_license;305- await vault.applyIncomingVault(306- makeIncomingBlob(),307- Date.now() + 100_000,308- "incoming-license"309- );310- expect(storage.peach_license).toBeUndefined();311- });312-313- it("no incoming license: local license is unchanged", async () => {314- const vault = await setupBlobs();315- storage.peach_license = "local-pro";316- await vault.applyIncomingVault(317- makeIncomingBlob(),318- Date.now() + 100_000319- );320- expect(storage.peach_license).toBe("local-pro");321- });322-323- it("empty-string incoming license is treated as no license", async () => {324- const vault = await setupBlobs();325- delete storage.peach_license;326- await vault.applyIncomingVault(327- makeIncomingBlob(),328- Date.now() + 100_000,329- ""330- );331- // Empty string is falsy; no adoption.332- expect(storage.peach_license).toBeUndefined();333- });334- });335-336- // ── applyIncomingVault snapshot behaviour ───────────────────────────────337-338- describe("applyIncomingVault snapshot", () => {339- it("a snapshot is taken before applying a newer incoming vault", async () => {340- const vault = await setupBlobs();341- const historyBefore = await vault.getVaultHistory();342- await vault.applyIncomingVault(343- makeIncomingBlob(),344- Date.now() + 100_000345- );346- const historyAfter = await vault.getVaultHistory();347- expect(historyAfter.length).toBeGreaterThanOrEqual(historyBefore.length);348- });349- });350-351- // ── Pairing token adversarial ───────────────────────────────────────────352-353- describe("pairing token lifecycle", () => {354- it("START_PAIRING_SERVER yields a 6-digit token", async () => {355- const r = await dispatch({ type: "START_PAIRING_SERVER" });356- expect(r.ok).toBe(true);357- const data = (r as { data: { token: string; expiresAt: number } }).data;358- expect(data.token).toMatch(/^\d{6}$/);359- expect(data.expiresAt).toBeGreaterThan(Date.now());360- });361-362- it("STOP_PAIRING_SERVER clears the token", async () => {363- await dispatch({ type: "START_PAIRING_SERVER" });364- const r = await dispatch({ type: "STOP_PAIRING_SERVER" });365- expect(r.ok).toBe(true);366- });367-368- it("SCAN_PAIRING_QR returns android_only off mobile", async () => {369- const r = await dispatch({370- type: "SCAN_PAIRING_QR",371- payload: { token: "123456" },372- });373- expect(r.ok).toBe(false);374- expect(r.error).toBe("android_only");375- });376-377- it("SCAN_PAIRING_QR with garbage token still returns android_only off mobile", async () => {378- const r = await dispatch({379- type: "SCAN_PAIRING_QR",380- payload: { token: "not-a-token" as unknown as string },381- });382- expect(r.ok).toBe(false);383- });384-385- it("ACCEPT_PAIRING always returns ok (free tier)", async () => {386- const r = await dispatch({ type: "ACCEPT_PAIRING", payload: {} });387- expect(r.ok).toBe(true);388- });389-390- for (let i = 0; i < 10; i++) {391- it(`generated token #${i} is a fresh 6-digit code`, async () => {392- const r = await dispatch({ type: "START_PAIRING_SERVER" });393- const data = (r as { data: { token: string } }).data;394- expect(data.token).toMatch(/^\d{6}$/);395- });396- }397- });398-399- // ── RESPOND_INCOMING_PAIRING adversarial ────────────────────────────────400-401- describe("RESPOND_INCOMING_PAIRING adversarial", () => {402- it("no pending request: returns no_pending_request", async () => {403- const r = await dispatch({404- type: "RESPOND_INCOMING_PAIRING",405- payload: { accept: true },406- });407- expect(r.ok).toBe(false);408- expect(r.error).toBe("no_pending_request");409- });410-411- it("expired request: getIncomingPairingRequest returns null", async () => {412- await dispatch({413- type: "DEBUG_INJECT_INCOMING_PAIRING",414- payload: {415- deviceName: "fake",416- publicKeyHex: "00".repeat(32),417- },418- });419- // Force expiry — overwrite the stored expiresAt to the past.420- const sessionRaw = sessionStorage["peach_incoming_pair"] as421- | Record<string, unknown>422- | undefined;423- if (sessionRaw && typeof sessionRaw === "object") {424- (sessionRaw as Record<string, unknown>).expiresAt = Date.now() - 1000;425- }426- const r = await dispatch({427- type: "RESPOND_INCOMING_PAIRING",428- payload: { accept: true },429- });430- // Expired request causes either explicit "expired" or "no_pending_request"431- // (because getIncomingPairingRequest auto-clears on expiry).432- expect(r.ok).toBe(false);433- });434-435- it("missing payload returns no_pending or error", async () => {436- const r = await dispatch({437- type: "RESPOND_INCOMING_PAIRING",438- payload: {} as { accept: boolean },439- });440- expect(r).toBeDefined();441- });442- });443-444- // ── DEBUG_INJECT_INCOMING_PAIRING adversarial ───────────────────────────445-446- describe("DEBUG_INJECT_INCOMING_PAIRING adversarial", () => {447- it("missing fields returns missing_payload", async () => {448- const r = await dispatch({449- type: "DEBUG_INJECT_INCOMING_PAIRING",450- payload: {},451- });452- expect(r.ok).toBe(false);453- expect(r.error).toBe("missing_payload");454- });455-456- const partials = [457- { deviceName: "x" },458- { publicKeyHex: "y" },459- { deviceName: "", publicKeyHex: "" },460- ];461- for (const [i, p] of partials.entries()) {462- it(`partial payload #${i} returns missing_payload`, async () => {463- const r = await dispatch({464- type: "DEBUG_INJECT_INCOMING_PAIRING",465- payload: p,466- });467- expect(r.ok).toBe(false);468- expect(r.error).toBe("missing_payload");469- });470- }471-472- it("valid payload returns ok", async () => {473- const r = await dispatch({474- type: "DEBUG_INJECT_INCOMING_PAIRING",475- payload: {476- deviceName: "test-device",477- publicKeyHex: "ab".repeat(32),478- },479- });480- expect(r.ok).toBe(true);481- });482- });483-484- // ── GET_SYNC_STATUS shape ───────────────────────────────────────────────485-486- describe("GET_SYNC_STATUS shape", () => {487- it("never synced: status=never", async () => {488- delete storage.peach_last_sync_at;489- const r = await dispatch({ type: "GET_SYNC_STATUS" });490- const data = (r as { data: { status: string } }).data;491- expect(data.status).toBe("never");492- });493-494- it("with last_sync_at: status=up_to_date", async () => {495- storage.peach_last_sync_at = String(Date.now());496- const r = await dispatch({ type: "GET_SYNC_STATUS" });497- const data = (r as { data: { status: string } }).data;498- expect(data.status).toBe("up_to_date");499- });500-501- it("garbage last_sync_at: status=never", async () => {502- storage.peach_last_sync_at = "not-a-number";503- const r = await dispatch({ type: "GET_SYNC_STATUS" });504- const data = (r as { data: { status: string } }).data;505- expect(data.status).toBe("never");506- });507- });508-509- // ── REMOVE_PAIRED_DEVICE adversarial ────────────────────────────────────510-511- describe("REMOVE_PAIRED_DEVICE adversarial", () => {512- const badIds = ["", " ", "not-a-real-id", "\x00", "a".repeat(1000)];513- for (const deviceId of badIds) {514- it(`unknown deviceId ${JSON.stringify(deviceId.slice(0, 20))} succeeds (idempotent)`, async () => {515- const r = await dispatch({516- type: "REMOVE_PAIRED_DEVICE",517- payload: { deviceId },518- });519- // The handler is idempotent — non-existent ids do not error.520- expect(r.ok).toBe(true);521- });522- }523- });524-525- // ── Newer-timestamp sweep ────────────────────────────────────────────────526-527- describe("timestamp boundary sweep", () => {528- // AUD-006: MAX_SAFE_INTEGER is now beyond the 5-min skew budget, so529- // "newer is accepted" only holds for timestamps not far in the future.530- for (const baseTs of [0, 1, 1_000, 1_700_000_000_000]) {531- it(`base ts ${baseTs}: applies when remote is strictly newer`, async () => {532- const vault = await import("./vault");533- await vault.createVault("test-pass-12345");534- storage.peach_last_modified_at = String(baseTs);535- const applied = await vault.applyIncomingVault(536- makeIncomingBlob(),537- baseTs + 1538- );539- expect(applied).toBe(true);540- });541-542- it(`base ts ${baseTs}: rejects when remote is equal`, async () => {543- const vault = await import("./vault");544- await vault.createVault("test-pass-12345");545- storage.peach_last_modified_at = String(baseTs);546- const applied = await vault.applyIncomingVault(547- makeIncomingBlob(),548- baseTs549- );550- expect(applied).toBe(false);551- });552-553- it(`base ts ${baseTs}: rejects when remote is strictly older`, async () => {554- const vault = await import("./vault");555- await vault.createVault("test-pass-12345");556- storage.peach_last_modified_at = String(baseTs);557- const applied = await vault.applyIncomingVault(558- makeIncomingBlob(),559- baseTs - 1560- );561- expect(applied).toBe(false);562- });563- }564- });565-566- // ── Token format adversarial ────────────────────────────────────────────567-568- describe("token format adversarial", () => {569- const badFormats = [570- "",571- "12345",572- "1234567",573- "abcdef",574- "12 34 56",575- "<script>",576- "null",577- "undefined",578- "-12345",579- "1.23456",580- "🔢🔢🔢🔢🔢🔢",581- ];582- for (const token of badFormats) {583- it(`SCAN_PAIRING_QR with token ${JSON.stringify(token)} returns android_only`, async () => {584- const r = await dispatch({585- type: "SCAN_PAIRING_QR",586- payload: { token },587- });588- expect(r.ok).toBe(false);589- });590- }591- });592-593- // ── runBlobSyncSession via deps (no transport) ──────────────────────────594-595- describe("runBlobSyncSession safety", () => {596- it("locked vault returns skipped status", async () => {597- const { runBlobSyncSession } = await import("./sync-coordinator");598- // Don't unlock. Build a stub transport that never resolves.599- const result = await runBlobSyncSession(600- "initiator",601- {602- send: vi.fn(async () => {}),603- receive: vi.fn(async () => new Uint8Array()),604- close: vi.fn(async () => {}),605- } as unknown as Parameters<typeof runBlobSyncSession>[1],606- {607- getVaultBlobForSync: async () => null,608- applyIncomingVault: async () => false,609- isVaultUnlocked: () => false,610- }611- );612- expect(result.status).toBe("skipped");613- expect(result.applied).toBe(false);614- });615- });616-617- // H3/COR-005 — when applyIncomingVault succeeds but reloadEntriesAfterSync618- // fails (the remote was encrypted under a different password), the vault619- // must lock rather than let stale in-memory entries clobber the new blob.620- describe("H3/COR-005 sync re-decrypt failure lock", () => {621- async function buildRemoteBlobWithDifferentKey(): Promise<string> {622- const vault = await import("./vault");623- const slotA = await vault.loadSlotA();624- if (!slotA) throw new Error("no slot A");625- // Re-use the same slot A shape but with a fresh random salt + a626- // garbage ciphertext. Decryption will fail because the salt doesn't627- // match the current in-memory derivedKey.628- const rand = (n: number) =>629- Array.from(crypto.getRandomValues(new Uint8Array(n)))630- .map(b => b.toString(16).padStart(2, "0"))631- .join("");632- const remoteA = {633- ...slotA,634- salt: rand(16),635- nonce: rand(12),636- ciphertext: rand(64),637- tag: rand(16),638- };639- return JSON.stringify({ a: remoteA, b: storage.peach_vault_b });640- }641-642- it("vault locks when reloadEntriesAfterSync fails after apply", async () => {643- const vault = await setupBlobs();644- const messages = await import("./messages");645- const deps = messages.buildSyncDeps();646- const remote = await buildRemoteBlobWithDifferentKey();647- // Newer timestamp so applyIncomingVault writes.648- const remoteTs = 1_700_000_000_000 + 100_000;649- expect(vault.isUnlocked()).toBe(true);650- const applied = await deps.applyIncomingVault(remote, remoteTs);651- expect(applied).toBe(true);652- expect(vault.isUnlocked()).toBe(false);653- });654-655- it("stale in-memory entries cannot clobber remote blob after sync", async () => {656- await setupBlobs();657- const messages = await import("./messages");658- const deps = messages.buildSyncDeps();659- const remote = await buildRemoteBlobWithDifferentKey();660- const remoteTs = 1_700_000_000_000 + 100_000;661- await deps.applyIncomingVault(remote, remoteTs);662- // Vault is locked — any vault mutation must fail.663- const r = await dispatch({664- type: "ADD_ENTRY",665- payload: { title: "Ghost", password: "p" },666- });667- expect(r.ok).toBe(false);668- });669-670- it("when reloadEntriesAfterSync succeeds, vault stays unlocked", async () => {671- const vault = await setupBlobs();672- const messages = await import("./messages");673- const deps = messages.buildSyncDeps();674- // Build a remote blob that decrypts under the *same* key — re-use675- // current slot A but bump the timestamp via writeSlot equivalent.676- const slotA = storage.peach_vault_a;677- const sameKeyBlob = JSON.stringify({ a: slotA, b: storage.peach_vault_b });678- const applied = await deps.applyIncomingVault(679- sameKeyBlob,680- 1_700_000_000_000 + 50_000,681- );682- expect(applied).toBe(true);683- expect(vault.isUnlocked()).toBe(true);684- });685- });686-687- // H1/THREAT-002 — responder must authenticate the initiator and reject any688- // initiator whose authenticated X25519 public key is not in the paired list.689- // The Noise NK transport itself is run by the native layer in production;690- // we drive the auth round directly over the JS transport in these tests.691- describe("sync initiator authentication", () => {692- // Helper: simulate one full responder-side SYNC_SESSION_STARTED handshake.693- // Returns: status='completed' if the responder accepted us, 'rejected' if694- // it closed the transport (auth failure or unpaired key); vaultBlobSent695- // reflects whether the responder progressed to the blob exchange.696- async function simulateResponderHandshake(opts: {697- initiatorKeypair: { secretKey: Uint8Array; publicKey: Uint8Array };698- pairedX25519Hex: string[];699- }): Promise<{ status: "completed" | "rejected"; vaultBlobSent: boolean }> {700- const { generateKeyPair, sharedKey } = await import("@stablelib/x25519");701- const { HMAC } = await import("@stablelib/hmac");702- const { SHA256 } = await import("@stablelib/sha256");703-704- // Pre-seed responder X25519 identity so we know its public key for the705- // proof MAC. The responder reads it lazily from chrome.storage.local706- // via getSyncIdentity().707- const responderKp = generateKeyPair();708- const b64 = (bytes: Uint8Array) => {709- let s = "";710- for (const b of bytes) s += String.fromCharCode(b);711- return btoa(s);712- };713- storage.peach_sync_identity = JSON.stringify({714- priv: b64(responderKp.secretKey),715- pub: b64(responderKp.publicKey),716- });717-718- // Seed paired devices (sync-storage reads from chrome.storage.local).719- storage.peach_paired_devices = pairedX25519Hex(opts.pairedX25519Hex);720-721- // Capacitor + SyncPlugin stub so isNativePlatform() returns true and722- // the responder's outgoing frames are captured.723- const sentFrames: string[] = [];724- (globalThis as unknown as { Capacitor: unknown }).Capacitor = {725- isNativePlatform: () => true,726- Plugins: {727- SyncPlugin: {728- addListener: () => {},729- sendToConnection: async ({ data }: { connectionId: string; data: string }) => {730- sentFrames.push(data);731- },732- },733- },734- };735-736- // Re-import messages so the SyncPlugin listener registers under the new Capacitor.737- vi.resetModules();738- const messages = await import("./messages");739- messages.setupMessageHandler();740- const vault = await import("./vault");741- await vault.ensureSettingsLoaded();742- await vault.createVault("test-pass-sync");743-744- // Fire SYNC_SESSION_STARTED — responder begins by sending CHALLENGE.745- const sessionPromise = dispatch({746- type: "SYNC_SESSION_STARTED",747- payload: { connectionId: "test-conn" },748- });749-750- // Wait for the responder to send CHALLENGE.751- const waitForFrame = async (): Promise<string> => {752- for (let i = 0; i < 200; i++) {753- if (sentFrames.length > 0) return sentFrames.shift()!;754- await new Promise(r => setTimeout(r, 5));755- }756- throw new Error("timeout waiting for responder frame");757- };758- const challengeB64 = await waitForFrame();759- const challengeBytes = base64ToU8(challengeB64);760- const challenge = JSON.parse(new TextDecoder().decode(challengeBytes)) as {761- type: string;762- nonce_hex: string;763- responder_public_key_hex: string;764- };765- if (challenge.type !== "CHALLENGE") throw new Error("expected CHALLENGE");766-767- // Compute proof: HMAC-SHA256(X25519(our_priv, responder_pub),768- // "peach-sync-v1-initiator-auth" || our_pub || responder_pub || nonce)769- const responderPub = hexToBytes(challenge.responder_public_key_hex);770- const dh = sharedKey(opts.initiatorKeypair.secretKey, responderPub);771- const mac = new HMAC(SHA256, dh);772- mac.update(new TextEncoder().encode("peach-sync-v1-initiator-auth"));773- mac.update(opts.initiatorKeypair.publicKey);774- mac.update(responderPub);775- mac.update(hexToBytes(challenge.nonce_hex));776- const proof = mac.digest();777-778- const proofFrame = JSON.stringify({779- type: "PROOF",780- initiator_public_key_hex: bytesToHexStr(opts.initiatorKeypair.publicKey),781- proof_hex: bytesToHexStr(proof),782- });783- // Inject the PROOF frame as the next "incoming" sync frame.784- const { deliverAndroidSyncFrame } = messages;785- deliverAndroidSyncFrame("test-conn", u8ToBase64(new TextEncoder().encode(proofFrame)));786-787- // After auth, responder either rejects (closes transport, returns788- // ok:false) or proceeds to runBlobSyncSession (which will hang waiting789- // for SYNC_HELLO; we time it out and infer "completed handshake").790- const result = await Promise.race([791- sessionPromise.then(r => ({ kind: "resolved" as const, response: r })),792- new Promise<{ kind: "blob-stage" }>(resolve =>793- setTimeout(() => resolve({ kind: "blob-stage" }), 200)794- ),795- ]);796-797- if (result.kind === "resolved") {798- const resp = result.response;799- if (resp.ok === false) {800- return { status: "rejected", vaultBlobSent: false };801- }802- // ok=true means the auth passed and runBlobSyncSession ran (probably803- // erroring on missing SYNC_HELLO, but auth succeeded).804- return { status: "completed", vaultBlobSent: sentFrames.length > 0 };805- }806- // Still pending after auth — the responder is past the auth gate.807- return { status: "completed", vaultBlobSent: sentFrames.length > 0 };808- }809-810- function pairedX25519Hex(keys: string[]): Array<Record<string, unknown>> {811- return keys.map((k, i) => ({812- deviceId: k,813- displayName: `Dev${i}`,814- x25519PublicKey: k,815- pairedAt: 1_700_000_000_000,816- lastSeenAt: 1_700_000_000_000,817- }));818- }819-820- function hexToBytes(s: string): Uint8Array {821- const out = new Uint8Array(s.length / 2);822- for (let i = 0; i < s.length; i += 2) out[i / 2] = parseInt(s.slice(i, i + 2), 16);823- return out;824- }825- function bytesToHexStr(b: Uint8Array): string {826- return Array.from(b).map(x => x.toString(16).padStart(2, "0")).join("");827- }828- function base64ToU8(b64: string): Uint8Array {829- const bin = atob(b64);830- const out = new Uint8Array(bin.length);831- for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);832- return out;833- }834- function u8ToBase64(u: Uint8Array): string {835- let s = "";836- for (const b of u) s += String.fromCharCode(b);837- return btoa(s);838- }839-840- it("unpaired initiator is rejected — vault blob never sent", async () => {841- const { generateKeyPair } = await import("@stablelib/x25519");842- const initKp = generateKeyPair();843- const result = await simulateResponderHandshake({844- initiatorKeypair: initKp,845- pairedX25519Hex: [],846- });847- expect(result.status).toBe("rejected");848- expect(result.vaultBlobSent).toBe(false);849- });850-851- it("paired initiator passes the auth gate", async () => {852- const { generateKeyPair } = await import("@stablelib/x25519");853- const initKp = generateKeyPair();854- const initHex = Array.from(initKp.publicKey)855- .map(x => x.toString(16).padStart(2, "0"))856- .join("");857- const result = await simulateResponderHandshake({858- initiatorKeypair: initKp,859- pairedX25519Hex: [initHex],860- });861- expect(result.status).toBe("completed");862- });863-864- it("forged public key in PROOF frame is rejected (proof MAC fails)", async () => {865- const { generateKeyPair } = await import("@stablelib/x25519");866- const realKp = generateKeyPair();867- const realHex = Array.from(realKp.publicKey)868- .map(x => x.toString(16).padStart(2, "0"))869- .join("");870- // The proof MAC is computed from initKp's private key — but the device871- // list contains a different (forged) public key. Even though our MAC872- // verifies cryptographically (it's bound to initKp.publicKey, which we873- // send in the PROOF frame), that hex is NOT in the paired list.874- const forgedKp = generateKeyPair();875- const result = await simulateResponderHandshake({876- initiatorKeypair: forgedKp,877- pairedX25519Hex: [realHex], // only the real key is paired878- });879- expect(result.status).toBe("rejected");880- expect(result.vaultBlobSent).toBe(false);881- });882- });883-});src/background/vault.test.ts+0 −15
@@ -347,21 +347,6 @@347347 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);348348 });349349 350- it("applyIncomingVault preserves the remote last-modified timestamp", async () => {351- const vault = await import("./vault");352- await vault.createVault("remote-pass");353- const blob = JSON.stringify({354- a: storage.peach_vault_a,355- b: storage.peach_vault_b,356- });357- storage.peach_last_modified_at = "1000";358- const remoteTimestamp = 2000;359-360- expect(await vault.applyIncomingVault(blob, remoteTimestamp)).toBe(true);361-362- expect(storage.peach_last_modified_at).toBe(String(remoteTimestamp));363- });364-365350 it("changePassword with wrong current password returns false and leaves slot A unchanged", async () => {366351 const vault = await import("./vault");367352 src/background/vault.ts+0 −221
@@ -2225,224 +2225,3 @@22252225 return true;22262226 }22272227 2228-// ---------------------------------------------------------------------------2229-// Sync helpers2230-// ---------------------------------------------------------------------------2231-2232-export async function getVaultBlobForSync(): Promise<{2233- blob: string;2234- timestamp: number;2235- license?: string;2236-} | null> {2237- const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, 'peach_last_modified_at', 'peach_license']);2238- const slotA = (raw as Record<string, unknown>)[SLOT_A];2239- if (!slotA) return null;2240- const slotB = (raw as Record<string, unknown>)[SLOT_B];2241- const blob = JSON.stringify({ a: slotA, b: slotB });2242- const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2243- const timestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;2244- const license = (raw as Record<string, unknown>)['peach_license'];2245- return {2246- blob,2247- timestamp,2248- license: typeof license === 'string' ? license : undefined,2249- };2250-}2251-2252-export async function applyIncomingVault(2253- blobJson: string,2254- remoteTimestamp: number,2255- remoteLicense?: string,2256-): Promise<boolean> {2257- const raw = await chrome.storage.local.get('peach_last_modified_at');2258- const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2259- const localTimestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;2260-2261- if (remoteLicense) {2262- const licenseRaw = await chrome.storage.local.get('peach_license');2263- const existing = (licenseRaw as Record<string, unknown>)['peach_license'];2264- if (!existing) {2265- // L16/SEC-010: validate signature before storing. An unverified peer2266- // could otherwise plant an arbitrary string as the local license and2267- // poison the next isProUser() / activateLicense() check.2268- const { validateLicense } = await import('./license');2269- const validation = await validateLicense(remoteLicense);2270- if (validation.valid) {2271- await chrome.storage.local.set({ peach_license: remoteLicense });2272- }2273- // Silently ignore invalid — never store garbage.2274- }2275- }2276-2277- // AUD-006: reject implausibly future timestamps. A paired peer with a bad2278- // clock — or a malicious peer — can send Number.MAX_SAFE_INTEGER and pin2279- // last-write-wins so no local change ever wins. 5 minutes covers legitimate2280- // clock drift; anything further is implausible.2281- const SYNC_CLOCK_SKEW_BUDGET_MS = 5 * 60 * 1_000;2282- if (remoteTimestamp > Date.now() + SYNC_CLOCK_SKEW_BUDGET_MS) {2283- console.warn(2284- '[Peach] sync rejected: remote timestamp too far in future',2285- { remoteTimestamp, now: Date.now(), delta: remoteTimestamp - Date.now() },2286- );2287- return false;2288- }2289-2290- if (remoteTimestamp <= localTimestamp) return false;2291-2292- // Parse + shape-validate before any storage mutation. A malformed blob2293- // (non-JSON, non-object, missing slot fields) must NOT touch slot A —2294- // otherwise an unauthenticated remote could poison the local vault by2295- // sending '[]' or '42' (M10/COR-012). Mirror the structural check2296- // already used in unlockFromCodex.2297- let parsed: unknown;2298- try {2299- parsed = JSON.parse(blobJson);2300- } catch {2301- throw new Error('invalid_incoming_vault_blob');2302- }2303- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {2304- throw new Error('invalid_incoming_vault_blob');2305- }2306- const { a, b } = parsed as { a?: unknown; b?: unknown };2307- if (!hasValidVaultSchema(a)) {2308- throw new Error('invalid_incoming_vault_blob');2309- }2310-2311- // Snapshot before overwriting — non-fatal but user-visible.2312- // M4/QUA-006: a silent snapshot failure leaves the user without their2313- // safety net for an incoming sync write; surface it.2314- try {2315- await takeVaultSnapshot('sync_received');2316- } catch (snapshotErr) {2317- console.warn('[Peach] vault snapshot failed during sync:', snapshotErr);2318- if (String(snapshotErr).includes('QUOTA') || String(snapshotErr).includes('quota')) {2319- try {2320- await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2321- } catch {2322- // popup may be closed; surfaced via console.warn above2323- }2324- }2325- }2326-2327- await writeSlot(SLOT_A, a);2328- if (hasValidVaultSchema(b)) await writeSlot(SLOT_B, b);2329- await chrome.storage.local.set({2330- peach_last_sync_at: String(Date.now()),2331- peach_last_modified_at: String(remoteTimestamp),2332- });2333-2334- return true;2335-}2336-2337-/**2338- * Returns the X25519 sync identity, encrypting the private key on first call2339- * and on any unencrypted-shape migration. Requires the vault to be unlocked2340- * — the private key is wrapped under the same derived key as the vault, so2341- * a locked browser cannot leak it from storage alone (M12/SEC-003).2342- *2343- * Persistence shapes:2344- * v0 (pre-M12, migrated on first read):2345- * { priv: base64, pub: base64 }2346- * v1 (encrypted):2347- * { pub: base64, enc_priv: hex, enc_nonce: hex, enc_tag: hex }2348- */2349-export async function getSyncIdentity(): Promise<{2350- privateKeyBytes: Uint8Array;2351- publicKeyBytes: Uint8Array;2352- publicKeyHex: string;2353-}> {2354- if (!derivedKey) throw new Error('vault_locked');2355- const raw = await chrome.storage.local.get('peach_sync_identity');2356- const stored = (raw as Record<string, unknown>)['peach_sync_identity'];2357-2358- if (typeof stored === 'string') {2359- const parsed = JSON.parse(stored) as {2360- pub: string;2361- priv?: string;2362- enc_priv?: string;2363- enc_nonce?: string;2364- enc_tag?: string;2365- };2366- const publicKeyBytes = base64DecodeBytes(parsed.pub);2367-2368- if (parsed.enc_priv && parsed.enc_nonce && parsed.enc_tag) {2369- const privHex = await decrypt(2370- derivedKey,2371- parsed.enc_nonce,2372- parsed.enc_priv,2373- parsed.enc_tag,2374- "sync_identity_v1",2375- );2376- const privateKeyBytes = unhex(privHex);2377- return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };2378- }2379-2380- if (parsed.priv) {2381- // Migrate v0 (unencrypted private key) → v1.2382- const privateKeyBytes = base64DecodeBytes(parsed.priv);2383- await persistEncryptedSyncIdentity(privateKeyBytes, publicKeyBytes);2384- return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };2385- }2386- }2387-2388- // Generate a fresh X25519 keypair and persist it encrypted.2389- const { generateKeyPair } = await import('@stablelib/x25519');2390- const keypair = generateKeyPair();2391- await persistEncryptedSyncIdentity(keypair.secretKey, keypair.publicKey);2392- return {2393- privateKeyBytes: keypair.secretKey,2394- publicKeyBytes: keypair.publicKey,2395- publicKeyHex: bytesToHex(keypair.publicKey),2396- };2397-}2398-2399-async function persistEncryptedSyncIdentity(2400- privateKeyBytes: Uint8Array,2401- publicKeyBytes: Uint8Array,2402-): Promise<void> {2403- if (!derivedKey) throw new Error('vault_locked');2404- const enc = await encrypt(derivedKey, hex(privateKeyBytes), "sync_identity_v1");2405- await chrome.storage.local.set({2406- peach_sync_identity: JSON.stringify({2407- pub: base64EncodeBytes(publicKeyBytes),2408- enc_priv: enc.ciphertext,2409- enc_nonce: enc.nonce,2410- enc_tag: enc.tag,2411- }),2412- });2413-}2414-2415-// Re-decrypt vault entries from storage using the current in-memory key.2416-// Called after applyIncomingVault so the in-memory state stays consistent2417-// with what was just written to storage.2418-export async function reloadEntriesAfterSync(): Promise<boolean> {2419- if (!derivedKey) return false;2420- const slotA = await loadSlot(SLOT_A);2421- if (!slotA) return false;2422- try {2423- const payload = await tryDecryptSlot(slotA, derivedKey, "slot_a_v1");2424- if (!payload) return false;2425- entries = payload.entries;2426- buildDomainIndexSync(entries);2427- return true;2428- } catch {2429- return false;2430- }2431-}2432-2433-function bytesToHex(bytes: Uint8Array): string {2434- return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');2435-}2436-2437-function base64EncodeBytes(bytes: Uint8Array): string {2438- let binary = '';2439- for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);2440- return btoa(binary);2441-}2442-2443-function base64DecodeBytes(b64: string): Uint8Array {2444- const binary = atob(b64);2445- const bytes = new Uint8Array(binary.length);2446- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);2447- return bytes;2448-}src/popup/App.tsx+9 −142
@@ -4,7 +4,6 @@44 import { sendMessage } from "../shared/bridge";55 import { ACCENT_COLORS } from "../shared/types";66 import type { AccentColorName, PendingPasskeyRequest, ProFeature, VaultEntry } from "../shared/types";7-import type { IncomingPairingRequest } from "../shared/sync-types";87 import { isPasskeyOnly } from "../shared/types";98 import { LockView } from "./views/Lock";109 import { Onboarding } from "./views/Onboarding";@@ -13,7 +12,7 @@1312 import { VaultView, type SortOrder } from "./views/Vault";1413 import { EntryView } from "./views/Entry";1514 import { AddView } from "./views/Add";16-import { PeachMark, PeachWordmark } from "./components/PeachMark";15+import { PeachWordmark } from "./components/PeachMark";1716 import { PasskeyApprovalView } from "./views/PasskeyApproval";1817 import { extractDomain } from "../shared/domain";1918 import { popupCopy } from "./copy";@@ -37,8 +36,7 @@3736 const HealthView = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));3837 const TrashView = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));3938 const PanicView = lazy(() => import("./views/Panic").then(m => ({ default: m.PanicView })));40-const SyncPairingView = lazy(() => import("./views/SyncPairing").then(m => ({ default: m.SyncPairingView })));41-const AddDeviceView = lazy(() => import("./views/SyncPairing").then(m => ({ default: m.AddDeviceView })));39+const ChirpSyncView = lazy(() => import("./views/ChirpSync").then(m => ({ default: m.ChirpSyncView })));4240 const CodexGenerateView = lazy(() => import("./views/CodexGenerateView").then(m => ({ default: m.CodexGenerateView })));4341 const CodexRestoreView = lazy(() => import("./views/CodexRestoreView").then(m => ({ default: m.CodexRestoreView })));4442 @@ -49,8 +47,7 @@4947 | "list"5048 | "add"5149 | "settings"52- | "sync-pairing"53- | "sync-add-device"50+ | "chirp-sync"5451 | "import"5552 | "export"5653 | "health"@@ -160,16 +157,6 @@160157 const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);161158 const [pendingCelebration, setPendingCelebration] = createSignal(false);162159 const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);163- const [incomingPair, setIncomingPair] = createSignal<IncomingPairingRequest | null>(null);164- const [recentPairToast, setRecentPairToast] = createSignal<string | null>(null);165- const [incomingPairResponding, setIncomingPairResponding] = createSignal(false);166- const [incomingPairTick, setIncomingPairTick] = createSignal(0);167- const incomingPairSecondsLeft = () => {168- void incomingPairTick();169- const req = incomingPair();170- if (!req) return 0;171- return Math.max(0, Math.ceil((req.expiresAt - Date.now()) / 1000));172- };173160 // True while Panic Step 1 has bounced the user out to Export. When Export's174161 // onBack fires we return to Panic instead of Settings so the user keeps the175162 // destruction context they came from.@@ -382,8 +369,6 @@382369 }383370 384371 await refreshPendingPasskey();385- await refreshIncomingPairing();386- await consumeRecentPairing();387372 388373 // Track tab changes so smart sort stays current389374 const handleTabActivated = () => { void refreshCurrentTab(); };@@ -445,23 +430,7 @@445430 // backgrounds the app. We measure elapsed background time on resume.446431 if (isNativePlatform()) {447432 let backgroundedAt: number | null = null;448- let syncPollInterval: ReturnType<typeof setInterval> | null = null;449433 450- function startSyncPolling() {451- if (syncPollInterval) return;452- syncPollInterval = setInterval(async () => {453- const res = await sendMessage({ type: 'TRIGGER_SYNC' }).catch(() => null);454- const r = res as { ok?: boolean; data?: { syncedCount?: number } } | null;455- if (r?.ok && (r?.data?.syncedCount ?? 0) > 0) {456- await sendMessage({ type: 'GET_ENTRIES' }).catch(() => null);457- }458- }, 60_000);459- }460-461- function stopSyncPolling() {462- if (syncPollInterval) { clearInterval(syncPollInterval); syncPollInterval = null; }463- }464-465434 const CapApp = nativePlugin<{466435 addListener: (467436 ev: string,@@ -474,7 +443,6 @@474443 ({ isActive }: { isActive: boolean }) => {475444 if (!isActive) {476445 backgroundedAt = Date.now();477- stopSyncPolling();478446 return;479447 }480448 if (backgroundedAt !== null && autoLockMinutes() > 0) {@@ -482,14 +450,11 @@482450 if (elapsed >= autoLockMinutes() * 60_000) handleHeaderLock();483451 }484452 backgroundedAt = null;485- // Resume polling when app comes to foreground (Pro only).486- if (proStatus()) startSyncPolling();487453 }488454 );489455 // Capacitor 6 listeners return a Promise<{ remove: () => Promise<void> }>.490456 onCleanup(() => {491457 Promise.resolve(sub).then((s) => s?.remove?.()).catch(() => {});492- stopSyncPolling();493458 });494459 }495460 @@ -554,7 +519,7 @@554519 handleBack();555520 return;556521 }557- if (v === "sync-pairing") { setView("settings"); return; }522+ if (v === "chirp-sync") { setView("settings"); return; }558523 };559524 window.addEventListener("peach:back", handlePeachBack);560525 onCleanup(() => window.removeEventListener("peach:back", handlePeachBack));@@ -742,8 +707,7 @@742707 if (showNotes()) { setShowNotes(false); setOpenNoteId(undefined); return; }743708 if (v === "list") { setView("entry"); return; }744709 if (v === "settings" || v === "import" || v === "export" || v === "add" || v === "health") { handleBack(); return; }745- if (v === "sync-pairing") { setView("settings"); return; }746- if (v === "sync-add-device") { setView("sync-pairing"); return; }710+ if (v === "chirp-sync") { setView("settings"); return; }747711 }748712 749713 // Bindings-driven dispatch. Single-key letter bindings without@@ -810,48 +774,6 @@810774 setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);811775 }812776 813- async function refreshIncomingPairing() {814- const res = await sendMessage({ type: "GET_INCOMING_PAIRING_REQUEST" });815- const data = (res as { ok: boolean; data?: IncomingPairingRequest | null }).data ?? null;816- setIncomingPair(data);817- }818-819- // Drains a single one-shot record left by the background when 6-digit820- // pairing succeeded while the popup was closed (or the SyncPairing view was821- // navigated away from before it could show its own success card).822- async function consumeRecentPairing() {823- const res = await sendMessage({ type: "GET_RECENT_PAIRING" });824- const data = (res as { ok: boolean; data?: { deviceName?: string } | null }).data;825- if (data?.deviceName) {826- setRecentPairToast(data.deviceName);827- window.setTimeout(() => setRecentPairToast(null), 4000);828- }829- }830-831- async function respondIncomingPairing(accept: boolean) {832- if (incomingPairResponding()) return;833- setIncomingPairResponding(true);834- await sendMessage({ type: "RESPOND_INCOMING_PAIRING", payload: { accept } });835- setIncomingPair(null);836- setIncomingPairResponding(false);837- }838-839- // 60-second expiry ticker for the incoming pairing banner. Re-runs when a840- // request appears, ticks once per second, and clears the banner if the841- // request expires while the popup is open.842- createEffect(() => {843- const req = incomingPair();844- if (!req) return;845- const handle = window.setInterval(() => {846- setIncomingPairTick((tick) => tick + 1);847- if (Date.now() >= req.expiresAt) {848- setIncomingPair(null);849- window.clearInterval(handle);850- }851- }, 1000);852- onCleanup(() => window.clearInterval(handle));853- });854-855777 async function refreshProStatus() {856778 const res = await sendMessage({ type: "GET_LICENSE" });857779 const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;@@ -926,17 +848,6 @@926848 }927849 928850 async function openVault(preferredEntryId?: string, skipViewSet = false) {929- // Desktop: fire-and-forget sync on every vault open (Pro only).930- // Never blocks popup load; refreshes entries if new data arrives.931- if (!isNativePlatform() && proStatus()) {932- void sendMessage({ type: 'TRIGGER_SYNC' }).then((res) => {933- const r = res as { ok?: boolean; data?: { syncedCount?: number } } | null;934- if (r?.ok && (r?.data?.syncedCount ?? 0) > 0) {935- void loadEntries();936- }937- }).catch(() => {});938- }939-940851 // Purge expired trash entries on each vault open941852 const retDays = trashRetentionDays();942853 if (retDays > 0) {@@ -1275,41 +1186,6 @@12751186 </div>12761187 </header>12771188 1278- <Show when={recentPairToast()}>1279- <div class="peach-pair-toast" role="status" aria-live="polite">1280- Paired with <strong>{recentPairToast()}</strong>1281- </div>1282- </Show>1283-1284- <Show when={incomingPair() !== null}>1285- <div class="peach-pair-request-banner" role="dialog" aria-label="Pairing request">1286- <div class="peach-pair-request-header">1287- <span class="peach-pair-request-mark"><PeachMark class="peach-pair-request-icon" /></span>1288- <span class="peach-pair-request-title">Pairing request</span>1289- <span class="peach-pair-request-countdown">{incomingPairSecondsLeft()}s</span>1290- </div>1291- <p class="peach-pair-request-body">1292- <strong>{incomingPair()!.deviceName}</strong> wants to sync with this Peach.1293- </p>1294- <div class="peach-pair-request-actions">1295- <button1296- class="peach-btn peach-btn--ghost peach-btn--sm"1297- onClick={() => void respondIncomingPairing(false)}1298- disabled={incomingPairResponding()}1299- >1300- Reject1301- </button>1302- <button1303- class="peach-btn peach-btn--accent peach-btn--sm"1304- onClick={() => void respondIncomingPairing(true)}1305- disabled={incomingPairResponding()}1306- >1307- Accept1308- </button>1309- </div>1310- </div>1311- </Show>1312-13131189 <Show when={pendingProNotice() !== null}>13141190 <div class="peach-pro-notice-banner">13151191 <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>@@ -1482,7 +1358,7 @@14821358 onExport={() => setView("export")}14831359 onBreachCheckDone={loadEntries}14841360 onOpenHealth={openHealth}1485- onOpenSyncPairing={() => setView("sync-pairing")}1361+ onOpenSyncPairing={() => setView("chirp-sync")}14861362 onOpenFileImport={handleOpenFileImport}14871363 onOpenPanic={() => setView("panic")}14881364 onOpenCodexGenerate={() => setView("codex-generate")}@@ -1513,21 +1389,12 @@15131389 />15141390 </Suspense>15151391 </Show>1516- <Show when={view() === "sync-pairing"}>1392+ <Show when={view() === "chirp-sync"}>15171393 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1518- <SyncPairingView1394+ <ChirpSyncView15191395 isPro={proStatus()}1520- onAddDevice={() => setView("sync-add-device")}1521- onLearnPro={() => { setSettingsInitialSection("license"); setView("settings"); }}15221396 onBack={() => setView("settings")}1523- />1524- </Suspense>1525- </Show>1526- <Show when={view() === "sync-add-device"}>1527- <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1528- <AddDeviceView1529- onClose={() => setView("sync-pairing")}1530- onPaired={() => setView("sync-pairing")}1397+ onLearnPro={() => { setSettingsInitialSection("license"); setView("settings"); }}15311398 />15321399 </Suspense>15331400 </Show>src/popup/chirp-pair.ts+7 −3
@@ -48,6 +48,8 @@4848 fetchLocalIdentity(): Promise<LocalIdentity>;4949 /** Background round-trip — persists the paired device. */5050 completePairing(paired: PairedDevice): Promise<void>;51+ /** Optional — cache the peer's IP for next session's fast path. */52+ cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;5153 /** Returns "192.168.1" for a /24; null if not detectable. */5254 getLocalSubnet(): Promise<string | null>;5355 /** Inject the WebSocket transport. Override in tests. */@@ -133,10 +135,12 @@133135 }134136 await transport.close();135137 136- // Use the connection's IP as our cached `lastKnownIp`; the wsPort came137- // in the PAIR_IDENTITY frame.138- paired = { ...paired, lastKnownIp: peerEndpoint.ip };138+ // The connection's IP is cached in `chrome.storage.local` keyed by139+ // pubkey — see `paired-devices.ts#setLastKnownIp`. The persistent v3140+ // `PairedDevice` record itself has no `lastKnownIp` field because the141+ // IP is per-network and must not roam over `chrome.storage.sync`.139142 await deps.completePairing(paired);143+ await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", peerEndpoint.ip);140144 deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName });141145 return paired;142146 }src/popup/components/SyncComingSoonBanner.tsx+0 −16
@@ -1,16 +0,0 @@1-export function SyncComingSoonBanner() {2- return (3- <div class="peach-sync-coming-soon">4- <div class="peach-sync-coming-soon__icon">5- <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">6- <circle cx="8" cy="8" r="6.5" />7- <path d="M8 5v3.5l2 1.5" />8- </svg>9- </div>10- <div>11- <p class="peach-sync-coming-soon__title">Sync requires the mobile app</p>12- <p class="peach-sync-coming-soon__body">Download Peach for iOS or Android to pair and sync your vault between devices.</p>13- </div>14- </div>15- );16-}src/popup/components/SyncInfoSheet.tsx+0 −49
@@ -1,49 +0,0 @@1-import { Component } from 'solid-js';2-3-interface SyncInfoSheetProps {4- onGotIt: () => void;5- onLearnPro: () => void;6-}7-8-export const SyncInfoSheet: Component<SyncInfoSheetProps> = (props) => {9- return (10- <div class="peach-sync-info-backdrop" onClick={props.onGotIt}>11- <div12- class="peach-sync-info-sheet"13- onClick={e => e.stopPropagation()}14- >15- <div class="peach-sync-info-handle" />16-17- <h2 class="peach-sync-info-title">How Peach Sync works</h2>18-19- <div class="peach-sync-info-body">20- <p class="peach-sync-info-point">21- <span class="peach-sync-info-icon">⇄</span>22- You can transfer your vault to another device for free —23- scan a code and your passwords move over instantly.24- </p>25- <p class="peach-sync-info-point">26- <span class="peach-sync-info-icon">✦</span>27- Continuous sync across multiple devices — keeping everything28- up to date automatically — requires Peach Pro.29- </p>30- </div>31-32- <div class="peach-sync-info-actions">33- <button34- class="peach-btn peach-btn--accent peach-btn--full"35- onClick={props.onGotIt}36- >37- Got it38- </button>39- <button40- class="peach-sync-info-pro-link"41- onClick={props.onLearnPro}42- >43- Learn about Pro →44- </button>45- </div>46- </div>47- </div>48- );49-};src/popup/styles/popup.css+223 −0
@@ -8888,3 +8888,226 @@88888888 0% { transform: scale(0.6); opacity: 0.6; }88898889 100% { transform: scale(2.4); opacity: 0; }88908890 }8891+8892+/* ─────────────────────────────────────────────────────────────────8893+ Settings → Sync surface (v3 ChirpSync). Wraps the same bird8894+ animation used by the pairing flow with a paired-devices list and8895+ the "Pair a device" CTA.8896+ ───────────────────────────────────────────────────────────────── */8897+.peach-chirp-settings {8898+ display: flex;8899+ flex-direction: column;8900+ gap: 18px;8901+ padding: 18px 20px 28px;8902+ width: 100%;8903+ box-sizing: border-box;8904+ overflow-y: auto;8905+ height: 100%;8906+}8907+.peach-chirp-settings--pairing {8908+ padding: 0;8909+ align-items: stretch;8910+ justify-content: stretch;8911+}8912+.peach-chirp-settings-header {8913+ display: flex;8914+ align-items: center;8915+ gap: 10px;8916+}8917+.peach-chirp-settings-title {8918+ margin: 0;8919+ font-size: 18px;8920+ font-weight: 600;8921+ color: var(--peach-text);8922+ letter-spacing: 0.01em;8923+}8924+.peach-chirp-settings-section {8925+ margin: 6px 0 -4px;8926+ font-size: 11px;8927+ font-weight: 600;8928+ letter-spacing: 0.08em;8929+ text-transform: uppercase;8930+ color: var(--peach-text-muted);8931+}8932+.peach-chirp-settings-error {8933+ font-size: 12px;8934+ padding: 8px 10px;8935+ border-radius: 8px;8936+ background: color-mix(in srgb, var(--peach-danger, #c25b4a) 18%, transparent);8937+ color: var(--peach-text);8938+}8939+.peach-chirp-settings-footer {8940+ margin-top: auto;8941+ padding-top: 18px;8942+ font-size: 11.5px;8943+ line-height: 1.5;8944+ color: var(--peach-text-muted);8945+ text-align: center;8946+}8947+8948+/* Hero card shown when no devices are paired yet. */8949+.peach-chirp-hero {8950+ display: flex;8951+ flex-direction: column;8952+ align-items: center;8953+ gap: 10px;8954+ padding: 18px 12px 4px;8955+ text-align: center;8956+}8957+.peach-chirp-hero-svg {8958+ display: block;8959+ max-width: 100%;8960+ filter: drop-shadow(0 4px 12px color-mix(in srgb, var(--peach-accent) 25%, transparent));8961+}8962+.peach-chirp-hero-headline {8963+ margin: 0;8964+ font-size: 16px;8965+ font-weight: 600;8966+ color: var(--peach-text);8967+}8968+.peach-chirp-hero-sub {8969+ margin: 0;8970+ font-size: 13px;8971+ line-height: 1.5;8972+ color: var(--peach-text-dim);8973+ max-width: 320px;8974+}8975+8976+.peach-chirp-pair-cta {8977+ display: flex;8978+ justify-content: center;8979+ padding: 4px 0 6px;8980+}8981+.peach-chirp-pair-button {8982+ display: inline-flex;8983+ align-items: center;8984+ gap: 8px;8985+ min-width: 220px;8986+ justify-content: center;8987+}8988+.peach-chirp-pair-button-icon {8989+ flex: 0 0 auto;8990+}8991+8992+/* Paired devices list. Roomy rows that look at home next to the rest8993+ of Settings; stale rows get a soft warm-warning border tint. */8994+.peach-chirp-device-list {8995+ list-style: none;8996+ margin: 0;8997+ padding: 0;8998+ display: flex;8999+ flex-direction: column;9000+ gap: 8px;9001+}9002+.peach-chirp-device-row {9003+ display: flex;9004+ align-items: center;9005+ gap: 12px;9006+ padding: 12px;9007+ border-radius: 10px;9008+ background: color-mix(in srgb, var(--peach-bg-elevated, var(--peach-bg)) 88%, var(--peach-accent) 12%);9009+ border: 1px solid color-mix(in srgb, var(--peach-accent) 12%, transparent);9010+}9011+.peach-chirp-device-row[data-stale="true"] {9012+ border-color: color-mix(in srgb, var(--peach-warn, #d29257) 36%, transparent);9013+}9014+.peach-chirp-device-icon {9015+ width: 32px;9016+ height: 32px;9017+ display: flex;9018+ align-items: center;9019+ justify-content: center;9020+ border-radius: 8px;9021+ color: var(--peach-text);9022+ background: color-mix(in srgb, var(--peach-accent) 18%, transparent);9023+}9024+.peach-chirp-device-info {9025+ flex: 1;9026+ display: flex;9027+ flex-direction: column;9028+ gap: 2px;9029+ min-width: 0;9030+}9031+.peach-chirp-device-name {9032+ font-size: 14px;9033+ font-weight: 600;9034+ color: var(--peach-text);9035+ overflow: hidden;9036+ text-overflow: ellipsis;9037+ white-space: nowrap;9038+}9039+.peach-chirp-device-meta {9040+ font-size: 11.5px;9041+ color: var(--peach-text-muted);9042+ display: flex;9043+ flex-wrap: wrap;9044+ align-items: center;9045+ gap: 8px;9046+}9047+.peach-chirp-device-badge {9048+ font-size: 10.5px;9049+ font-weight: 500;9050+ letter-spacing: 0.02em;9051+ padding: 2px 6px;9052+ border-radius: 999px;9053+ background: color-mix(in srgb, var(--peach-accent) 22%, transparent);9054+ color: var(--peach-text);9055+}9056+.peach-chirp-device-stale {9057+ font-size: 10.5px;9058+ font-weight: 500;9059+ color: var(--peach-warn, #d29257);9060+}9061+.peach-chirp-device-remove {9062+ flex: 0 0 auto;9063+ color: var(--peach-text-muted);9064+}9065+.peach-chirp-device-remove:hover {9066+ color: var(--peach-danger, #c25b4a);9067+}9068+.peach-chirp-unpair-confirm {9069+ display: flex;9070+ gap: 6px;9071+ align-items: center;9072+}9073+9074+/* Pro-gate card — soft upsell with a stylised wave illustration. */9075+.peach-chirp-pro-card {9076+ margin: 8px 0;9077+ padding: 22px 18px;9078+ border-radius: 14px;9079+ background: color-mix(in srgb, var(--peach-bg-elevated, var(--peach-bg)) 84%, var(--peach-accent) 16%);9080+ border: 1px solid color-mix(in srgb, var(--peach-accent) 18%, transparent);9081+ display: flex;9082+ flex-direction: column;9083+ align-items: center;9084+ gap: 12px;9085+ text-align: center;9086+}9087+.peach-chirp-pro-illustration {9088+ display: flex;9089+ align-items: center;9090+ justify-content: center;9091+ gap: 10px;9092+ color: var(--peach-accent);9093+}9094+.peach-chirp-pro-mark {9095+ width: 44px;9096+ height: 44px;9097+}9098+.peach-chirp-pro-waves {9099+ opacity: 0.85;9100+}9101+.peach-chirp-pro-headline {9102+ margin: 2px 0 0;9103+ font-size: 16px;9104+ font-weight: 600;9105+ color: var(--peach-text);9106+}9107+.peach-chirp-pro-body {9108+ margin: 0;9109+ font-size: 13px;9110+ line-height: 1.55;9111+ color: var(--peach-text-dim);9112+ max-width: 320px;9113+}src/popup/views/ChirpSync.tsx+390 −0
@@ -0,0 +1,390 @@1+// ChirpSync — the v3 Settings → Sync surface.2+//3+// Three modes the user can be in:4+//5+// 1. Locked behind Pro (free user) — soft upsell card, "Learn more" routes6+// to the License section.7+// 2. Idle — list of paired devices + "Pair a device" hero button. Stale8+// devices (>7 days since last sync) show a soft warning indicator;9+// vouched devices (received via device manifest, v2 brief §4.2) show10+// an "Added automatically" badge.11+// 3. Pairing — ChirpPairingFlow takes over the full surface and drives12+// the three-act bird animation in lockstep with the audio protocol.13+//14+// The pairing orchestrator lives in `popup/chirp-pair.ts`; this view is15+// the binding between that orchestrator, the background-message16+// CHIRP_* handlers, and the Settings navigation surrounding it.17+18+import {19+ Component,20+ Show,21+ createMemo,22+ createSignal,23+ For,24+ onMount,25+} from "solid-js";26+import { sendMessage } from "../../shared/bridge";27+import type {28+ DeviceType,29+ PairedDevice,30+ PairingProgress,31+} from "../../shared/sync-types";32+import { ChirpPairingFlow } from "../components/ChirpPairingFlow";33+import {34+ ChirpPairingFailure,35+ runChirpPairing,36+ type ChirpPairingDeps,37+} from "../chirp-pair";38+import { getLocalSubnet } from "../../shared/local-network";39+import { PeachMark } from "../components/PeachMark";40+41+export interface ChirpSyncViewProps {42+ isPro: boolean;43+ onBack(): void;44+ onLearnPro(): void;45+}46+47+const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;48+49+type Mode = "idle" | "pairing";50+51+export const ChirpSyncView: Component<ChirpSyncViewProps> = (props) => {52+ const [devices, setDevices] = createSignal<PairedDevice[]>([]);53+ const [mode, setMode] = createSignal<Mode>("idle");54+ const [pairing, setPairing] = createSignal<PairingProgress>({ phase: "idle" });55+ const [error, setError] = createSignal<string | null>(null);56+ const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);57+58+ onMount(() => {59+ void refresh();60+ });61+62+ async function refresh(): Promise<void> {63+ if (!props.isPro) return;64+ const res = await sendMessage({ type: "CHIRP_LIST_PAIRED_DEVICES" });65+ const data = (res as { ok: boolean; data?: { devices: PairedDevice[] } }).data;66+ setDevices(data?.devices ?? []);67+ }68+69+ async function handleStartPairing(): Promise<void> {70+ setError(null);71+ setPairing({ phase: "idle" });72+ setMode("pairing");73+74+ const deps: ChirpPairingDeps = {75+ onProgress: (p) => setPairing(p),76+ fetchLocalIdentity: async () => {77+ const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });78+ const r = res as { ok: boolean; data?: Parameters<ChirpPairingDeps["onProgress"]>[0] & object; error?: string };79+ if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");80+ // The handler returns a LocalIdentity; we trust the background's shape.81+ return r.data as unknown as Awaited<ReturnType<ChirpPairingDeps["fetchLocalIdentity"]>>;82+ },83+ completePairing: async (paired) => {84+ const res = await sendMessage({85+ type: "CHIRP_COMPLETE_PAIRING",86+ payload: { paired },87+ });88+ const r = res as { ok: boolean; error?: string };89+ if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");90+ },91+ getLocalSubnet,92+ openPairingTransport: async (endpoint) => {93+ // Native WebSocket transport for the pairing-mode peer scan. The94+ // chirp-pair orchestrator only uses send/receive/close, so a thin95+ // adapter over the platform WebSocket suffices.96+ return new Promise((resolve, reject) => {97+ let ws: WebSocket;98+ try {99+ ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);100+ } catch (err) {101+ reject(err);102+ return;103+ }104+ const inbox: Uint8Array[] = [];105+ const waiters: Array<(b: Uint8Array) => void> = [];106+ let closed = false;107+ ws.binaryType = "arraybuffer";108+ const timer = setTimeout(() => {109+ try { ws.close(); } catch { /* ignore */ }110+ reject(new Error("pairing_transport_timeout"));111+ }, 2_000);112+ ws.onopen = () => {113+ clearTimeout(timer);114+ resolve({115+ async send(data: Uint8Array) {116+ ws.send(data as Uint8Array<ArrayBuffer>);117+ },118+ receive(): Promise<Uint8Array> {119+ if (closed) return Promise.reject(new Error("closed"));120+ const v = inbox.shift();121+ if (v) return Promise.resolve(v);122+ return new Promise<Uint8Array>((res) => waiters.push(res));123+ },124+ async close() {125+ try { ws.close(); } catch { /* ignore */ }126+ },127+ });128+ };129+ ws.onmessage = (ev) => {130+ if (!(ev.data instanceof ArrayBuffer)) return;131+ const bytes = new Uint8Array(ev.data);132+ const w = waiters.shift();133+ if (w) w(bytes); else inbox.push(bytes);134+ };135+ ws.onerror = () => {136+ clearTimeout(timer);137+ reject(new Error("pairing_transport_error"));138+ };139+ ws.onclose = () => {140+ closed = true;141+ while (waiters.length > 0) waiters.shift()!(new Uint8Array(0));142+ };143+ });144+ },145+ };146+147+ try {148+ await runChirpPairing(deps);149+ await refresh();150+ } catch (err) {151+ if (err instanceof ChirpPairingFailure) {152+ // The orchestrator already pushed the terminal phase into153+ // `pairing()`; the inline error copy will render. No further154+ // action needed — the user can tap "Try again" or "Cancel".155+ return;156+ }157+ setError((err as Error).message);158+ setMode("idle");159+ }160+ }161+162+ async function handleUnpair(deviceId: string): Promise<void> {163+ await sendMessage({ type: "CHIRP_UNPAIR_DEVICE", payload: { deviceId } });164+ setConfirmUnpair(null);165+ await refresh();166+ }167+168+ function relativeTime(when: number | undefined): string {169+ if (!when) return "Not synced yet";170+ const elapsed = Date.now() - when;171+ if (elapsed < 60_000) return "Just now";172+ const minutes = Math.floor(elapsed / 60_000);173+ if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;174+ const hours = Math.floor(minutes / 60);175+ if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;176+ const days = Math.floor(hours / 24);177+ if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`;178+ return "Not seen recently";179+ }180+181+ function isStale(d: PairedDevice): boolean {182+ if (d.lastSyncedAt === undefined) return false;183+ return Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;184+ }185+186+ // --- Sub-views --------------------------------------------------187+188+ const proGateView = () => (189+ <div class="peach-chirp-settings peach-chirp-settings--gated">190+ <header class="peach-chirp-settings-header">191+ <button class="peach-icon-btn" aria-label="Back" onClick={() => props.onBack()}>192+ <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">193+ <path d="M11 4 L5 9 L11 14" />194+ </svg>195+ </button>196+ <h1 class="peach-chirp-settings-title">Sync</h1>197+ </header>198+ <div class="peach-chirp-pro-card">199+ <div class="peach-chirp-pro-illustration">200+ <PeachMark class="peach-chirp-pro-mark" />201+ <svg class="peach-chirp-pro-waves" width="200" height="80" viewBox="0 0 200 80" fill="none" stroke="currentColor" stroke-width="1.4">202+ <path d="M 30 40 Q 100 10 170 40" />203+ <path d="M 30 50 Q 100 20 170 50" opacity="0.6" />204+ <path d="M 30 60 Q 100 30 170 60" opacity="0.3" />205+ </svg>206+ </div>207+ <h2 class="peach-chirp-pro-headline">Chirp Sync is a Peach Pro feature</h2>208+ <p class="peach-chirp-pro-body">209+ Pair your phone with your laptop using a quick inaudible chirp —210+ then your vault stays in sync across devices on your local network.211+ No QR codes, no relay servers, no cloud accounts.212+ </p>213+ <button class="peach-btn peach-btn-primary" onClick={() => props.onLearnPro()}>214+ Learn more about Peach Pro215+ </button>216+ </div>217+ </div>218+ );219+220+ const idleView = () => (221+ <div class="peach-chirp-settings">222+ <header class="peach-chirp-settings-header">223+ <button class="peach-icon-btn" aria-label="Back" onClick={() => props.onBack()}>224+ <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">225+ <path d="M11 4 L5 9 L11 14" />226+ </svg>227+ </button>228+ <h1 class="peach-chirp-settings-title">Sync</h1>229+ </header>230+231+ <Show when={error()}>232+ <div class="peach-chirp-settings-error" role="alert">{error()}</div>233+ </Show>234+235+ <Show when={devices().length === 0}>236+ <div class="peach-chirp-hero">237+ <ChirpHeroBirds />238+ <h2 class="peach-chirp-hero-headline">Pair your phone with this Peach</h2>239+ <p class="peach-chirp-hero-sub">240+ Hold your phone near your laptop. Peach will chirp once,241+ inaudibly, and pair automatically. No codes, no servers.242+ </p>243+ </div>244+ </Show>245+246+ <div class="peach-chirp-pair-cta">247+ <button class="peach-btn peach-btn-primary peach-chirp-pair-button" onClick={() => void handleStartPairing()}>248+ <svg class="peach-chirp-pair-button-icon" width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">249+ <path d="M3 9 L9 3 L15 9" />250+ <path d="M3 13 L9 7 L15 13" opacity="0.5" />251+ </svg>252+ Pair a device253+ </button>254+ </div>255+256+ <Show when={devices().length > 0}>257+ <h2 class="peach-chirp-settings-section">Paired devices</h2>258+ <ul class="peach-chirp-device-list">259+ <For each={devices()}>260+ {(d) => (261+ <li class="peach-chirp-device-row" data-stale={isStale(d) ? "true" : "false"}>262+ <DeviceTypeIcon type={d.deviceType ?? "extension"} />263+ <div class="peach-chirp-device-info">264+ <span class="peach-chirp-device-name">{d.deviceName ?? "Unnamed device"}</span>265+ <span class="peach-chirp-device-meta">266+ {relativeTime(d.lastSyncedAt)}267+ <Show when={d.trustSource === "vouched"}>268+ <span class="peach-chirp-device-badge">Added automatically</span>269+ </Show>270+ <Show when={isStale(d)}>271+ <span class="peach-chirp-device-stale">Not seen recently</span>272+ </Show>273+ </span>274+ </div>275+ <Show276+ when={confirmUnpair() !== d.deviceId}277+ fallback={278+ <div class="peach-chirp-unpair-confirm">279+ <button class="peach-btn peach-btn-ghost peach-btn-sm" onClick={() => setConfirmUnpair(null)}>280+ Cancel281+ </button>282+ <button class="peach-btn peach-btn-destructive peach-btn-sm" onClick={() => void handleUnpair(d.deviceId)}>283+ Remove284+ </button>285+ </div>286+ }287+ >288+ <button289+ class="peach-icon-btn peach-chirp-device-remove"290+ aria-label={`Unpair ${d.deviceName}`}291+ onClick={() => setConfirmUnpair(d.deviceId)}292+ >293+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">294+ <path d="M3 3 L11 11 M11 3 L3 11" />295+ </svg>296+ </button>297+ </Show>298+ </li>299+ )}300+ </For>301+ </ul>302+ </Show>303+304+ <footer class="peach-chirp-settings-footer">305+ <p>306+ Chirp Sync stays entirely on your local network. No relay servers,307+ no cloud accounts — just your devices in the same room.308+ </p>309+ </footer>310+ </div>311+ );312+313+ const pairingView = () => (314+ <div class="peach-chirp-settings peach-chirp-settings--pairing">315+ <ChirpPairingFlow316+ progress={pairing()}317+ onCancel={() => setMode("idle")}318+ onRetry={() => void handleStartPairing()}319+ onDismiss={() => {320+ setMode("idle");321+ void refresh();322+ }}323+ />324+ </div>325+ );326+327+ const currentMode = createMemo(() => {328+ if (!props.isPro) return "gated";329+ if (mode() === "pairing") return "pairing";330+ return "idle";331+ });332+333+ return (334+ <Show335+ when={currentMode() === "gated"}336+ fallback={337+ <Show when={currentMode() === "pairing"} fallback={idleView()}>338+ {pairingView()}339+ </Show>340+ }341+ >342+ {proGateView()}343+ </Show>344+ );345+};346+347+const DeviceTypeIcon: Component<{ type: DeviceType }> = (props) => {348+ return (349+ <div class="peach-chirp-device-icon" data-type={props.type}>350+ <Show when={props.type === "android" || props.type === "ios"}>351+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">352+ <rect x="6" y="2" width="8" height="16" rx="1.5" />353+ <circle cx="10" cy="15.4" r="0.6" fill="currentColor" />354+ </svg>355+ </Show>356+ <Show when={props.type === "extension" || props.type === "desktop"}>357+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">358+ <rect x="2" y="4" width="16" height="10" rx="1.4" />359+ <path d="M7 17 L13 17" />360+ <path d="M9 14 L9 17 M11 14 L11 17" />361+ </svg>362+ </Show>363+ </div>364+ );365+};366+367+// Schematic two-bird illustration shown when no devices are paired yet.368+// The desktop bird stands ready, the mobile bird hops in from the right —369+// frozen mid-greeting. The live pairing animation drives the same SVG370+// shapes from ChirpPairingFlow once the user taps "Pair a device".371+const ChirpHeroBirds: Component = () => {372+ return (373+ <svg class="peach-chirp-hero-svg" width="240" height="120" viewBox="0 0 240 120" fill="none">374+ <ellipse cx="80" cy="78" rx="32" ry="24" class="peach-chirp-bird-body" />375+ <circle cx="98" cy="56" r="16" class="peach-chirp-bird-head" />376+ <circle cx="103" cy="54" r="2.4" class="peach-chirp-bird-eye" />377+ <path d="M 113 56 L 122 56 L 116 62 Z" class="peach-chirp-bird-beak" />378+ <path d="M 60 64 Q 50 76 60 86" class="peach-chirp-bird-wing" fill="none" stroke-width="2.4" stroke-linecap="round" />379+380+ <path d="M 130 60 Q 160 36 180 60" stroke="var(--peach-accent)" stroke-width="1.6" fill="none" stroke-linecap="round" opacity="0.45" />381+ <path d="M 130 70 Q 160 46 180 70" stroke="var(--peach-accent)" stroke-width="1.6" fill="none" stroke-linecap="round" opacity="0.25" />382+383+ <ellipse cx="184" cy="80" rx="22" ry="17" class="peach-chirp-bird-body" />384+ <circle cx="170" cy="64" r="11" class="peach-chirp-bird-head" />385+ <circle cx="166" cy="62" r="1.8" class="peach-chirp-bird-eye" />386+ <path d="M 158 64 L 151 64 L 156 69 Z" class="peach-chirp-bird-beak" />387+ <path d="M 200 72 Q 210 82 200 92" class="peach-chirp-bird-wing" fill="none" stroke-width="2.2" stroke-linecap="round" />388+ </svg>389+ );390+};src/popup/views/Lock.tsx+3 −157
@@ -532,11 +532,11 @@532532 533533 // ── Restore tab view ───────────────────────────────────────────────────────534534 535-type RestoreTab = "symbols" | "csv" | "phone";535+type RestoreTab = "symbols" | "csv";536536 537537 function tabLabel(tab: RestoreTab): string {538538 return (539- { symbols: "PeachScript", csv: "CSV", phone: "From Phone" }[tab]539+ { symbols: "PeachScript", csv: "CSV" }[tab]540540 );541541 }542542 @@ -580,7 +580,7 @@580580 581581 <div class="peach-newvault-card peach-restore-tabcard">582582 <div class="peach-restore-tabs" role="tablist">583- <For each={["symbols", "csv", "phone"] as RestoreTab[]}>583+ <For each={["symbols", "csv"] as RestoreTab[]}>584584 {(tab) => (585585 <button586586 class="peach-restore-tab"@@ -601,9 +601,6 @@601601 <Show when={activeTab() === "csv"}>602602 <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />603603 </Show>604- <Show when={activeTab() === "phone"}>605- <RestoreFromPhone onSuccess={handleRestoreSuccess} />606- </Show>607604 </div>608605 </div>609606 @@ -963,157 +960,6 @@963960 <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>964961 <Show when={error()}>965962 <p class="peach-rt-error">{error()}</p>966- </Show>967- </div>968- );969-}970-971-// ── From Phone tab ─────────────────────────────────────────────────────────972-973-type PhoneState = "waiting" | "connected" | "trust" | "error";974-975-function RestoreFromPhone(props: { onSuccess: () => void | Promise<void> }) {976- const [state, setState] = createSignal<PhoneState>("waiting");977- const [code, setCode] = createSignal<string | null>(null);978- const [qrUrl, setQrUrl] = createSignal<string | null>(null);979- const [expired, setExpired] = createSignal(false);980- const [deviceName, setDeviceName] = createSignal<string | null>(null);981- const [accepting, setAccepting] = createSignal(false);982- const [serverError, setServerError] = createSignal<string | null>(null);983- let expireTimer: ReturnType<typeof setTimeout> | null = null;984-985- function clearExpire() {986- if (expireTimer !== null) { clearTimeout(expireTimer); expireTimer = null; }987- }988-989- async function startPairing() {990- setExpired(false);991- setCode(null);992- setQrUrl(null);993- setServerError(null);994- clearExpire();995- const res = await sendMessage({ type: "START_PAIRING_SERVER" });996- const r = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };997- if (!r.ok || !r.data) {998- setServerError(r.error === "pro_required" ? "Pro required." : "Couldn't start pairing server.");999- return;1000- }1001- const token = r.data.token;1002- const formatted = token.length === 6 ? `${token.slice(0, 3)} ${token.slice(3)}` : token;1003- setCode(formatted);1004- const qrData = `peach://handoff?token=${token}`;1005- try {1006- const QRCode = (await import("qrcode")).default;1007- const url = await QRCode.toDataURL(qrData, { errorCorrectionLevel: "M", margin: 2, scale: 4, color: { dark: "#000000", light: "#ffffff" } });1008- setQrUrl(url);1009- } catch {1010- // QR optional — code is the primary affordance1011- }1012- const remaining = Math.max(0, r.data.expiresAt - Date.now());1013- expireTimer = setTimeout(() => {1014- setCode(null);1015- setQrUrl(null);1016- setExpired(true);1017- }, remaining);1018- }1019-1020- function handleRuntimeMsg(msg: unknown) {1021- const m = msg as { type?: string; payload?: { deviceName?: string } };1022- if (m?.type === "PAIRING_COMPLETED") {1023- clearExpire();1024- setDeviceName(m.payload?.deviceName ?? "your phone");1025- setState("trust");1026- }1027- }1028-1029- onMount(() => {1030- void startPairing();1031- // chrome global is absent in the Capacitor WebView — guard at runtime.1032- if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1033- chrome.runtime.onMessage.addListener(handleRuntimeMsg);1034- }1035- });1036-1037- onCleanup(() => {1038- clearExpire();1039- if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1040- chrome.runtime.onMessage.removeListener(handleRuntimeMsg);1041- }1042- sendMessage({ type: "STOP_PAIRING_SERVER" }).catch(() => {});1043- });1044-1045- async function handleAccept(trust: boolean) {1046- setAccepting(true);1047- if (trust) {1048- await sendMessage({ type: "ACCEPT_PAIRING" }).catch(() => {});1049- }1050- await props.onSuccess();1051- }1052-1053- return (1054- <div class="peach-rt-phone">1055- <Show when={state() === "waiting"}>1056- <Show when={!expired()}>1057- <Show when={code() !== null} fallback={<p class="peach-rt-phone-hint">starting…</p>}>1058- <p class="peach-rt-phone-code">{code()}</p>1059- <Show when={qrUrl() !== null}>1060- <img class="peach-rt-phone-qr" src={qrUrl()!} alt="pairing QR code" />1061- </Show>1062- <p class="peach-rt-phone-hint">1063- Open Peach on your phone and tap<br />1064- Settings → Send to desktop.<br />1065- Same WiFi required.1066- </p>1067- </Show>1068- <Show when={serverError()}>1069- <p class="peach-rt-error">{serverError()}</p>1070- </Show>1071- </Show>1072- <Show when={expired()}>1073- <p class="peach-rt-phone-hint">Code expired.</p>1074- <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void startPairing()}>1075- Generate new code1076- </button>1077- </Show>1078- </Show>1079-1080- <Show when={state() === "connected"}>1081- <p class="peach-rt-phone-hint">1082- Connected to {deviceName() ?? "your phone"}.<br />1083- Receiving vault…1084- </p>1085- </Show>1086-1087- <Show when={state() === "trust"}>1088- <p class="peach-rt-phone-hint" style="margin-bottom:10px">1089- Vault received from {deviceName() ?? "your phone"}.<br />1090- Trust this network for sync?1091- </p>1092- <button1093- class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1094- onClick={() => void handleAccept(true)}1095- disabled={accepting()}1096- >1097- Yes, trust this network1098- </button>1099- <button1100- class="peach-btn peach-btn--ghost peach-btn--sm"1101- style="margin-top:6px"1102- onClick={() => void handleAccept(false)}1103- disabled={accepting()}1104- >1105- Not now1106- </button>1107- </Show>1108-1109- <Show when={state() === "error"}>1110- <p class="peach-rt-phone-hint">1111- Could not connect. Check that both devices<br />1112- are on the same WiFi network.1113- </p>1114- <button class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit" onClick={() => { setState("waiting"); void startPairing(); }}>1115- Try again1116- </button>1117963 </Show>1118964 </div>1119965 );src/popup/views/SyncPairing.tsx+0 −608
not shown here: too long for one page
src/shared/local-network.ts+9 −89
@@ -1,7 +1,13 @@1-const SYNC_PORT = 44100;2-const PROBE_TIMEOUT_MS = 400;1+// Local network discovery — minimal helper used by chirp pairing.2+//3+// `getLocalSubnet` returns the /24 prefix (e.g. "192.168.1") that the4+// browser is on, derived from a host-relative WebRTC ICE candidate. The5+// extension uses this to scan the LAN for the peer's pairing-port6+// WebSocket after the audio chirp exchange completes. Legacy v2.147+// discovery/pair probes have been removed — see chirp-pair.ts for the8+// v3 pairing-port scan and chirp-sync-transport.ts for the rate-limited9+// Phase-2 subnet probe.310 4-// Detect the local subnet via WebRTC ICE candidate parsing.511 // Returns e.g. "192.168.1" or null if unavailable.612 export async function getLocalSubnet(): Promise<string | null> {713 return new Promise((resolve) => {@@ -19,90 +25,4 @@1925 resolve(null);2026 }2127 });22-}23-24-// Probe a single IP to see if it responds to PEACH_DISCOVER_PROBE with our25-// publicKeyHex. Returns true if it's our paired device.26-async function probeDiscover(ip: string, publicKeyHex: string): Promise<boolean> {27- return new Promise((resolve) => {28- try {29- const ws = new WebSocket(`ws://${ip}:${SYNC_PORT}`);30- const timer = setTimeout(() => { try { ws.close(); } catch { /* ignore */ } resolve(false); }, PROBE_TIMEOUT_MS);31- ws.onopen = () => ws.send(JSON.stringify({ type: 'PEACH_DISCOVER_PROBE', publicKeyHex }));32- ws.onmessage = (e) => {33- clearTimeout(timer);34- try {35- const msg = JSON.parse(e.data as string);36- resolve(msg?.type === 'PEACH_DISCOVER_ACK' && msg?.publicKeyHex === publicKeyHex);37- } catch { resolve(false); }38- ws.close();39- };40- ws.onerror = () => { clearTimeout(timer); resolve(false); };41- ws.onclose = () => { clearTimeout(timer); };42- } catch {43- resolve(false);44- }45- });46-}47-48-// Scan the subnet for a device with the given publicKeyHex.49-// Returns the IP if found, null otherwise.50-export async function discoverDevice(subnet: string, publicKeyHex: string): Promise<string | null> {51- const batch = 16;52- for (let start = 1; start <= 254; start += batch) {53- const end = Math.min(start + batch - 1, 254);54- const ips = Array.from({ length: end - start + 1 }, (_, i) => `${subnet}.${start + i}`);55- const results = await Promise.all(ips.map(async (ip) => {56- const found = await probeDiscover(ip, publicKeyHex);57- return found ? ip : null;58- }));59- const hit = results.find(r => r !== null);60- if (hit) return hit;61- }62- return null;63-}64-65-// Probe a single IP to see if it's a phone in pairing mode with the given token.66-// Returns { ip, publicKeyHex } if found, null otherwise.67-async function probePair(ip: string, token: string): Promise<{ ip: string; publicKeyHex: string } | null> {68- return new Promise((resolve) => {69- try {70- const ws = new WebSocket(`ws://${ip}:${SYNC_PORT}`);71- const timer = setTimeout(() => { try { ws.close(); } catch { /* ignore */ } resolve(null); }, PROBE_TIMEOUT_MS);72- ws.onopen = () => ws.send(JSON.stringify({ type: 'PEACH_PAIR_PROBE', token }));73- ws.onmessage = (e) => {74- clearTimeout(timer);75- try {76- const msg = JSON.parse(e.data as string);77- if (msg?.type === 'PEACH_PAIR_ACK' && typeof msg?.publicKeyHex === 'string') {78- resolve({ ip, publicKeyHex: msg.publicKeyHex as string });79- } else {80- resolve(null);81- }82- } catch { resolve(null); }83- ws.close();84- };85- ws.onerror = () => { clearTimeout(timer); resolve(null); };86- ws.onclose = () => { clearTimeout(timer); };87- } catch {88- resolve(null);89- }90- });91-}92-93-// Scan the subnet for a phone in pairing mode with the given token.94-// Returns { ip, publicKeyHex } of the first phone that accepts the token.95-export async function scanForPairingPhone(96- subnet: string,97- token: string,98-): Promise<{ ip: string; publicKeyHex: string } | null> {99- const batch = 16;100- for (let start = 1; start <= 254; start += batch) {101- const end = Math.min(start + batch - 1, 254);102- const ips = Array.from({ length: end - start + 1 }, (_, i) => `${subnet}.${start + i}`);103- const results = await Promise.all(ips.map(ip => probePair(ip, token)));104- const hit = results.find(r => r !== null);105- if (hit) return hit;106- }107- return null;10828 }src/shared/sync-types.ts+19 −79
@@ -1,11 +1,5 @@1-// Chirp Sync — type definitions.1+// Chirp Sync — type definitions (Master Brief v2).22 //3-// This file is in the middle of a v2.14 → v3 migration. The v3 chirp-sync4-// architecture (audio-bootstrapped pairing + manifest-diff WebSocket sync)5-// is being added alongside the v2.14 full-blob-over-native-host types.6-// The v2.14 types are scheduled for removal once every consumer in7-// messages.ts has been migrated; do not write new code against them.8-//93 // v3 — audio pairing once per device pair (Phase 1). All ongoing vault sync104 // after that runs over a direct WebSocket on the local network (Phase 2).115 // The two phases never overlap.@@ -64,23 +58,6 @@6458 // ---------------------------------------------------------------------------6559 6660 /**67- * v3 paired-device record. Stored in `chrome.storage.sync` so the registry68- * roams across browser profiles signed into Chrome/Firefox sync.69- *70- * `longTermPublicKeyHex` is the counterpart's vault-derived sync identity71- * static X25519 public key — see `deriveSyncIdentityKeypair`. Mutual Noise72- * XX authentication on every WebSocket connection rejects any peer not73- * present in this registry.74- *75- * `lastKnownIp` is intentionally NOT stored here — it's per-network and76- * lives in `chrome.storage.local` keyed by `longTermPublicKeyHex`. See77- * `getLastKnownIp` / `setLastKnownIp` in sync-storage.ts.78- *79- * Legacy v2.14 fields `displayName`, `x25519PublicKey`, `lastSeenAt`,80- * `lastSyncAt`, `lastKnownIp` are kept for migration only; new code reads81- * `deviceName`, `longTermPublicKeyHex`, `lastSyncedAt`.82- */83-/**8461 * Trust source of a paired-device entry — per v2 brief §4.2.8562 *8663 * "direct" — established via an audio pairing session with this device.@@ -93,6 +70,19 @@9370 */9471 export type TrustSource = "direct" | "vouched";9572 73+/**74+ * v3 paired-device record. Stored in `chrome.storage.sync` so the registry75+ * roams across browser profiles signed into Chrome/Firefox sync.76+ *77+ * `longTermPublicKeyHex` is the counterpart's vault-derived X25519 sync78+ * identity static key — see `deriveSyncIdentityKeypair`. Mutual Noise XX79+ * authentication on every WebSocket connection rejects any peer not80+ * present in this registry.81+ *82+ * `lastKnownIp` is intentionally NOT stored here — it's per-network and83+ * lives in `chrome.storage.local` keyed by `longTermPublicKeyHex`. See84+ * `getLastKnownIp` / `setLastKnownIp` in `paired-devices.ts`.85+ */9686 export interface PairedDevice {9787 deviceId: string;9888 deviceName?: string;@@ -102,30 +92,18 @@10292 * Long-term Ed25519 verification key for the peer's device-manifest10393 * signatures (v2 brief §4.3). Derived deterministically from the same10494 * master key as `longTermPublicKeyHex`, via `deriveSyncSigningKeypair`.105- * Optional during the migration — older paired records lack it, so106- * manifests issued by such peers can't be verified and will be rejected.10795 */10896 signingPublicKeyHex?: string;10997 /** v2 brief §4.2 — distinguishes direct-paired vs vouched-via-manifest. */11098 trustSource?: TrustSource;11199 /**112- * The peer's WebSocket sync port. Android picks a random high port113- * (49152–65535) at first launch and announces it inside the encrypted114- * pairing channel; the extension caches it here. iOS / Tauri sync hosts115- * follow the same convention.100+ * The peer's WebSocket sync port. Mobile hosts pick a random high port101+ * (49152–65535) at first launch and announce it inside the encrypted102+ * pairing channel; the extension caches it here.116103 */117104 wsPort?: number;118105 pairedAt: number;119106 lastSyncedAt?: number;120-121- // v2.14 legacy — kept readable so the migration sweep doesn't crash122- // every test that still constructs the old shape. New writers must use123- // the v3 fields above.124- displayName?: string;125- x25519PublicKey?: string;126- lastSeenAt?: number;127- lastSyncAt?: number;128- lastKnownIp?: string;129107 }130108 131109 // ---------------------------------------------------------------------------@@ -133,8 +111,8 @@133111 // ---------------------------------------------------------------------------134112 135113 /**136- * Phase 1 pairing state, surfaced to the SyncPairing view. The view binds137- * its bird-animation acts directly to these states: `listening` and114+ * Phase 1 pairing state, surfaced to the chirp pairing view. The view115+ * binds its bird-animation acts directly to these states: `listening` and138116 * `chirping` map to Act 1, `handshake` maps to Act 2, `paired` to Act 3.139117 */140118 export type PairingPhase =@@ -218,41 +196,3 @@218196 }219197 220198 export type SyncStatusV3 = "up_to_date" | "syncing" | "unreachable" | "never";221-222-// ---------------------------------------------------------------------------223-// v2.14 legacy types — DO NOT USE IN NEW CODE224-//225-// These remain only because messages.ts and the v2.14 coordinator/network226-// modules import them. Once the migration sweep lands, this whole block227-// goes away.228-// ---------------------------------------------------------------------------229-230-export interface TrustedNetwork {231- ssid?: string;232- bssid?: string;233- isTailscale: boolean;234- addedAt: number;235-}236-237-export interface SyncableDevice {238- publicKeyHex: string;239- deviceName: string;240- pairedAt: number;241-}242-243-export type SyncMessage =244- | { type: 'SYNC_HELLO' }245- | { type: 'SYNC_VAULT_SEND'; blob: string; timestamp: number; license?: string; devices: SyncableDevice[] }246- | { type: 'SYNC_VAULT_ACK'; applied: boolean }247- | { type: 'SYNC_DONE' }248- | { type: 'SYNC_ERROR'; error: string };249-250-export type SyncStatus = "synced" | "syncing" | "no_peers" | "not_trusted" | "offline";251-252-export interface IncomingPairingRequest {253- deviceName: string;254- publicKeyHex: string;255- receivedAt: number;256- expiresAt: number;257- connectionId?: string;258-}src/shared/sync-wire.ts+0 −31
@@ -1,31 +0,0 @@1-import type { SyncMessage } from './sync-types';2-3-// Sync messages are JSON-encoded and then Noise NK encrypted. The encrypted4-// binary is base64-encoded for transmission over the text-only WS server.5-// This layer only handles the JSON ↔ SyncMessage boundary; the caller6-// is responsible for the encrypt/decrypt step around it.7-8-export function encodeSyncMessage(msg: SyncMessage): Uint8Array {9- return new TextEncoder().encode(JSON.stringify(msg));10-}11-12-export function decodeSyncMessage(data: Uint8Array): SyncMessage {13- const parsed = JSON.parse(new TextDecoder().decode(data));14- if (typeof parsed?.type !== 'string') throw new Error('invalid sync message');15- return parsed as SyncMessage;16-}17-18-// Base64 helpers for the WS text-frame transport layer.19-20-export function bytesToBase64(bytes: Uint8Array): string {21- let binary = '';22- for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);23- return btoa(binary);24-}25-26-export function base64ToBytes(b64: string): Uint8Array {27- const binary = atob(b64);28- const bytes = new Uint8Array(binary.length);29- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);30- return bytes;31-}src/shared/types.ts+0 −19
@@ -251,26 +251,11 @@251251 | "VERIFY_MAIN_PASSWORD"252252 | "CHANGE_PASSWORD"253253 | "GET_SITE_PASSWORD_POLICY"254- | "GET_SYNC_STATE"255- | "GET_PAIRED_DEVICES"256- | "GET_SYNC_STATUS"257- | "TRIGGER_SYNC"258- | "ACCEPT_PAIRING"259- | "REMOVE_PAIRED_DEVICE"260- | "REMOVE_TRUSTED_NETWORK"261- | "MARK_NETWORK_TRUSTED"262- | "RESET_SYNC_IDENTITY"263254 | "CHIRP_GET_LOCAL_IDENTITY"264255 | "CHIRP_COMPLETE_PAIRING"265256 | "CHIRP_LIST_PAIRED_DEVICES"266257 | "CHIRP_UNPAIR_DEVICE"267258 | "CHIRP_RENAME_LOCAL_DEVICE"268- | "GET_INCOMING_PAIRING_REQUEST"269- | "RESPOND_INCOMING_PAIRING"270- | "DEBUG_INJECT_INCOMING_PAIRING"271- | "START_PAIRING_SERVER"272- | "STOP_PAIRING_SERVER"273- | "GET_RECENT_PAIRING"274259 | "GET_PENDING_PASSKEY"275260 | "APPROVE_PASSKEY"276261 | "DENY_PASSKEY"@@ -312,9 +297,6 @@312297 | "VALIDATE_RECOVERY_WORDS"313298 | "RECOVER_WITH_WORDS"314299 | "GET_RECOVERY_WORDS"315- | "SCAN_PAIRING_QR"316- | "SYNC_SESSION_STARTED"317- | "GET_LOCAL_IP"318300 | "GET_IDENTITY_SUGGESTIONS"319301 | "TOUCH_AUTO_LOCK"320302 | "POPULATE_INLINE_VAULT"@@ -322,7 +304,6 @@322304 | "DELETE_NOTE"323305 | "GET_NOTES"324306 | "SEARCH_VAULT"325- | "PAIRING_COMPLETED"326307 | "ML_INIT"327308 | "ML_PREDICT_BATCH"328309 // Content-script-bound message types — sent BY the background TO contentsrc/shared/websocket-sync-transport.ts+0 −108
@@ -1,108 +0,0 @@1-import { noiseNKInitiator, performInitiatorAuth } from './noise-js';2-import { bytesToBase64, base64ToBytes } from './sync-wire';3-4-export interface SyncTransport {5- send(data: Uint8Array): Promise<void>;6- receive(): Promise<Uint8Array>;7- close(): Promise<void>;8-}9-10-// Binary Noise NK frames are base64-encoded and sent as text WS frames so11-// they work over the phone's text-only PeachWebSocketServer. Each frame is12-// a plain base64 string (not JSON-wrapped) so the server's else-branch13-// forwards it via peach:sync-frame without modification.14-export async function createWebSocketSyncTransport(15- url: string,16- remotePublicKeyHex: string,17- ourPrivateKey: Uint8Array,18- ourPublicKey: Uint8Array,19-): Promise<SyncTransport> {20- const remotePublicKey = hexToBytes(remotePublicKeyHex);21-22- return new Promise((resolve, reject) => {23- const ws = new WebSocket(url);24-25- const messageQueue: string[] = [];26- const waiters: ((frame: string) => void)[] = [];27-28- const enqueue = (frame: string) => {29- if (waiters.length > 0) {30- waiters.shift()!(frame);31- } else {32- messageQueue.push(frame);33- }34- };35-36- ws.onmessage = (event) => enqueue(event.data as string);37- ws.onerror = () => reject(new Error('WebSocket connection failed'));38- ws.onclose = () => { waiters.forEach(w => w('')); waiters.length = 0; };39-40- ws.onopen = async () => {41- try {42- const rawSend = (msg: Uint8Array) => {43- ws.send(bytesToBase64(msg));44- return Promise.resolve();45- };46- const rawReceive = (): Promise<Uint8Array> => new Promise((res) => {47- const handle = (frame: string) => {48- if (frame === '') { res(new Uint8Array(0)); return; }49- res(base64ToBytes(frame));50- };51- if (messageQueue.length > 0) handle(messageQueue.shift()!);52- else waiters.push(handle);53- });54-55- // Noise NK handshake — desktop is always the initiator.56- const session = await noiseNKInitiator(remotePublicKey, rawSend, rawReceive);57-58- // Post-handshake initiator auth round: prove possession of our static59- // private key to the responder so the responder can verify we are a60- // paired device. Plaintext bytes are Noise-encrypted on the wire.61- const encryptedSend = (data: Uint8Array) =>62- rawSend(session.encrypt(data));63- const encryptedReceive = async () => {64- const frame = await rawReceive();65- if (frame.length === 0) return frame;66- return session.decrypt(frame);67- };68- await performInitiatorAuth(69- session,70- ourPrivateKey,71- ourPublicKey,72- remotePublicKey,73- encryptedSend,74- encryptedReceive,75- );76-77- resolve({78- send: async (plaintext) => {79- ws.send(bytesToBase64(session.encrypt(plaintext)));80- },81- receive: () => new Promise((res) => {82- const handle = (frame: string) => {83- if (frame === '') { res(new Uint8Array(0)); return; }84- res(session.decrypt(base64ToBytes(frame)));85- };86- if (messageQueue.length > 0) handle(messageQueue.shift()!);87- else waiters.push(handle);88- }),89- close: () => {90- ws.close();91- return Promise.resolve();92- },93- });94- } catch (err) {95- ws.close();96- reject(err);97- }98- };99- });100-}101-102-function hexToBytes(hex: string): Uint8Array {103- const bytes = new Uint8Array(hex.length / 2);104- for (let i = 0; i < hex.length; i += 2) {105- bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);106- }107- return bytes;108-}