781b92629025
feat: Add deterministic full-mesh sync (v4.4.0)
- time
- 2026-07-28 22:20 UTC · 3 months ago
- machine
- koh2
- agent
- gpt-5
- session
- deterministic full mesh
- parent
- 359f57c490f8
↓ peach-browser-781b9262.tar.gzatlas at this save
files changed22 files
manifest.firefox.json+1 −1manifest.json+2 −1package.json+1 −1src/background/entry.ts+14 −1src/background/mesh-relay-listener.test.ts+62 −0src/background/mesh-relay-listener.ts+219 −0src/background/sync-coordinator.test.ts+55 −1src/background/sync-coordinator.ts+164 −4src/background/sync-runner.ts+3 −2src/background/sync-static.test.ts+1 −1src/background/sync-trigger.test.ts+21 −1src/background/sync-trigger.ts+26 −10src/background/sync-vault-adapter.test.ts+34 −15src/background/sync-vault-adapter.ts+155 −112src/shared/device-manifest.test.ts+6 −3src/shared/device-manifest.ts+13 −3src/shared/mesh-peer-role.ts+59 −0src/shared/rendezvous.test.ts+14 −0src/shared/rendezvous.ts+43 −0src/shared/sync-kk-transport.test.ts+94 −1src/shared/sync-transport.ts+185 −5src/shared/sync-types.ts+9 −6manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.3.20",4+ "version": "4.4.0",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+2 −1
@@ -1,7 +1,8 @@11 {22 "manifest_version": 3,3+ "minimum_chrome_version": "116",34 "name": "Peach Password Manager",4- "version": "4.3.20",5+ "version": "4.4.0",56 "description": "Zero-knowledge password manager",67 "action": {78 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "4.3.20",3+ "version": "4.4.0",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/background/entry.ts+14 −1
@@ -8,9 +8,19 @@88 import { ensureSettingsLoaded } from "./vault";99 import { isRecoveryConfirmPending } from "./recovery-pending";1010 import { denyAllPendingPasskeyRequests } from "./passkey";11-import { hydratePersistedSyncState, setSyncRunner, triggerSyncIfReachable } from "./sync-trigger";11+import {12+ hydratePersistedSyncState,13+ setSyncDialTargetSelector,14+ setSyncRunner,15+ triggerSyncIfReachable,16+} from "./sync-trigger";1217 import { productionSyncRunner } from "./sync-runner";1318 import { repairPairedRevocationOverlaps } from "./paired-devices";19+import {20+ requestMeshRelayReconcile,21+ selectProductionDialTargets,22+ setupMeshRelayListener,23+} from "./mesh-relay-listener";1424 1525 // Keep alive for MV3 service worker1626 chrome.runtime.onInstalled.addListener(() => {});@@ -69,6 +79,8 @@6979 // `triggerSyncIfReachable` is a no-op (the runner slot starts null) — the7080 // pairing/transport/coordinator stack ships dormant until this assignment.7181 setSyncRunner(productionSyncRunner);82+ setSyncDialTargetSelector(selectProductionDialTargets);83+ setupMeshRelayListener();7284 // 10-minute background sync. Must be registered during initial synchronous7385 // evaluation for the same reason as the clipboard alarm: a cold worker7486 // woken BY the sync alarm needs the listener already attached. Setup is@@ -79,6 +91,7 @@7991 // guard inside triggerSyncIfReachable handles the case where another path8092 // (e.g. a fresh pairing completing on the same wake) is already running.8193 void restored94+ .then(() => requestMeshRelayReconcile())8295 .then(() => repairPairedRevocationOverlaps())8396 .then(() => hydratePersistedSyncState())8497 .then(() => triggerSyncIfReachable({ reason: "startup" }))src/background/mesh-relay-listener.test.ts+62 −0
@@ -0,0 +1,62 @@1+import { describe, expect, it } from "vitest";2+import type { PairedDevice } from "../shared/sync-types";3+import {4+ isRouteCapableMeshPeer,5+ nextMeshWaiterRetryDelay,6+ selectDialTargets,7+ weDialMeshPeer,8+} from "../shared/mesh-peer-role";9+10+function peer(11+ id: string,12+ key: string,13+ overrides: Partial<PairedDevice> = {},14+): PairedDevice {15+ return {16+ deviceId: id,17+ deviceName: id,18+ deviceType: "extension",19+ longTermPublicKeyHex: key,20+ signingPublicKeyHex: "cc".repeat(32),21+ rendezvousSecretHex: "dd".repeat(32),22+ relayUrl: "wss://relay.peachpasswords.com",23+ trustSource: "vouched",24+ pairedAt: 1,25+ ...overrides,26+ };27+}28+29+describe("mesh relay roles", () => {30+ it("assigns exactly one dialer for every device-type combination", () => {31+ const low = "11".repeat(32);32+ const high = "ee".repeat(32);33+ for (const deviceType of ["extension", "android", "ios"] as const) {34+ expect(weDialMeshPeer(low, high)).toBe(true);35+ expect(weDialMeshPeer(high, low)).toBe(false);36+ expect(37+ selectDialTargets(38+ [peer(deviceType, high, { deviceType })],39+ low,40+ ).map((device) => device.deviceId),41+ ).toEqual([deviceType]);42+ }43+ });44+45+ it("admits direct and vouched routes but rejects incomplete or unknown rows", () => {46+ const key = "ee".repeat(32);47+ expect(isRouteCapableMeshPeer(peer("direct", key, { trustSource: "direct" }))).toBe(true);48+ expect(isRouteCapableMeshPeer(peer("vouched", key))).toBe(true);49+ expect(isRouteCapableMeshPeer(peer("missing-secret", key, {50+ rendezvousSecretHex: undefined,51+ }))).toBe(false);52+ expect(isRouteCapableMeshPeer(peer("printer", key, {53+ deviceType: "printer" as PairedDevice["deviceType"],54+ }))).toBe(false);55+ });56+57+ it("backs off failed waiter reconnects and resets after a completed round", () => {58+ expect(nextMeshWaiterRetryDelay(2_000, false)).toBe(4_000);59+ expect(nextMeshWaiterRetryDelay(20_000, false)).toBe(30_000);60+ expect(nextMeshWaiterRetryDelay(30_000, true)).toBe(2_000);61+ });62+});src/background/mesh-relay-listener.ts+219 −0
@@ -0,0 +1,219 @@1+import type { PairedDevice } from "../shared/sync-types";2+import {3+ isRouteCapableMeshPeer,4+ MESH_WAITER_RETRY_DELAY_MS,5+ nextMeshWaiterRetryDelay,6+ selectDialTargets,7+ weDialMeshPeer,8+} from "../shared/mesh-peer-role";9+import {10+ openRelaySyncResponderSession,11+ type SyncPeerIdentity,12+} from "../shared/sync-transport";13+import {14+ rendezvousSecretFromHex,15+ waitingRendezvousIds,16+} from "../shared/rendezvous";17+import { runSyncResponder } from "./sync-coordinator";18+import { createSyncVaultAdapter } from "./sync-vault-adapter";19+import { getLocalPairingIdentity } from "./device-identity";20+import {21+ findPairedDeviceById,22+ listPairedDevices,23+ markLastSyncedAt,24+} from "./paired-devices";25+import { getSyncSettings, relayInForce } from "./sync-settings";26+import { isUnlocked } from "./vault";27+28+const RECONCILE_INTERVAL_MS = 60_000;29+30+interface WaiterHandle {31+ abort: AbortController;32+}33+34+interface MeshListenerState {35+ installed: boolean;36+ reconcileQueued: boolean;37+ timer: ReturnType<typeof setTimeout> | null;38+ waiters: Map<string, WaiterHandle>;39+}40+41+function state(): MeshListenerState {42+ const global = globalThis as typeof globalThis & {43+ __peachMeshListenerState?: MeshListenerState;44+ };45+ if (!global.__peachMeshListenerState) {46+ global.__peachMeshListenerState = {47+ installed: false,48+ reconcileQueued: false,49+ timer: null,50+ waiters: new Map(),51+ };52+ }53+ return global.__peachMeshListenerState;54+}55+56+export async function selectProductionDialTargets(57+ devices: PairedDevice[],58+): Promise<PairedDevice[]> {59+ // Preserve the established VAULT_LOCKED outcome. The production runner60+ // refuses before opening a socket, so returning the candidates here is safe61+ // and avoids misreporting a locked vault as "no eligible host".62+ if (!isUnlocked()) return devices;63+ const local = await getLocalPairingIdentity();64+ try {65+ return selectDialTargets(66+ devices,67+ local.identity.longTermPublicKeyHex,68+ );69+ } finally {70+ local.privateKey.fill(0);71+ local.publicKey.fill(0);72+ }73+}74+75+export function setupMeshRelayListener(): void {76+ const listener = state();77+ if (listener.installed) return;78+ listener.installed = true;79+ chrome.storage?.onChanged?.addListener(() => requestMeshRelayReconcile());80+ requestMeshRelayReconcile();81+}82+83+export function requestMeshRelayReconcile(): void {84+ const listener = state();85+ if (listener.reconcileQueued) return;86+ listener.reconcileQueued = true;87+ queueMicrotask(() => {88+ listener.reconcileQueued = false;89+ void reconcile().catch(() => undefined);90+ });91+}92+93+async function reconcile(): Promise<void> {94+ const listener = state();95+ if (listener.timer !== null) clearTimeout(listener.timer);96+ listener.timer = setTimeout(requestMeshRelayReconcile, RECONCILE_INTERVAL_MS);97+98+ if (!isUnlocked()) {99+ abortAll(listener);100+ return;101+ }102+103+ const local = await getLocalPairingIdentity();104+ const desired = new Map<string, {105+ peer: PairedDevice;106+ relayUrl: string;107+ rendezvousId: string;108+ }>();109+ try {110+ const settings = await getSyncSettings();111+ const fallbackRelay = relayInForce(settings);112+ const nowSeconds = Date.now() / 1000;113+ for (const peer of await listPairedDevices()) {114+ if (115+ !isRouteCapableMeshPeer(peer) ||116+ weDialMeshPeer(117+ local.identity.longTermPublicKeyHex,118+ peer.longTermPublicKeyHex!,119+ )120+ ) {121+ continue;122+ }123+ const relayUrl = peer.relayUrl ?? fallbackRelay;124+ const secret = rendezvousSecretFromHex(peer.rendezvousSecretHex!);125+ for (const rendezvousId of waitingRendezvousIds(secret, nowSeconds)) {126+ desired.set(`${peer.deviceId}|${relayUrl}|${rendezvousId}`, {127+ peer,128+ relayUrl,129+ rendezvousId,130+ });131+ }132+ }133+ } finally {134+ local.privateKey.fill(0);135+ local.publicKey.fill(0);136+ }137+138+ for (const [key, handle] of listener.waiters) {139+ if (desired.has(key)) continue;140+ handle.abort.abort();141+ listener.waiters.delete(key);142+ }143+ for (const [key, target] of desired) {144+ if (listener.waiters.has(key)) continue;145+ const abort = new AbortController();146+ listener.waiters.set(key, { abort });147+ void holdWaiter(target, abort.signal).finally(() => {148+ if (listener.waiters.get(key)?.abort === abort) {149+ listener.waiters.delete(key);150+ }151+ });152+ }153+}154+155+async function holdWaiter(156+ target: { peer: PairedDevice; relayUrl: string; rendezvousId: string },157+ signal: AbortSignal,158+): Promise<void> {159+ let retryDelayMs = MESH_WAITER_RETRY_DELAY_MS;160+ while (!signal.aborted && isUnlocked()) {161+ const current = await findPairedDeviceById(target.peer.deviceId);162+ if (!current || !isRouteCapableMeshPeer(current)) return;163+164+ let session: Awaited<ReturnType<typeof openRelaySyncResponderSession>> | null = null;165+ let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;166+ let succeeded = false;167+ try {168+ local = await getLocalPairingIdentity();169+ const identity: SyncPeerIdentity = {170+ ...local.identity,171+ longTermPrivateKey: local.privateKey,172+ };173+ session = await openRelaySyncResponderSession(174+ target.relayUrl,175+ identity,176+ current.longTermPublicKeyHex!,177+ target.rendezvousId,178+ { signal },179+ );180+ const outcome = await runSyncResponder(181+ session,182+ createSyncVaultAdapter({ deviceName: current.deviceName }),183+ );184+ if (outcome.status === "completed") {185+ await markLastSyncedAt(current.deviceId, Date.now());186+ succeeded = true;187+ }188+ requestMeshRelayReconcile();189+ } catch {190+ if (signal.aborted) return;191+ } finally {192+ local?.privateKey.fill(0);193+ local?.publicKey.fill(0);194+ await session?.close().catch(() => undefined);195+ }196+ await waitForRetry(signal, retryDelayMs);197+ retryDelayMs = nextMeshWaiterRetryDelay(retryDelayMs, succeeded);198+ }199+}200+201+function waitForRetry(signal: AbortSignal, delayMs: number): Promise<void> {202+ if (signal.aborted) return Promise.resolve();203+ return new Promise((resolve) => {204+ const onAbort = () => {205+ clearTimeout(timer);206+ resolve();207+ };208+ const timer = setTimeout(() => {209+ signal.removeEventListener("abort", onAbort);210+ resolve();211+ }, delayMs);212+ signal.addEventListener("abort", onAbort, { once: true });213+ });214+}215+216+function abortAll(listener: MeshListenerState): void {217+ for (const handle of listener.waiters.values()) handle.abort.abort();218+ listener.waiters.clear();219+}src/background/sync-coordinator.test.ts+55 −1
@@ -1,5 +1,9 @@11 import { describe, expect, it, vi } from "vitest";2-import { runSyncInitiator, type SyncVaultAdapter } from "./sync-coordinator";2+import {3+ runSyncInitiator,4+ runSyncResponder,5+ type SyncVaultAdapter,6+} from "./sync-coordinator";37 import type { SyncMessage, SignedDeviceManifest } from "../shared/sync-types";48 import type { SyncSession } from "../shared/sync-transport";59 import { buildSignedDeviceManifest } from "../shared/device-manifest";@@ -53,6 +57,30 @@5357 return [ourSession, peer];5458 }5559 60+function symmetricSessionPair(): [SyncSession, SyncSession] {61+ const queues: [Uint8Array[], Uint8Array[]] = [[], []];62+ const readers: [63+ Array<(value: Uint8Array) => void>,64+ Array<(value: Uint8Array) => void>,65+ ] = [[], []];66+ const make = (side: 0 | 1): SyncSession => ({67+ send(bytes) {68+ const other = side === 0 ? 1 : 0;69+ const reader = readers[other].shift();70+ if (reader) reader(bytes);71+ else queues[other].push(bytes);72+ },73+ receive() {74+ const queued = queues[side].shift();75+ if (queued) return Promise.resolve(queued);76+ return new Promise<Uint8Array>((resolve) => readers[side].push(resolve));77+ },78+ async close() {},79+ remoteStaticPublicKey: new Uint8Array(32).fill(side === 0 ? 0x22 : 0x11),80+ });81+ return [make(0), make(1)];82+}83+5684 interface FakePeer {5785 send(msg: SyncMessage): Promise<void>;5886 receive(): Promise<SyncMessage>;@@ -174,6 +202,32 @@174202 });175203 176204 describe("runSyncInitiator", () => {205+ it("syncs two mesh peers directly with their introducer absent", async () => {206+ const [dialerSession, waiterSession] = symmetricSessionPair();207+ const dialer = makeVaultAdapter({208+ deviceId: "laptop-a",209+ localVersion: 10,210+ entries: { a: 10 },211+ ciphertext: { a: "cipher-a" },212+ });213+ const waiter = makeVaultAdapter({214+ deviceId: "laptop-c",215+ localVersion: 11,216+ entries: { c: 11 },217+ ciphertext: { c: "cipher-c" },218+ });219+220+ const [dialerOutcome, waiterOutcome] = await Promise.all([221+ runSyncInitiator(dialerSession, dialer),222+ runSyncResponder(waiterSession, waiter),223+ ]);224+225+ expect(dialerOutcome.status).toBe("completed");226+ expect(waiterOutcome.status).toBe("completed");227+ expect(dialer.appliedEntries).toEqual([{ c: "cipher-c" }]);228+ expect(waiter.appliedEntries).toEqual([{ a: "cipher-a" }]);229+ });230+177231 it("reports the privacy-safe protocol stage when a device stops answering", async () => {178232 vi.useFakeTimers();179233 try {src/background/sync-coordinator.ts+164 −4
@@ -90,10 +90,8 @@9090 }9191 9292 /**93- * Run one sync round as the initiator. The extension is always the94- * initiator (it is the WebSocket client). The peer's role is symmetric95- * after SYNC_HELLO, so a corresponding responder helper exists for the96- * mobile-side coordinator that calls this same protocol the other way.93+ * Run one sync round as the lower-key initiator. Device type does not decide94+ * the role; the higher-key peer runs [runSyncResponder].9795 */9896 export async function runSyncInitiator(9997 session: SyncSession,@@ -253,6 +251,168 @@253251 // Session lifetime is owned by the runner (sync-runner.ts), which254252 // closes it in its own finally block. The coordinator must not close it255253 // here — the runner still needs to record the outcome after we return.254+ timer.cancel();255+ }256+}257+258+/**259+ * Exact complement of [runSyncInitiator] for the higher-key peer holding a260+ * relay waiter room. Keeping this in the browser lets two extensions form a261+ * direct mesh edge; the device that introduced them is not in the data path.262+ */263+export async function runSyncResponder(264+ session: SyncSession,265+ vault: SyncVaultAdapter,266+): Promise<SyncOutcome> {267+ const result: SyncOutcome = {268+ status: "errored",269+ entriesReceived: 0,270+ entriesSent: 0,271+ tombstonesReceived: 0,272+ tombstonesSent: 0,273+ };274+ const timer = scheduleTimeout(SESSION_TIMEOUT_MS);275+ let stage: SyncStage = "waiting_for_device_hello";276+ try {277+ const ourVaultId = await vault.computeVaultId();278+ const ourDeviceId = await vault.getOurDeviceId();279+ const localVersion = await vault.getLocalVersion();280+ const localTime = Date.now();281+282+ const helloIn = await receiveMessage(session, timer);283+ if (helloIn.type !== "SYNC_HELLO") {284+ return errorOutcome(result, `expected SYNC_HELLO, got ${helloIn.type}`);285+ }286+ await sendMessage(session, {287+ type: "SYNC_HELLO",288+ vaultId: ourVaultId,289+ deviceId: ourDeviceId,290+ version: localVersion,291+ deviceTime: localTime,292+ });293+ if (helloIn.vaultId !== ourVaultId) {294+ result.status = "vault_mismatch";295+ return result;296+ }297+ if (Math.abs(localTime - (helloIn.deviceTime ?? 0)) > 120_000) {298+ console.warn(299+ `[sync] clock skew detected: local=${localTime} remote=${helloIn.deviceTime ?? "unknown"}`,300+ );301+ }302+303+ if (helloIn.version === localVersion) {304+ stage = "waiting_for_device_done";305+ const done = await receiveMessage(session, timer);306+ if (done.type !== "SYNC_DONE") {307+ return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);308+ }309+ await sendMessage(session, {310+ type: "SYNC_DONE",311+ entriesReceived: 0,312+ entriesSent: 0,313+ });314+ result.status = "completed";315+ return result;316+ }317+318+ const ourEntries = await vault.buildEntryManifest();319+ const ourTombstones = await vault.buildTombstoneManifest();320+ stage = "waiting_for_device_manifest";321+ const manifestIn = await receiveMessage(session, timer);322+ if (manifestIn.type !== "SYNC_MANIFEST") {323+ return errorOutcome(result, `expected SYNC_MANIFEST, got ${manifestIn.type}`);324+ }325+ await sendMessage(session, {326+ type: "SYNC_MANIFEST",327+ entries: ourEntries,328+ tombstones: ourTombstones,329+ });330+331+ stage = "waiting_for_device_request";332+ const requestIn = await receiveMessage(session, timer);333+ if (requestIn.type !== "SYNC_REQUEST") {334+ return errorOutcome(result, `expected SYNC_REQUEST, got ${requestIn.type}`);335+ }336+ const wantFromPeer = diffNewer(manifestIn.entries, ourEntries);337+ await sendMessage(session, { type: "SYNC_REQUEST", entryIds: wantFromPeer });338+339+ stage = "waiting_for_device_entries";340+ const entriesIn = await receiveMessage(session, timer);341+ if (entriesIn.type !== "SYNC_ENTRIES") {342+ return errorOutcome(result, `expected SYNC_ENTRIES, got ${entriesIn.type}`);343+ }344+ if (wantFromPeer.length > 0) {345+ await vault.applyEntries(entriesIn.entries);346+ result.entriesReceived = Object.keys(entriesIn.entries).length;347+ }348+ if (requestIn.entryIds.length > 0) {349+ const ourPayload = await vault.getEncryptedEntries(requestIn.entryIds);350+ await sendMessage(session, { type: "SYNC_ENTRIES", entries: ourPayload });351+ result.entriesSent = Object.keys(ourPayload).length;352+ }353+354+ stage = "waiting_for_device_tombstones";355+ const tombstonesIn = await receiveMessage(session, timer);356+ if (tombstonesIn.type !== "SYNC_TOMBSTONES") {357+ return errorOutcome(result, `expected SYNC_TOMBSTONES, got ${tombstonesIn.type}`);358+ }359+ await vault.applyTombstones(tombstonesIn.tombstones);360+ result.tombstonesReceived = Object.keys(tombstonesIn.tombstones).length;361+362+ const newerTombstonesForPeer: Record<string, number> = {};363+ for (const [id, timestamp] of Object.entries(ourTombstones)) {364+ if (timestamp > (manifestIn.tombstones[id] ?? 0)) {365+ newerTombstonesForPeer[id] = timestamp;366+ }367+ }368+ await sendMessage(session, {369+ type: "SYNC_TOMBSTONES",370+ tombstones: newerTombstonesForPeer,371+ });372+ result.tombstonesSent = Object.keys(newerTombstonesForPeer).length;373+374+ if (vault.buildOutgoingDeviceManifest && vault.applyIncomingDeviceManifest) {375+ stage = "waiting_for_device_graph";376+ const graphIn = await receiveMessage(session, timer);377+ if (graphIn.type !== "SYNC_DEVICE_MANIFEST") {378+ return errorOutcome(379+ result,380+ `expected SYNC_DEVICE_MANIFEST, got ${graphIn.type}`,381+ );382+ }383+ const ours = await vault.buildOutgoingDeviceManifest();384+ await sendMessage(session, {385+ type: "SYNC_DEVICE_MANIFEST",386+ manifest: ours,387+ });388+ if (graphIn.manifest) {389+ await vault.applyIncomingDeviceManifest(390+ graphIn.manifest,391+ session.remoteStaticPublicKey,392+ );393+ }394+ }395+396+ stage = "waiting_for_device_done";397+ const done = await receiveMessage(session, timer);398+ if (done.type !== "SYNC_DONE") {399+ return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);400+ }401+ await sendMessage(session, {402+ type: "SYNC_DONE",403+ entriesReceived: result.entriesReceived,404+ entriesSent: result.entriesSent,405+ });406+ result.status = "completed";407+ return result;408+ } catch (error) {409+ if ((error as Error).message === "sync_timeout") {410+ result.status = "timeout";411+ result.error = `sync_timeout:${stage}`;412+ return result;413+ }414+ return errorOutcome(result, (error as Error).message);415+ } finally {256416 timer.cancel();257417 }258418 }src/background/sync-runner.ts+3 −2
@@ -170,9 +170,10 @@170170 lastError = error;171171 // Each clock-skew window fails separately and only the last one survives172172 // into the thrown message; on a real device the earlier ones are often173- // the informative ones.173+ // the informative ones. The URL and rendezvous capability are174+ // deliberately omitted because either may reveal private routing state.174175 console.info(175- `[Peach sync] relay dial failed — relay=${relayUrl} rendezvous=${rendezvousId}: ${176+ `[Peach sync] relay dial failed — reason=${176177 error instanceof Error ? error.message : String(error)177178 }`,178179 );src/background/sync-static.test.ts+1 −1
@@ -72,7 +72,7 @@7272 expect(triggerSrc).toMatch(/trustSource === "vouched"/);7373 });7474 75- it("the browser never listens — no pairing server or audio-chirp module survives", () => {75+ it("pairing stays relay-only with no local server or audio-chirp module", () => {7676 const appSrc = src("../popup/App.tsx");7777 expect(appSrc).not.toContain("openPairingServer");7878 expect(appSrc).not.toContain("chirp-pair-capacitor");src/background/sync-trigger.test.ts+21 −1
@@ -3,6 +3,7 @@33 import { upsertPairedDevice, findPairedDeviceById } from "./paired-devices";44 import {55 triggerSyncIfReachable,6+ setSyncDialTargetSelector,67 setSyncRunner,78 _resetSyncTriggerForTest,89 getSyncState,@@ -181,7 +182,7 @@181182 expect(seen).toEqual(["VVV-vouched"]);182183 });183184 184- it("never treats another extension as a sync host — it cannot listen", async () => {185+ it("keeps the legacy no-selector test fallback limited to native hosts", async () => {185186 let ran = false;186187 setSyncRunner(async () => { ran = true; return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });187188 await upsertPairedDevice({@@ -192,6 +193,25 @@192193 });193194 await triggerSyncIfReachable({ reason: "foreground" });194195 expect(ran).toBe(false);196+ });197+198+ it("lets the production role selector choose an extension dial target", async () => {199+ const seen: string[] = [];200+ setSyncRunner(async (device) => {201+ seen.push(device.deviceId);202+ return { ok: true as const, entriesReceived: 0, entriesSent: 0 };203+ });204+ setSyncDialTargetSelector(async (devices) =>205+ devices.filter((device) => device.deviceType === "extension")206+ );207+ await upsertPairedDevice({208+ ...EXT,209+ trustSource: "vouched",210+ });211+212+ await triggerSyncIfReachable({ reason: "foreground" });213+214+ expect(seen).toEqual(["CCC-ext"]);195215 });196216 197217 it("persists peach_last_sync_result to storage after a successful sync", async () => {src/background/sync-trigger.ts+26 −10
@@ -63,11 +63,15 @@6363 | { ok: false; error: string };6464 6565 export type RunSyncWithDevice = (device: PairedDevice) => Promise<SyncRunResult>;66+export type SelectSyncDialTargets = (67+ devices: PairedDevice[],68+) => Promise<PairedDevice[]>;6669 6770 interface PeachSyncTriggerState {6871 syncInFlight: boolean;6972 lastState: SyncState;7073 runner: RunSyncWithDevice | null;74+ selectDialTargets: SelectSyncDialTargets | null;7175 }7276 7377 function getSyncTriggerState(): PeachSyncTriggerState {@@ -79,6 +83,7 @@7983 syncInFlight: false,8084 lastState: { status: "idle" },8185 runner: null,86+ selectDialTargets: null,8287 };8388 }8489 return g.__peachSyncTriggerState;@@ -88,11 +93,18 @@8893 getSyncTriggerState().runner = fn;8994 }9095 96+export function setSyncDialTargetSelector(97+ fn: SelectSyncDialTargets | null,98+): void {99+ getSyncTriggerState().selectDialTargets = fn;100+}101+91102 export function _resetSyncTriggerForTest(): void {92103 const state = getSyncTriggerState();93104 state.syncInFlight = false;94105 state.lastState = { status: "idle" };95106 state.runner = null;107+ state.selectDialTargets = null;96108 }97109 98110 export function getSyncState(): SyncState {@@ -148,19 +160,23 @@148160 149161 try {150162 const devices = await listPairedDevices();151- // Only native peers can accept a connection, so only they are sync hosts;152- // another extension is reachable solely via a relay, which is the relaying153- // device's job, not ours.154- //155- // "vouched" belongs here alongside "direct": a vouched device is one the156- // user has already confirmed out of the pending queue (MV-7), and the whole157- // point of mesh introduction is that it becomes reachable without its own158- // pairing code. Excluding it left confirmed devices permanently unsynced.159- const hosts = devices.filter(163+ const routeCapable = devices.filter(160164 (d) =>161- (d.deviceType === "android" || d.deviceType === "ios") &&165+ (166+ d.deviceType === "extension" ||167+ d.deviceType === "android" ||168+ d.deviceType === "ios"169+ ) &&162170 (d.trustSource === "direct" || d.trustSource === "vouched"),163171 );172+ // Production resolves the symmetric key-order role here: lower static key173+ // dials, higher static key is kept online by mesh-relay-listener. Tests174+ // without the production selector retain the pre-mesh native-host default.175+ const hosts = state.selectDialTargets176+ ? await state.selectDialTargets(routeCapable)177+ : routeCapable.filter(178+ (d) => d.deviceType === "android" || d.deviceType === "ios",179+ );164180 if (devices.length === 0) {165181 finish({ outcome: "no_paired_devices" });166182 return;src/background/sync-vault-adapter.test.ts+34 −15
@@ -1,6 +1,7 @@11 import { beforeEach, describe, expect, it, vi } from "vitest";22 33 type StorageShape = Record<string, unknown>;4+const MESH_RELAY = "wss://relay.peachpasswords.com";45 56 function makeChromeMock(local: StorageShape, sync: StorageShape): typeof chrome {67 const area = (store: StorageShape) => ({@@ -305,6 +306,7 @@305306 longTermPublicKeyHex: "a".repeat(64),306307 signingPublicKeyHex: await signingPubkeyHex("android-1"),307308 wsPort: 49152,309+ relayUrl: MESH_RELAY,308310 pairedAt: 1_700_000_000_000,309311 trustSource: "direct",310312 });@@ -327,10 +329,11 @@327329 deviceType: "android",328330 longTermPublicKeyHex: "a".repeat(64),329331 wsPort: 49152,332+ relayUrl: MESH_RELAY,330333 });331334 });332335 333- it("MV-7: applyIncomingDeviceManifest queues unknown vouched devices for confirmation and updates existing ones in place", async () => {336+ it("v3 auto-admits same-vault manifest peers with an independent mesh route", async () => {334337 const { adapter } = await setupVault();335338 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");336339 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");@@ -374,6 +377,7 @@374377 longTermPublicKeyHex: "e".repeat(64),375378 signingPublicKeyHex: await signingPubkeyHex("android-new"),376379 wsPort: 51234,380+ relayUrl: MESH_RELAY,377381 pairedAt: 1_700_000_000_020,378382 },379383 {@@ -383,6 +387,7 @@383387 longTermPublicKeyHex: "f".repeat(64),384388 signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),385389 wsPort: 52345,390+ relayUrl: MESH_RELAY,386391 pairedAt: 1_700_000_000_030,387392 },388393 ],@@ -394,16 +399,14 @@394399 new Uint8Array(32).fill(0xcc),395400 );396401 397- // MV-7: the never-seen device is NOT auto-paired; it waits in the pending398- // queue for explicit user confirmation.399- expect(await findPairedDeviceById("android-new")).toBeNull();400- const pending = await listPendingVouchedDevices();401- expect(pending.map((d) => d.deviceId)).toEqual(["android-new"]);402- expect(pending[0]).toMatchObject({402+ expect(await findPairedDeviceById("android-new")).toMatchObject({403403 deviceName: "New Pixel",404- longTermPublicKeyHex: "e".repeat(64),405- vouchedByDeviceId: "extension-issuer",404+ trustSource: "vouched",405+ relayUrl: MESH_RELAY,406+ rendezvousSecretHex: expect.stringMatching(/^[0-9a-f]{64}$/),406407 });408+ const pending = await listPendingVouchedDevices();409+ expect(pending).toEqual([]);407410 408411 // The already-paired device is still updated in place — but S5: the409412 // third-party issuer does NOT get to rename it. Keys and ports propagate;@@ -417,6 +420,7 @@417420 });418421 expect((await listPairedDevices()).map((d) => d.deviceId).sort()).toEqual([419422 "android-existing",423+ "android-new",420424 "extension-issuer",421425 ]);422426 });@@ -720,6 +724,7 @@720724 deviceType: "android",721725 longTermPublicKeyHex: "2".repeat(64),722726 signingPublicKeyHex: await signingPubkeyHex("mesh-peer"),727+ relayUrl: MESH_RELAY,723728 pairedAt: 1_700_000_000_010,724729 }],725730 mobileIssuerKp.privateKey,@@ -729,8 +734,12 @@729734 new Uint8Array(32).fill(0x11),730735 );731736 732- expect(await findPairedDeviceById("mesh-peer")).toBeNull();733- expect((await listPendingVouchedDevices()).map((d) => d.deviceId)).toEqual(["mesh-peer"]);737+ expect(await findPairedDeviceById("mesh-peer")).toMatchObject({738+ trustSource: "vouched",739+ relayUrl: MESH_RELAY,740+ rendezvousSecretHex: expect.stringMatching(/^[0-9a-f]{64}$/),741+ });742+ expect(await listPendingVouchedDevices()).toEqual([]);734743 });735744 736745 it("applyIncomingDeviceManifest ignores vouched issuers and bad signatures", async () => {@@ -854,6 +863,7 @@854863 deviceType: "android",855864 longTermPublicKeyHex: "5".repeat(64),856865 signingPublicKeyHex: await signingPubkeyHex("smuggled"),866+ relayUrl: MESH_RELAY,857867 pairedAt: 3,858868 }],859869 betaKp.privateKey,@@ -866,7 +876,12 @@866876 // Same manifest, same devices, delivered over BETA's session: it must land.867877 // Without this control the assertion above could pass for any reason.868878 await adapter.applyIncomingDeviceManifest?.(fromBeta, new Uint8Array(32).fill(0xbb));869- expect((await listPendingVouchedDevices()).map((d) => d.deviceId)).toEqual(["smuggled"]);879+ const { findPairedDeviceById } = await import("./paired-devices");880+ expect(await findPairedDeviceById("smuggled")).toMatchObject({881+ trustSource: "vouched",882+ relayUrl: MESH_RELAY,883+ });884+ expect(await listPendingVouchedDevices()).toEqual([]);870885 });871886 872887 it("S1: a revoked long-term key cannot walk back in under a fresh deviceId", async () => {@@ -919,6 +934,7 @@919934 deviceType: "extension",920935 longTermPublicKeyHex: stolenKey,921936 signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),937+ relayUrl: MESH_RELAY,922938 pairedAt: Date.now(),923939 }],924940 issuerKp.privateKey,@@ -937,14 +953,17 @@937953 deviceType: "extension",938954 longTermPublicKeyHex: "9".repeat(64),939955 signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),956+ relayUrl: MESH_RELAY,940957 pairedAt: Date.now(),941958 }],942959 issuerKp.privateKey,943960 );944961 await adapter.applyIncomingDeviceManifest?.(clean, new Uint8Array(32).fill(0xcc));945- expect((await listPendingVouchedDevices()).map((d) => d.deviceId)).toEqual([946- "stolen-laptop-mk2",947- ]);962+ expect(await findPairedDeviceById("stolen-laptop-mk2")).toMatchObject({963+ trustSource: "vouched",964+ relayUrl: MESH_RELAY,965+ });966+ expect(await listPendingVouchedDevices()).toEqual([]);948967 });949968 950969 it("S1: a revoked key cannot be written onto a device we still trust", async () => {src/background/sync-vault-adapter.ts+155 −112
@@ -28,6 +28,7 @@2828 import { getOrCreateDeviceId } from "./device-identity";2929 import {3030 computeVaultId,31+ deriveSyncIdentityKeypair,3132 deriveSyncSigningKeypair,3233 hexToPubkey,3334 pubkeyToHex,@@ -57,9 +58,13 @@5758 upsertPairedDevice,5859 } from "./paired-devices";5960 import {60- addPendingVouchedDevice,6161 removePendingVouchedDevice,6262 } from "./pending-vouched-devices";63+import {64+ bytesToHex as rendezvousBytesToHex,65+ deriveMeshRendezvousSecret,66+ normalizeRelayUrl,67+} from "../shared/rendezvous";6368 import { recordSyncAlerts } from "./sync-alerts";6469 import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";6570 import type { VaultEntry } from "../shared/types";@@ -243,6 +248,7 @@243248 longTermPublicKeyHex: device.longTermPublicKeyHex,244249 signingPublicKeyHex: device.signingPublicKeyHex,245250 wsPort: device.wsPort,251+ relayUrl: device.relayUrl,246252 pairedAt: device.pairedAt,247253 };248254 }@@ -251,16 +257,13 @@251257 * Apply a device manifest issued by a peer we pair with directly.252258 *253259 * The issuer used to be required to be an "extension", on the assumption that254- * the extension was the mesh's trust anchor. It is not: the browser can never255- * listen for a connection, so the device it joins the mesh THROUGH is a native256- * peer (a phone), and that peer is the one holding the peer list to hand over.257- * Rejecting its manifest made mesh introduction impossible in the direction it258- * actually happens.260+ * the extension was the mesh's trust anchor. It is not: the device a fresh261+ * install joins through is the one holding the peer list to hand over.259262 *260263 * Trust is not widened by that change. The issuer must still be a DIRECTLY261- * paired device — a vouched peer cannot vouch onward — and the MV-7 gate below262- * still routes any device we have never seen into the pending queue for263- * explicit user confirmation rather than trusting it on the signature alone.264+ * paired device and a vouched peer cannot vouch onward. A v3 entry is admitted265+ * only when it also carries a valid relay route; both endpoints independently266+ * derive the same pair-only capability from their vault root and public keys.264267 */265268 async function applyVerifiedPeerManifest(266269 manifest: SignedDeviceManifest,@@ -294,127 +297,167 @@294297 if (body.issuerDeviceId !== issuer.deviceId) return;295298 296299 const ourDeviceId = await getOrCreateDeviceId();300+ const masterKey = requireMasterKey();301+ const localKeys = deriveSyncIdentityKeypair(masterKey, ourDeviceId);297302 298- // Revocations are absorbed BEFORE any vouching, so a manifest that both299- // revokes and re-vouches the same device in one payload cannot race itself.300- // A peer may not revoke US — that would let one compromised device sever the301- // whole mesh — so our own id is filtered out first.302- const newlyRevoked = await mergeDeviceRevocations(303- body.revoked.filter((r) => r.deviceId !== ourDeviceId),304- );305- for (const deviceId of newlyRevoked) {306- await removePairedDeviceById(deviceId);307- await removePendingVouchedDevice(deviceId);308- void recordSyncAlerts([309- {310- type: "DEVICE_REVOKED",311- entryId: "",312- entryTitle: deviceId,313- deviceName: issuer.deviceName ?? "paired device",314- timestamp: Date.now(),315- },316- ]);317- }318-319- for (const entry of body.devices) {320- if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {321- continue;303+ try {304+ // Revocations are absorbed BEFORE any vouching, so a manifest that both305+ // revokes and re-vouches the same device in one payload cannot race itself.306+ // A peer may not revoke US — that would let one compromised device sever the307+ // whole mesh — so our own id is filtered out first.308+ const newlyRevoked = await mergeDeviceRevocations(309+ body.revoked.filter((r) => r.deviceId !== ourDeviceId),310+ );311+ for (const deviceId of newlyRevoked) {312+ await removePairedDeviceById(deviceId);313+ await removePendingVouchedDevice(deviceId);314+ void recordSyncAlerts([315+ {316+ type: "DEVICE_REVOKED",317+ entryId: "",318+ entryTitle: deviceId,319+ deviceName: issuer.deviceName ?? "paired device",320+ timestamp: Date.now(),321+ },322+ ]);322323 }323- // Terminal: no manifest re-admits a revoked device. Matched on the key as324- // well as the id, because the id is the issuer's to choose — checking only325- // the id let a revoked key walk back in under a fresh one. The check sits326- // ahead of the existing-device lookup so it also refuses rotating a device327- // we still trust onto a revoked key.328- if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) {329- continue;330- }331- const existing = await findPairedDeviceById(entry.deviceId);332324 333- // MV-7: a device we have NEVER seen does not become a sync target on334- // the strength of the manifest signature alone. Queue it for explicit335- // user confirmation; until then it cannot push entries or tombstones336- // at us (it is absent from the paired-devices registry, so the runner337- // never opens a session to it and inbound static keys don't match).338- if (!existing) {339- await addPendingVouchedDevice({325+ for (const entry of body.devices) {326+ if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {327+ continue;328+ }329+ // Terminal: no manifest re-admits a revoked device. Matched on the key as330+ // well as the id, because the id is the issuer's to choose — checking only331+ // the id let a revoked key walk back in under a fresh one. The check sits332+ // ahead of the existing-device lookup so it also refuses rotating a device333+ // we still trust onto a revoked key.334+ if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) {335+ continue;336+ }337+ const existing = await findPairedDeviceById(entry.deviceId);338+ const meshRoute = deriveManifestMeshRoute(339+ masterKey,340+ localKeys.publicKey,341+ entry,342+ );343+344+ // A v3 manifest from a DIRECT, KK-authenticated issuer proves same-vault345+ // membership and carries enough route material for the two introduced346+ // endpoints to derive their own capability. Admit it immediately so a347+ // six-device vault converges to one graph instead of requiring ten manual348+ // confirmations on every fresh install.349+ if (!existing) {350+ if (!meshRoute) continue;351+ await upsertPairedDevice({352+ deviceId: entry.deviceId,353+ deviceName: entry.deviceName,354+ deviceType: entry.deviceType,355+ longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),356+ signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),357+ wsPort: entry.wsPort,358+ rendezvousSecretHex: meshRoute.rendezvousSecretHex,359+ relayUrl: meshRoute.relayUrl,360+ pairedAt: entry.pairedAt,361+ trustSource: "vouched",362+ });363+ await removePendingVouchedDevice(entry.deviceId);364+ continue;365+ }366+367+ // MV-8d: a key changing under a stable deviceId is legitimate after a368+ // reinstall, but it is exactly what a graph-poisoning attack looks like —369+ // signal the user either way, durably (the alert queue survives a closed370+ // popup) and live (the broadcast updates an open one).371+ if (372+ existing.longTermPublicKeyHex &&373+ !constantTimeEqualHex(374+ existing.longTermPublicKeyHex,375+ entry.longTermPublicKeyHex,376+ )377+ ) {378+ // S5: name the device from the LOCAL record, never from the manifest.379+ // The issuer is a third party here (its own entry is skipped above), so380+ // `entry.deviceName` is an unverified claim about someone else — an381+ // attacker who could put it on screen could make its own key rotation382+ // read as a rotation on a device the user trusts more.383+ const knownName = existing.deviceName ?? entry.deviceName;384+ notifyPopup({385+ type: "DEVICE_KEY_ROTATED",386+ payload: { deviceName: knownName, deviceId: entry.deviceId },387+ });388+ void recordSyncAlerts([389+ {390+ type: "DEVICE_KEY_ROTATED",391+ entryId: "",392+ entryTitle: knownName,393+ deviceName: knownName,394+ timestamp: Date.now(),395+ },396+ ]);397+ }398+399+ // Existing device — update in place as before. The name is deliberately400+ // NOT taken from the manifest: a device's name is set by that device at401+ // pairing, and letting a third-party issuer rewrite it is the rename-then-402+ // impersonate half of S5. Self-renames never arrive here anyway.403+ await upsertPairedDevice({404+ ...existing,340405 deviceId: entry.deviceId,341- deviceName: entry.deviceName,406+ deviceName: existing.deviceName ?? entry.deviceName,342407 deviceType: entry.deviceType,343408 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),344409 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),345- wsPort: entry.wsPort,346- pairedAt: entry.pairedAt,347- receivedAt: Date.now(),348- vouchedByDeviceId: issuer.deviceId,410+ pairedAt: existing.pairedAt ?? entry.pairedAt,411+ trustSource: existing.trustSource ?? "direct",412+ wsPort: existing.wsPort ?? entry.wsPort,413+ rendezvousSecretHex:414+ existing.trustSource === "vouched" && meshRoute415+ ? meshRoute.rendezvousSecretHex416+ : existing.rendezvousSecretHex,417+ relayUrl:418+ existing.trustSource === "vouched" && meshRoute419+ ? meshRoute.relayUrl420+ : existing.relayUrl,421+ lastSyncedAt: existing.lastSyncedAt,349422 });350- // No local record exists yet, so this name is the issuer's unverified351- // claim and nothing better is available. It reaches a screen the user352- // must explicitly confirm — treat it as untrusted display text there.353- notifyPopup({354- type: "VOUCHED_DEVICE_PENDING",355- payload: { deviceName: entry.deviceName, deviceId: entry.deviceId },356- });357- continue; // not trusted yet358423 }424+ } finally {425+ masterKey.fill(0);426+ localKeys.secretKey.fill(0);427+ }428+}359429 360- // MV-8d: a key changing under a stable deviceId is legitimate after a361- // reinstall, but it is exactly what a graph-poisoning attack looks like —362- // signal the user either way, durably (the alert queue survives a closed363- // popup) and live (the broadcast updates an open one).364- if (365- existing.longTermPublicKeyHex &&366- !constantTimeEqualHex(367- existing.longTermPublicKeyHex,368- entry.longTermPublicKeyHex,369- )370- ) {371- // S5: name the device from the LOCAL record, never from the manifest.372- // The issuer is a third party here (its own entry is skipped above), so373- // `entry.deviceName` is an unverified claim about someone else — an374- // attacker who could put it on screen could make its own key rotation375- // read as a rotation on a device the user trusts more.376- const knownName = existing.deviceName ?? entry.deviceName;377- notifyPopup({378- type: "DEVICE_KEY_ROTATED",379- payload: { deviceName: knownName, deviceId: entry.deviceId },380- });381- void recordSyncAlerts([382- {383- type: "DEVICE_KEY_ROTATED",384- entryId: "",385- entryTitle: knownName,386- deviceName: knownName,387- timestamp: Date.now(),388- },389- ]);430+function deriveManifestMeshRoute(431+ masterKey: Uint8Array,432+ localPublicKey: Uint8Array,433+ entry: ManifestDeviceEntry,434+): { rendezvousSecretHex: string; relayUrl: string } | null {435+ try {436+ if (!entry.relayUrl) return null;437+ const relayUrl = normalizeRelayUrl(entry.relayUrl);438+ const secret = deriveMeshRendezvousSecret(439+ masterKey,440+ localPublicKey,441+ hexToPubkey(entry.longTermPublicKeyHex),442+ );443+ try {444+ return {445+ rendezvousSecretHex: rendezvousBytesToHex(secret),446+ relayUrl,447+ };448+ } finally {449+ secret.fill(0);390450 }391-392- // Existing device — update in place as before. The name is deliberately393- // NOT taken from the manifest: a device's name is set by that device at394- // pairing, and letting a third-party issuer rewrite it is the rename-then-395- // impersonate half of S5. Self-renames never arrive here anyway.396- await upsertPairedDevice({397- ...existing,398- deviceId: entry.deviceId,399- deviceName: existing.deviceName ?? entry.deviceName,400- deviceType: entry.deviceType,401- longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),402- signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),403- pairedAt: existing.pairedAt ?? entry.pairedAt,404- trustSource: existing.trustSource ?? "direct",405- wsPort: existing.wsPort ?? entry.wsPort,406- lastSyncedAt: existing.lastSyncedAt,407- });451+ } catch {452+ return null;408453 }409454 }410455 411-// MV-7: fire-and-forget broadcast to the popup. The popup may be closed,412-// which is fine — the pending-vouched queue is the durable signal.413456 function notifyPopup(msg: { type: string; payload?: unknown }): void {414457 try {415458 void chrome.runtime.sendMessage(msg).catch(() => undefined);416459 } catch {417- /* popup closed — pending queue is the durable signal */460+ /* popup closed — the durable sync-alert queue remains authoritative */418461 }419462 }420463 src/shared/device-manifest.test.ts+6 −3
@@ -23,6 +23,7 @@2323 deviceType: "android",2424 longTermPublicKeyHex: "a".repeat(64),2525 signingPublicKeyHex: "b".repeat(64),26+ relayUrl: "wss://relay.peachpasswords.com",2627 pairedAt: 1_700_000_000_000,2728 };2829 }@@ -134,7 +135,7 @@134135 });135136 });136137 137-describe("manifest v0x02 canonical form — cross-platform KAT", () => {138+describe("manifest v0x03 canonical form — cross-platform KAT", () => {138139 // Pinned byte-for-byte against peach-android's canonicalDeviceManifestBody.139140 // If either side reorders a key, renames a field, or changes number140141 // formatting, this string moves and the two stop verifying each other's@@ -142,15 +143,16 @@142143 const CANONICAL =143144 '{"devices":[{"deviceId":"peer-1","deviceName":"Peer One","deviceType":"android",' +144145 '"longTermPublicKeyHex":"' + "aa".repeat(32) + '","pairedAt":1700000000001,' +146+ '"relayUrl":"wss://relay.peachpasswords.com",' +145147 '"signingPublicKeyHex":"' + "bb".repeat(32) + '","wsPort":49142}],' +146- '"issuedAt":1700000000000,"issuerDeviceId":"issuer-1","manifestVersion":2,' +148+ '"issuedAt":1700000000000,"issuerDeviceId":"issuer-1","manifestVersion":3,' +147149 '"revoked":[{"deviceId":"gone-1","longTermPublicKeyHex":"' + "cc".repeat(32) + '",' +148150 '"revokedAt":1700000000002,"revokedByDeviceId":"issuer-1"}]}';149151 150152 it("canonicalises a manifest body carrying a revocation to the pinned string", () => {151153 expect(152154 canonicalise({153- manifestVersion: 2,155+ manifestVersion: 3,154156 issuerDeviceId: "issuer-1",155157 issuedAt: 1_700_000_000_000,156158 devices: [@@ -161,6 +163,7 @@161163 longTermPublicKeyHex: "aa".repeat(32),162164 signingPublicKeyHex: "bb".repeat(32),163165 pairedAt: 1_700_000_000_001,166+ relayUrl: "wss://relay.peachpasswords.com",164167 wsPort: 49142,165168 },166169 ],src/shared/device-manifest.ts+13 −3
@@ -5,12 +5,13 @@55 // Manifests are exchanged after the entry sync completes during a normal66 // `SyncSession`, encrypted under reconnect Noise KK along with everything else.77 //8-// Purpose: propagate trust between devices without requiring additional audio9-// pairing sessions. Any DIRECTLY paired device may issue a manifest — the8+// Purpose: propagate trust between devices without repeating first-pair9+// confirmation. Any DIRECTLY paired device may issue a manifest — the1010 // browser cannot listen for connections, so the peer it joins the mesh through1111 // is a phone, and that phone is the one holding the peer list to hand over.1212 // The receiver verifies the signature against that issuer's known Ed2551913-// public key and queues any newly-vouched entries for user confirmation.13+// public key. A v3 entry that carries a valid relay route is auto-admitted as14+// same-vault "vouched" state; it still cannot vouch for another device.1415 // See docs/mesh-vouching-authority.md for the full authority trace.1516 //1617 // Security properties:@@ -31,6 +32,7 @@3132 type SignedDeviceManifest,3233 } from "./sync-types";3334 import { signEd25519, verifyEd25519 } from "./sync-identity";35+import { normalizeRelayUrl } from "./rendezvous";3436 3537 export class DeviceManifestError extends Error {3638 readonly kind:@@ -159,6 +161,13 @@159161 ) {160162 throw new DeviceManifestError("malformed", "device.wsPort invalid");161163 }164+ if (d.relayUrl !== undefined) {165+ try {166+ normalizeRelayUrl(d.relayUrl);167+ } catch {168+ throw new DeviceManifestError("malformed", "device.relayUrl invalid");169+ }170+ }162171 }163172 164173 if (!Array.isArray(manifest.body.revoked)) {@@ -209,6 +218,7 @@209218 pairedAt: d.pairedAt,210219 };211220 if (d.wsPort !== undefined) normalised.wsPort = d.wsPort;221+ if (d.relayUrl !== undefined) normalised.relayUrl = normalizeRelayUrl(d.relayUrl);212222 return normalised;213223 }214224 src/shared/mesh-peer-role.ts+59 −0
@@ -0,0 +1,59 @@1+import type { PairedDevice } from "./sync-types";2+3+export const MESH_WAITER_RETRY_DELAY_MS = 2_000;4+export const MESH_WAITER_MAX_RETRY_DELAY_MS = 30_000;5+6+export function isRouteCapableMeshPeer(device: PairedDevice): boolean {7+ const trusted =8+ device.trustSource === undefined ||9+ device.trustSource === "direct" ||10+ device.trustSource === "vouched";11+ return (12+ trusted &&13+ (device.deviceType === "extension" ||14+ device.deviceType === "android" ||15+ device.deviceType === "ios") &&16+ typeof device.longTermPublicKeyHex === "string" &&17+ /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) &&18+ typeof device.rendezvousSecretHex === "string" &&19+ /^[0-9a-f]{64}$/.test(device.rendezvousSecretHex)20+ );21+}22+23+/** Lower public key dials; higher public key waits. */24+export function weDialMeshPeer(25+ ourLongTermPublicKeyHex: string,26+ peerLongTermPublicKeyHex: string,27+): boolean {28+ if (29+ !/^[0-9a-f]{64}$/.test(ourLongTermPublicKeyHex) ||30+ !/^[0-9a-f]{64}$/.test(peerLongTermPublicKeyHex) ||31+ ourLongTermPublicKeyHex === peerLongTermPublicKeyHex32+ ) {33+ return false;34+ }35+ return ourLongTermPublicKeyHex < peerLongTermPublicKeyHex;36+}37+38+export function selectDialTargets(39+ devices: PairedDevice[],40+ ourLongTermPublicKeyHex: string,41+): PairedDevice[] {42+ return devices.filter(43+ (device) =>44+ isRouteCapableMeshPeer(device) &&45+ weDialMeshPeer(46+ ourLongTermPublicKeyHex,47+ device.longTermPublicKeyHex!,48+ ),49+ );50+}51+52+export function nextMeshWaiterRetryDelay(53+ current: number,54+ succeeded: boolean,55+): number {56+ return succeeded57+ ? MESH_WAITER_RETRY_DELAY_MS58+ : Math.min(current * 2, MESH_WAITER_MAX_RETRY_DELAY_MS);59+}src/shared/rendezvous.test.ts+14 −0
@@ -4,6 +4,7 @@44 DEFAULT_RELAY_URL,55 buildRendezvousUrl,66 bytesToHex,7+ deriveMeshRendezvousSecret,78 deriveRendezvousId,89 deriveRendezvousSecret,910 dialingRendezvousIds,@@ -19,6 +20,19 @@1920 );2021 2122 describe("relay rendezvous contract", () => {23+ it("derives the symmetric cross-client mesh capability vector", () => {24+ const root = Uint8Array.from({ length: 32 }, (_, i) => i);25+ const low = Uint8Array.from({ length: 32 }, (_, i) => i + 32);26+ const high = Uint8Array.from({ length: 32 }, (_, i) => i + 64);27+ const forward = deriveMeshRendezvousSecret(root, low, high);28+ const reverse = deriveMeshRendezvousSecret(root, high, low);29+30+ expect(bytesToHex(forward)).toBe(31+ "ad0359be7dc8fbb7df19ed027354b5029133a23a6023f43c2b3500b43addae7b",32+ );33+ expect(reverse).toEqual(forward);34+ });35+2236 it("derives the cross-platform secret and hourly id KATs", () => {2337 const secret = deriveRendezvousSecret(HANDSHAKE_HASH);2438 expect(bytesToHex(secret)).toBe(src/shared/rendezvous.ts+43 −0
@@ -6,6 +6,7 @@66 export const PAIRING_RENDEZVOUS_TOKEN_BYTES = 16;77 88 const SECRET_INFO = new TextEncoder().encode("peach-rendezvous-secret-v1");9+const MESH_SECRET_INFO = new TextEncoder().encode("peach-mesh-rendezvous-v1");910 const WINDOW_INFO = new TextEncoder().encode("peach-rendezvous-v1");1011 const EMPTY_SALT = new Uint8Array(0);1112 @@ -18,6 +19,41 @@1819 }1920 2021 /**22+ * Derive the reconnect capability for a manifest-introduced device pair.23+ *24+ * Cross-client encoding contract:25+ * low = lexicographically smaller 32-byte X25519 public key26+ * high = lexicographically larger 32-byte X25519 public key27+ * info = UTF8("peach-mesh-rendezvous-v1") || low || high28+ * secret = HKDF-SHA256(vaultRoot, salt="", info, 32)29+ */30+export function deriveMeshRendezvousSecret(31+ vaultRoot: Uint8Array,32+ ourPublicKey: Uint8Array,33+ peerPublicKey: Uint8Array,34+): Uint8Array {35+ if (vaultRoot.length !== 32) {36+ throw new Error("vault root must be exactly 32 bytes");37+ }38+ if (ourPublicKey.length !== 32 || peerPublicKey.length !== 32) {39+ throw new Error("mesh public keys must be exactly 32 bytes");40+ }41+ const order = compareBytes(ourPublicKey, peerPublicKey);42+ if (order === 0) {43+ throw new Error("mesh peers must have distinct public keys");44+ }45+ const [low, high] =46+ order < 047+ ? [ourPublicKey, peerPublicKey]48+ : [peerPublicKey, ourPublicKey];49+ const info = new Uint8Array(MESH_SECRET_INFO.length + 64);50+ info.set(MESH_SECRET_INFO, 0);51+ info.set(low, MESH_SECRET_INFO.length);52+ info.set(high, MESH_SECRET_INFO.length + 32);53+ return new HKDF(SHA256, vaultRoot, EMPTY_SALT, info).expand(32);54+}55+56+/**2157 * Derive one opaque hourly relay capability.2258 *2359 * Cross-client encoding contract:@@ -120,4 +156,11 @@120156 let binary = "";121157 for (const byte of bytes) binary += String.fromCharCode(byte);122158 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");159+}160+161+function compareBytes(a: Uint8Array, b: Uint8Array): number {162+ for (let i = 0; i < a.length; i += 1) {163+ if (a[i] !== b[i]) return a[i]! < b[i]! ? -1 : 1;164+ }165+ return 0;123166 }src/shared/sync-kk-transport.test.ts+94 −1
@@ -3,6 +3,7 @@33 import { noiseKKResponder } from "./noise-js";44 import { padSyncFrame, unpadSyncFrame } from "./sync-padding";55 import {6+ openRelaySyncResponderSession,67 openRelaySyncSession,78 openSyncSession,89 type SyncPeerIdentity,@@ -106,6 +107,99 @@106107 afterEach(() => vi.unstubAllGlobals());107108 108109 describe("KK WebSocket reconnect transport", () => {110+ it("opens a browser waiter and completes a direct extension-to-extension KK session", async () => {111+ const initiatorKeys = generateKeyPair();112+ const responderKeys = generateKeyPair();113+ type MockSocket = {114+ peer?: MockSocket;115+ binaryType: string;116+ readyState: number;117+ onopen: ((event: Event) => void) | null;118+ onmessage: ((event: MessageEvent) => void) | null;119+ onerror: (() => void) | null;120+ onclose: ((event: CloseEvent) => void) | null;121+ send(data: Uint8Array | ArrayBuffer): void;122+ close(): void;123+ };124+ let waiting: MockSocket | undefined;125+ const WebSocketMock = vi.fn().mockImplementation(function() {126+ const socket: MockSocket = {127+ binaryType: "arraybuffer",128+ readyState: 1,129+ onopen: null,130+ onmessage: null,131+ onerror: null,132+ onclose: null,133+ send(data) {134+ const frame = data instanceof Uint8Array135+ ? data.slice()136+ : new Uint8Array(data.slice(0));137+ if (frame.byteLength === 0) return;138+ const target = socket.peer;139+ if (!target) return;140+ queueMicrotask(() => target.onmessage?.({141+ data: frame.buffer,142+ } as MessageEvent));143+ },144+ close() {145+ socket.readyState = 3;146+ queueMicrotask(() => socket.onclose?.({ code: 1000 } as CloseEvent));147+ },148+ };149+ if (waiting) {150+ socket.peer = waiting;151+ waiting.peer = socket;152+ waiting = undefined;153+ } else {154+ waiting = socket;155+ }156+ queueMicrotask(() => socket.onopen?.(new Event("open")));157+ return socket;158+ });159+ Object.assign(WebSocketMock, { OPEN: 1 });160+ vi.stubGlobal("WebSocket", WebSocketMock);161+162+ const initiatorIdentity: SyncPeerIdentity = {163+ deviceId: "firefox-a",164+ deviceName: "Firefox A",165+ deviceType: "extension",166+ longTermPublicKeyHex: hex(initiatorKeys.publicKey),167+ longTermPrivateKey: initiatorKeys.secretKey,168+ };169+ const responderIdentity: SyncPeerIdentity = {170+ deviceId: "firefox-c",171+ deviceName: "Firefox C",172+ deviceType: "extension",173+ longTermPublicKeyHex: hex(responderKeys.publicKey),174+ longTermPrivateKey: responderKeys.secretKey,175+ };176+177+ const responderPromise = openRelaySyncResponderSession(178+ "wss://relay.example.test",179+ responderIdentity,180+ initiatorIdentity.longTermPublicKeyHex,181+ RENDEZVOUS_ID,182+ { keepaliveMs: 1_000_000 },183+ );184+ await new Promise((resolve) => setTimeout(resolve, 0));185+ const initiatorPromise = openRelaySyncSession(186+ "wss://relay.example.test",187+ initiatorIdentity,188+ responderIdentity.longTermPublicKeyHex,189+ RENDEZVOUS_ID,190+ );191+ const [initiatorSession, responderSession] = await Promise.all([192+ initiatorPromise,193+ responderPromise,194+ ]);195+196+ await initiatorSession.send(new TextEncoder().encode("direct mesh"));197+ await expect(responderSession.receive()).resolves.toEqual(198+ new TextEncoder().encode("direct mesh"),199+ );200+ await Promise.all([initiatorSession.close(), responderSession.close()]);201+ });202+109203 it("selects paired state then completes KK with both keys pinned", async () => {110204 const peer = installPeer();111205 const session = await openSyncSession(@@ -291,4 +385,3 @@291385 expect(Array.from(unpadSyncFrame(joined))).toEqual(Array.from(payload));292386 });293387 });294-src/shared/sync-transport.ts+185 −5
@@ -1,9 +1,11 @@11 // WebSocket reconnect transport — Noise KK with both paired static keys.22 // First pairing remains Noise NK; reconnect never negotiates or falls back.33 //4-// The extension is always the WebSocket client; Android (or future iOS /5-// native sync hosts) is always the server. Each binary WebSocket message6-// carries exactly one Noise frame — no length prefix, no fragmentation.4+// The lower long-term public key is the WebSocket/Noise initiator; the higher5+// key holds the relay room as responder. Device type does not affect the role,6+// so two extensions can sync directly without a native hub. Each binary7+// WebSocket message carries one Noise frame (large padded application frames8+// are split across multiple Noise messages below).79 //810 // On connect:911 // 1. Open ws://<ip>:<port>/@@ -21,7 +23,11 @@2123 // (~500ms per IP) and parallel batches of 16 to keep the worst-case scan2224 // under ~10 seconds.2325 24-import { noiseKKInitiator, type NoiseNKSession } from "./noise-js";26+import {27+ noiseKKInitiator,28+ noiseKKResponder,29+ type NoiseNKSession,30+} from "./noise-js";2531 import { SYNC_LIMITS } from "./sync-limits";2632 import {2733 padSyncFrame,@@ -56,6 +62,7 @@5662 const DEFAULT_PROBE_BATCH = 16;5763 const DEFAULT_CONNECT_TIMEOUT_MS = 4_000;5864 const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;65+export const RELAY_WAITER_KEEPALIVE_MS = 20_000;5966 /** v2 brief §5.2 — subnet probe is rate-limited to once per 60 seconds. */6067 export const SUBNET_PROBE_COOLDOWN_MS = 60_000;6168 @@ -98,6 +105,7 @@98105 export interface SyncSessionOptions {99106 connectTimeoutMs?: number;100107 handshakeTimeoutMs?: number;108+ signal?: AbortSignal;101109 }102110 103111 function encodePairIdentity(id: SyncPeerIdentity): Uint8Array {@@ -157,6 +165,153 @@157165 );158166 }159167 168+/**169+ * Hold one relay room as the deterministic responder, then run Noise KK and170+ * the bound PAIR_IDENTITY exchange. The zero-length liveness frame keeps a171+ * Chrome 116+ MV3 service worker alive while the room is empty; the Peach172+ * relay consumes that frame locally and never forwards it to the peer.173+ */174+export async function openRelaySyncResponderSession(175+ relayUrl: string,176+ ourIdentity: SyncPeerIdentity,177+ expectedRemotePubkeyHex: string,178+ rendezvousId: string,179+ options: SyncSessionOptions & { keepaliveMs?: number } = {},180+): Promise<SyncSession> {181+ if (!/^[A-Za-z0-9_-]{22,128}$/.test(rendezvousId)) {182+ throw new Error("reconnect requires a valid rotating rendezvous id");183+ }184+ if (ourIdentity.longTermPrivateKey.length !== 32) {185+ throw new Error("local reconnect private key must be exactly 32 bytes");186+ }187+ const ws = await connectWebSocketUrl(188+ buildRendezvousUrl(relayUrl, rendezvousId),189+ "relay",190+ options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,191+ );192+ const inbox: Uint8Array[] = [];193+ const readers: Array<(b: Uint8Array) => void> = [];194+ let closed = false;195+ let closeError: Error = new Error("WebSocket closed");196+ const keepaliveMs = options.keepaliveMs ?? RELAY_WAITER_KEEPALIVE_MS;197+ const abortWaiter = () => {198+ try { ws.close(1000, "relay waiter replaced"); } catch { /* ignore */ }199+ };200+ const keepalive = setInterval(() => {201+ if (ws.readyState !== WebSocket.OPEN) return;202+ try { ws.send(new Uint8Array(0)); } catch { /* onclose owns cleanup */ }203+ }, keepaliveMs);204+205+ ws.binaryType = "arraybuffer";206+ ws.onmessage = (event: MessageEvent) => {207+ if (!(event.data instanceof ArrayBuffer)) return;208+ if (event.data.byteLength === 0) return;209+ if (event.data.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {210+ closeError = new SyncFrameTooLargeError(211+ `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,212+ );213+ try { ws.close(1009, "sync frame too large"); } catch { /* ignore */ }214+ return;215+ }216+ const bytes = new Uint8Array(event.data);217+ const reader = readers.shift();218+ if (reader) reader(bytes);219+ else inbox.push(bytes);220+ };221+ ws.onerror = () => {222+ try { ws.close(); } catch { /* ignore */ }223+ };224+ ws.onclose = (event: CloseEvent) => {225+ clearInterval(keepalive);226+ options.signal?.removeEventListener("abort", abortWaiter);227+ closed = true;228+ if (event.code === 1009) closeError = new SyncFrameTooLargeError();229+ while (readers.length > 0) readers.shift()!(new Uint8Array(0));230+ };231+ // Install close handling before honoring an already-aborted signal so a232+ // synchronous/mock close cannot strand the first receive() forever.233+ options.signal?.addEventListener("abort", abortWaiter, { once: true });234+ if (options.signal?.aborted) abortWaiter();235+236+ const send = async (bytes: Uint8Array): Promise<void> => {237+ if (bytes.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {238+ throw new SyncFrameTooLargeError(239+ `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,240+ );241+ }242+ ws.send(bytes as Uint8Array<ArrayBuffer>);243+ };244+ const receive = (): Promise<Uint8Array> => {245+ if (closed) return Promise.reject(closeError);246+ const buffered = inbox.shift();247+ if (buffered) return Promise.resolve(buffered);248+ return new Promise<Uint8Array>((resolve, reject) => {249+ readers.push((bytes) => {250+ if (bytes.length === 0 && closed) reject(closeError);251+ else resolve(bytes);252+ });253+ });254+ };255+256+ let noiseSession: NoiseNKSession | undefined;257+ try {258+ const selectorBytes = await receive();259+ const selector = JSON.parse(new TextDecoder().decode(selectorBytes));260+ if (261+ selector?.type !== "PEACH_RECONNECT" ||262+ typeof selector.rendezvous_id !== "string" ||263+ !equalAscii(selector.rendezvous_id, rendezvousId)264+ ) {265+ throw new Error("reconnect capability does not match relay room");266+ }267+ const handshakeTimer = setTimeout(() => {268+ closeError = new Error("sync_handshake_timeout");269+ try { ws.close(1000, "sync handshake timeout"); } catch { /* ignore */ }270+ }, options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS);271+ try {272+ const ourPublicKey = hexToPubkey(ourIdentity.longTermPublicKeyHex);273+ noiseSession = await noiseKKResponder(274+ ourIdentity.longTermPrivateKey,275+ ourPublicKey,276+ hexToPubkey(expectedRemotePubkeyHex),277+ send,278+ receive,279+ );280+ const theirFrame = JSON.parse(281+ new TextDecoder().decode(282+ unpadSyncFrame(noiseSession.decrypt(await receive())),283+ ),284+ );285+ if (theirFrame?.type !== "PAIR_IDENTITY") {286+ throw new Error(`expected PAIR_IDENTITY, got ${theirFrame?.type as string}`);287+ }288+ if (theirFrame.long_term_public_key_hex !== expectedRemotePubkeyHex) {289+ throw new Error("peer identity does not match authenticated KK key");290+ }291+ await send(noiseSession.encrypt(padSyncFrame(encodePairIdentity(ourIdentity))));292+ } finally {293+ clearTimeout(handshakeTimer);294+ }295+ } catch (error) {296+ clearInterval(keepalive);297+ options.signal?.removeEventListener("abort", abortWaiter);298+ noiseSession?.close();299+ try { ws.close(); } catch { /* ignore */ }300+ throw error;301+ }302+303+ return buildEncryptedSyncSession(304+ ws,305+ noiseSession,306+ expectedRemotePubkeyHex,307+ receive,308+ () => {309+ clearInterval(keepalive);310+ options.signal?.removeEventListener("abort", abortWaiter);311+ },312+ );313+}314+160315 async function openSyncSessionAtUrl(161316 url: string,162317 targetLabel: string,@@ -301,6 +456,21 @@301456 throw err;302457 }303458 459+ return buildEncryptedSyncSession(460+ ws,461+ session,462+ expectedRemotePubkeyHex,463+ receive,464+ );465+}466+467+function buildEncryptedSyncSession(468+ ws: WebSocket,469+ session: NoiseNKSession,470+ expectedRemotePubkeyHex: string,471+ receiveCiphertext: () => Promise<Uint8Array>,472+ beforeClose: () => void = () => undefined,473+): SyncSession {304474 return {305475 async send(plaintext: Uint8Array) {306476 // E7: bucket the size before encrypting, so the relay sees frame shapes@@ -348,7 +518,7 @@348518 // payload, so we read it there to learn the total.349519 let buffered: Uint8Array | null = null;350520 while (true) {351- const ct = await receive();521+ const ct = await receiveCiphertext();352522 const pt = session.decrypt(ct);353523 if (buffered === null && pt.byteLength > 0 && pt[0] === FRAME_CONTINUATION_MARKER) {354524 // Strip the continuation byte and keep the payload.@@ -375,11 +545,21 @@375545 }376546 },377547 async close() {548+ beforeClose();378549 try { ws.close(); } catch { /* ignore */ }379550 session.close();380551 },381552 remoteStaticPublicKey: hexToPubkey(expectedRemotePubkeyHex),382553 };554+}555+556+function equalAscii(left: string, right: string): boolean {557+ const a = new TextEncoder().encode(left);558+ const b = new TextEncoder().encode(right);559+ if (a.length !== b.length) return false;560+ let diff = 0;561+ for (let i = 0; i < a.length; i += 1) diff |= a[i] ^ b[i];562+ return diff === 0;383563 }384564 385565 function connectWebSocketUrl(src/shared/sync-types.ts+9 −6
@@ -30,8 +30,9 @@3030 *3131 * "direct" — established via a pairing-code session with this device.3232 * "vouched" — introduced by a directly-paired peer via a signed device33- * manifest during a normal sync, then confirmed by the user34- * (MV-7). Functionally equivalent to direct for sync purposes;33+ * manifest during a normal sync. A valid v3 route is admitted34+ * automatically as same-vault membership. It is equivalent to35+ * direct for sync, but cannot issue manifests itself;3536 * surfaced with an "Added automatically" badge in Settings.3637 */3738 export type TrustSource = "direct" | "vouched";@@ -126,10 +127,10 @@126127 // v3 device manifest — propagates trust between mobile devices (v2 brief §4.3)127128 // ---------------------------------------------------------------------------128129 129-/** 0x02 added the `revoked` array to the signed body. Coordinated wire change130- * with peach-android — the canonical signing input differs, so a 0x01 peer and131- * a 0x02 peer cannot verify each other's manifests. */132-export const DEVICE_MANIFEST_VERSION = 0x02 as const;130+/** 0x03 adds each peer edge's relay URL so a newly introduced pair can agree131+ * on a route without depending on the introducing device. Coordinated wire132+ * change with peach-android. */133+export const DEVICE_MANIFEST_VERSION = 0x03 as const;133134 /** Manifests older than this are silently rejected (v2 brief §4.3). */134135 export const DEVICE_MANIFEST_FRESHNESS_MS = 5 * 60_000;135136 @@ -152,6 +153,8 @@152153 * vouched entries lets mobile peers connect without another audio pairing.153154 */154155 wsPort?: number;156+ /** Relay the issuer currently uses for this peer edge. */157+ relayUrl?: string;155158 pairedAt: number;156159 }157160